diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 529ad46..ee386a4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,5 +1,5 @@ name: Bug report -description: Something in ForgeDialog behaves differently than documented +description: Something in Forge Dialog behaves differently than documented labels: ['bug'] body: - type: markdown @@ -12,7 +12,7 @@ body: - type: input id: version attributes: - label: ForgeDialog version + label: Forge Dialog version placeholder: 0.7.0 validations: required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 7857d7e..95edaf8 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,5 +1,5 @@ name: Feature request -description: Suggest a capability ForgeDialog does not have yet +description: Suggest a capability Forge Dialog does not have yet labels: ['enhancement'] body: - type: textarea @@ -33,7 +33,7 @@ body: attributes: label: Does this fit the project's constraints? description: | - ForgeDialog is dependency-free, framework-agnostic, accessible by default, + Forge Dialog is dependency-free, framework-agnostic, accessible by default, and holds its bundles to gzip budgets. Proposals that conflict with those are still worth raising — just say so. options: diff --git a/.gitignore b/.gitignore index b99dd22..6fa881d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,12 @@ temp # Claude Code .claude/ CLAUDE.md +site/vendor + +# Generated by scripts/build-site.mjs +site/robots.txt +site/sitemap.xml +site/llms.txt + +# Cloudflare Wrangler local state +.wrangler diff --git a/.prettierignore b/.prettierignore index acb948d..36270d1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,3 +6,8 @@ playwright-report test-results temp .consumer-builds +site/vendor +site/robots.txt +site/sitemap.xml +site/llms.txt +.wrangler diff --git a/AGENTS.md b/AGENTS.md index 2c5b715..a17277c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Project Structure & Module Organization -ForgeDialog is a framework-agnostic TypeScript dialog library. Public exports begin in `src/index.ts`. High-level helpers live in `src/api/`, dialog behavior and accessibility primitives in `src/core/`, plugin support in `src/plugins/`, localization defaults in `src/i18n/`, and CSS plus theme helpers in `src/theme/`. Shared public types are in `src/types.ts`, while small internal helpers belong in `src/utils/`. +Forge Dialog is a framework-agnostic TypeScript dialog library. Public exports begin in `src/index.ts`. High-level helpers live in `src/api/`, dialog behavior and accessibility primitives in `src/core/`, plugin support in `src/plugins/`, localization defaults in `src/i18n/`, and CSS plus theme helpers in `src/theme/`. Shared public types are in `src/types.ts`, while small internal helpers belong in `src/utils/`. Tests mirror these areas under `tests/` and use the `*.test.ts` suffix. Global test setup is in `tests/setup.ts`. `demo/index.html` is the manual browser demo. Generated output goes to `dist/`; do not edit or commit generated files unless a release workflow explicitly requires it. @@ -28,4 +28,4 @@ Vitest runs in jsdom and discovers `tests/**/*.test.ts`. Place focused tests bes ## Commit & Pull Request Guidelines -Existing commits use concise, imperative summaries such as `Add ForgeDialog core: ...`. Keep each commit scoped and explain the user-visible outcome. Pull requests should include a clear description, relevant issue links, verification commands, and tests. Add screenshots or a short recording for visual, animation, or demo changes, and call out API or accessibility impacts explicitly. +Existing commits use concise, imperative summaries such as `Add Forge Dialog core: ...`. Keep each commit scoped and explain the user-visible outcome. Pull requests should include a clear description, relevant issue links, verification commands, and tests. Add screenshots or a short recording for visual, animation, or demo changes, and call out API or accessibility impacts explicitly. diff --git a/CHANGELOG.md b/CHANGELOG.md index dea9d29..473e060 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- `homepage` now points at the documentation site rather than the README anchor, so the npm + page links to the reference instead of back to GitHub. + ## 0.7.0 - Added per-component appearance controls: `surfaceColor`, `titleColor`, `titleBackground`, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e5f2003..9b58164 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ -# Contributing to ForgeDialog +# Contributing to Forge Dialog -Thanks for helping out. ForgeDialog is a dependency-free TypeScript dialog library, so most +Thanks for helping out. Forge Dialog is a dependency-free TypeScript dialog library, so most contributions touch `src/`, its tests, and the checks that keep the published package honest. ## Getting set up diff --git a/README.md b/README.md index d74c8b5..aebb226 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# ForgeDialog 0.7 +# Forge Dialog 0.7 _A modern, lightweight, and highly customizable JavaScript dialog library for alerts, confirmations, modals, drawers, and interactive workflows._ -ForgeDialog is a next-generation dialog component built for modern web applications. It provides beautiful animations, flexible layouts, accessibility, and a powerful API while remaining lightweight, dependency-free, and framework-agnostic. +Forge Dialog is a next-generation dialog component built for modern web applications. It provides beautiful animations, flexible layouts, accessibility, and a powerful API while remaining lightweight, dependency-free, and framework-agnostic. -Designed to replace traditional browser dialogs and heavy modal libraries, ForgeDialog makes it easy to build elegant user interactions — from simple alert boxes to complex multi-step workflows. +Designed to replace traditional browser dialogs and heavy modal libraries, Forge Dialog makes it easy to build elegant user interactions — from simple alert boxes to complex multi-step workflows. ## Highlights @@ -176,7 +176,7 @@ import { alert } from 'forgedialog/alert'; ## Advanced UI and workflows -ForgeDialog uses the native `` top layer and adds typed outcomes, close reasons, +Forge Dialog uses the native `` top layer and adds typed outcomes, close reasons, `AbortSignal`, drawers, bottom sheets, lightboxes, loading states, toast notifications, command palettes, and persisted branching wizards. @@ -257,6 +257,8 @@ npm run test:consumers # build Vite, Webpack, and Node CJS fixtures npm run validate # run every non-browser quality and packaging gate npm run validate:all # run validate plus the cross-browser suite npm run demo # build and serve the demo/ page +npm run site # build and serve the marketing site in site/ +npm run site:icons # re-render the site's PNG icons from site/icon.svg ``` Releases use Changesets (`npm run changeset`) and the protected GitHub release workflow. The @@ -267,6 +269,13 @@ it depends on, are in [docs/releasing.md](docs/releasing.md). Supported baselines are Chrome/Edge 88+, Firefox 78+, Safari 15.4+, and Node.js 20+ for package tooling and SSR imports. +## Website + +`site/` holds a dependency-free landing page with an interactive appearance playground, plus a +full API reference at `site/docs.html`. Build and serve both with `npm run site`. The icon, the +generated `sitemap.xml` / `robots.txt` / `llms.txt`, the structured data, and the Cloudflare +deployment settings are covered in [docs/website.md](docs/website.md). + ## Contributing Setup, the checks CI runs, and the gotchas worth knowing are in diff --git a/SECURITY.md b/SECURITY.md index c9b67f8..c1d62ca 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Supported versions -ForgeDialog is pre-1.0. Fixes land on the most recent minor line; older `0.x` lines are not +Forge Dialog is pre-1.0. Fixes land on the most recent minor line; older `0.x` lines are not patched. | Version | Supported | @@ -25,7 +25,7 @@ credited in the advisory unless you prefer otherwise. ## Scope -ForgeDialog renders UI in the browser and ships no server component, so most reports will concern +Forge Dialog renders UI in the browser and ships no server component, so most reports will concern how untrusted content reaches the DOM. **In scope** diff --git a/demo/index.html b/demo/index.html index 6b7f2c5..b66ca6a 100644 --- a/demo/index.html +++ b/demo/index.html @@ -3,7 +3,7 @@ - ForgeDialog Demo + Forge Dialog Demo -

ForgeDialog Demo

+

Forge Dialog Demo

Manual verification page for alert / confirm / prompt / open() and stacking.

diff --git a/docs/architecture.md b/docs/architecture.md index a363251..45ec7d3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ -# ForgeDialog Architecture +# Forge Dialog Architecture -ForgeDialog is split into a dependency-free runtime and optional presentation, workflow, and +Forge Dialog is split into a dependency-free runtime and optional presentation, workflow, and framework entry points. `src/core/Dialog.ts` owns the state machine and native `` lifecycle; `DialogStack` coordinates Escape handling and scroll locking. DOM construction remains isolated in `domBuilder` so render behavior can evolve independently from orchestration. diff --git a/docs/migration-0.5.md b/docs/migration-0.5.md index 6e97229..2cec58b 100644 --- a/docs/migration-0.5.md +++ b/docs/migration-0.5.md @@ -1,4 +1,4 @@ -# Migrating to ForgeDialog 0.5 +# Migrating to Forge Dialog 0.5 Version 0.5 keeps the main `forgedialog` entry backward compatible. Existing imports continue to include advanced dragging and animation presets, so most applications need no code changes. diff --git a/docs/releasing.md b/docs/releasing.md index ed98de7..a610752 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,6 +1,6 @@ # Releasing -ForgeDialog publishes to npm from GitHub Actions using npm **trusted publishing** (OIDC), so no +Forge Dialog publishes to npm from GitHub Actions using npm **trusted publishing** (OIDC), so no `NPM_TOKEN` secret is stored in the repository. The workflow is `.github/workflows/release.yml`. ## One-time setup @@ -43,11 +43,10 @@ publisher with: | Workflow filename | `release.yml` | | Environment | `npm` | -> **Check this before relying on it for the first release.** npm configures trusted publishers from -> a package's settings page, which normally means the package has to exist first. `forgedialog` is -> currently unpublished. If npm will not let you add a publisher for a name that has never been -> published, do the first publish manually as described below, then come back and add the trusted -> publisher for every release after it. +> npm configures trusted publishers from a package's settings page, which means the package has to +> exist first. `forgedialog` is published — 0.7.0 went out on 1 September 2026 — so that page is +> available and this can be set up now. The manual first publish described below is history for +> this package; it is kept because a fork starting from an unpublished name still needs it. ### 3. Enable publishing in the workflow @@ -69,7 +68,7 @@ accident before you are ready. 4. Tag the merge commit and push the tag: ```sh - git tag -a v0.7.0 -m "ForgeDialog v0.7.0" + git tag -a v0.7.0 -m "Forge Dialog v0.7.0" git push origin v0.7.0 ``` @@ -90,7 +89,8 @@ request it. ## First publish without trusted publishing -Only if step 2 above turns out to require an existing package: +Not needed here — `forgedialog` already has a published version. This is for a fork publishing a +name that has never been published, where step 2 has no settings page to configure yet: ```sh npm run validate:all @@ -106,13 +106,12 @@ delete the token you used. **The publish step was skipped.** `NPM_PUBLISH_ENABLED` is not `true`. That is the default. -**`npm error code E404` on `PUT .../forgedialog`, with the tarball already built and listed in the +**`npm error code E404` on `PUT .../`, with the tarball already built and listed in the log.** This is not an auth or OIDC problem — npm returns 404 rather than 401/403 here specifically -because trusted publishing has nothing to attach to yet: the package has never been published, so -there is no packages page to hold a trusted publisher entry. This is the exact case called out -above in "Check this before relying on it for the first release." Do the manual first publish in -the "First publish without trusted publishing" section, then add the trusted publisher and set -`NPM_PUBLISH_ENABLED=true`. +because trusted publishing has nothing to attach to yet: the name has never been published, so +there is no package settings page to hold a trusted publisher entry. It cannot happen to +`forgedialog` any more, which has a published version; a fork hitting it should do the manual first +publish in the section above, then add the trusted publisher and set `NPM_PUBLISH_ENABLED=true`. **`ENEEDAUTH` or an OIDC error** (distinct from the E404 above; this is for a package that already has at least one published version). The trusted publisher entry on npmjs.com must match the diff --git a/docs/website.md b/docs/website.md new file mode 100644 index 0000000..d4de9c7 --- /dev/null +++ b/docs/website.md @@ -0,0 +1,167 @@ +# Website + +The marketing site lives in `site/`. It is hand-written HTML, CSS, and browser JavaScript with no +dependencies and no framework, matching the library it advertises. + +## Running it locally + +```sh +npm run site +``` + +That builds the library, copies `dist/index.global.js` and `dist/index.css` into `site/vendor/`, +writes `robots.txt`, `sitemap.xml`, and `llms.txt`, and serves `site/`. Use `npm run site:build` on +its own if you only need the files. + +`site/vendor/` is generated and git-ignored: the page runs the code from this checkout rather than a +CDN, so what you see is always the build you have. + +## How it is put together + +| File | Role | +| ----------------- | --------------------------------------------------------------------- | +| `site/index.html` | The landing page: demos, appearance playground, facts table, and FAQ. | +| `site/docs.html` | The API reference, written against the reviewed report in `etc/`. | +| `site/404.html` | Served for any unmatched path. Carries `noindex`. | +| `site/styles.css` | Site styling. Its dark mode reads the same `data-fd-theme` attribute | +| | that the library's `setTheme()` sets, so both switch together. | +| `site/common.js` | Shared chrome: version badge, theme toggle, copy buttons. | +| `site/site.js` | Landing-page demos and the appearance playground. | +| `site/docs.js` | Highlights the sidebar entry for the section being read. | +| `site/icon.svg` | The mark, and the favicon browsers actually load. | +| `wrangler.jsonc` | Cloudflare deployment config. Points at `site/`, not `dist/`. | +| `site/_headers` | Cloudflare response headers, including a strict CSP. | + +### URLs + +The docs page is served at **`/docs`**, not `/docs.html`. Both Cloudflare's static-asset routing +(`html_handling: "auto-trailing-slash"`, the default) and the `serve` used by `npm run site` drop +the `.html` and redirect the extension form, so the extensionless URL is the one that answers with +a `200`. Every link, canonical tag, sitemap entry, and `llms.txt` link uses it — a canonical or a +sitemap entry that redirects is a conflicting signal to a search engine. + +The version badge is read from the library's own `VERSION` export at runtime, so nothing has to be +kept in sync by hand and no build step rewrites source files. + +The playground calls `instance.update()` on every control change, which is why the dialog restyles +while it is open rather than needing to be reopened. + +### Naming + +The product is **Forge Dialog**, two words, and that is how every page, heading, and document +refers to it. The npm package (`forgedialog`), the browser global (`ForgeDialog`), the custom +element (`forge-dialog`), and the repository name stay as they are: they are identifiers, not +prose. + +## Icons + +`site/icon.svg` is the source of truth and is what browsers load as the favicon. Two bitmaps are +committed beside it because SVG is not enough everywhere: + +| File | Size | Used by | +| --------------------------- | -------- | ------------------------------ | +| `site/apple-touch-icon.png` | 180×180 | iOS home screens | +| `site/og-image.png` | 1200×630 | Link previews and social cards | + +Both are rendered from `icon.svg` through Chromium, which is already a dev dependency: + +```sh +npm run site:icons +``` + +Run it only when the icon changes; neither the site build nor a deploy runs it. The apple-touch +variant squares off the mark's ground (via `#ground { rx: 0 }`) because iOS applies its own rounded +mask and would otherwise round it twice. + +## SEO and answer engines + +The canonical origin is declared once, in the `` tag of `site/index.html`. +`scripts/build-site.mjs` reads it back to generate every other absolute URL, so **moving the site +to another domain means editing the canonical tags in `index.html` and `docs.html` and nothing +else** — the sitemap, `robots.txt`, and `llms.txt` follow automatically. + +Generated on each build, and git-ignored: + +- **`sitemap.xml`** — both pages, each with the date its HTML file was last committed, read from + `git log`. A build that cannot reach git history (a shallow clone, a tarball export) writes the + entry with no `lastmod` rather than stamping today's date on a page that did not change: a + sitemap that is wrong about `lastmod` is one a crawler learns to ignore. +- **`robots.txt`** — allows everything, names the sitemap, and explicitly allows the answer-engine + crawlers (ClaudeBot, GPTBot, PerplexityBot, Google-Extended, and friends). It deliberately does + **not** disallow `/vendor/`: blocking a site's own CSS and JavaScript is what breaks rendering + for search crawlers. +- **`llms.txt`** — the [llmstxt.org](https://llmstxt.org) convention. A short, factual summary a + language model can quote: version, licence, install command, size, browser support, and links to + both pages. The version comes from `package.json`, so it cannot drift. + +Both pages carry JSON-LD structured data. The landing page describes itself as a `WebSite`, a +`SoftwareApplication`, and an `FAQPage`; the reference page as a `TechArticle` with a +`BreadcrumbList`. The FAQ entries are generated from the visible questions and answers, so the +markup and the page cannot disagree. + +The FAQ and the "At a glance" table exist for the same reason: they state the facts about Forge +Dialog in self-contained sentences, which is what both search snippets and answer engines quote. + +## Deploying to Cloudflare + +The site ships as a [Workers static-assets](https://developers.cloudflare.com/workers/static-assets/) +project: no Worker script, every request answered straight from `site/`. `wrangler.jsonc` at the +repository root is the whole configuration, so a deploy is just: + +```sh +npm run site:build +npx wrangler deploy +``` + +Note that the assets directory is **`site/`, not `dist/`**. `dist/` is the compiled library — +`.mjs`, `.cjs`, `.d.ts`, and `.css` with no `index.html` in it — so deploying that directory +uploads ninety-odd files and still serves nothing at `/`. + +### Workers Builds + +To deploy on push, connect the repository under **Workers & Pages → Create → Workers → Import a +repository** and set: + +| Setting | Value | +| -------------- | --------------------- | +| Build command | `npm run site:build` | +| Deploy command | `npx wrangler deploy` | +| Node version | `20` or newer | + +Cloudflare runs `npm ci` before the build command, so no install step is needed. Set the Node +version through a `NODE_VERSION` environment variable if the default is older than 20, which the +package's `engines` field requires. + +Leave the deploy command without an `--assets` flag: the flag overrides `wrangler.jsonc`, which is +how a project ends up shipping the wrong directory. The build runs the library's own `npm run +build` first, so a broken build fails the deploy rather than shipping a stale `site/vendor/`. + +### Custom domain + +Attach the domain under the Worker's **Settings → Domains & Routes**. It has to match the canonical +tags in `index.html` and `docs.html`, or search engines will be told the page lives somewhere it +does not. + +The `*.workers.dev` URL stays enabled as a way to check a deploy. It serves the same pages, but +every page carries an absolute canonical pointing at the custom domain, so search engines +consolidate the two rather than treating them as duplicates. Set `"workers_dev": false` in +`wrangler.jsonc` if you would rather it not answer at all. + +### Unmatched paths + +`not_found_handling: "404-page"` in `wrangler.jsonc` makes Workers serve `site/404.html` with a +`404` status for anything that does not match a file, instead of the platform's bare response. That +page links its stylesheets and scripts with root-relative paths (`/styles.css`, not `./styles.css`) +because it answers for nested addresses too, where a relative path would resolve against the +missing directory. It carries `noindex` and no canonical tag, since it has no URL of its own. + +### Response headers + +`site/_headers` sets `X-Content-Type-Options`, `Referrer-Policy`, `X-Frame-Options`, and a strict +`Content-Security-Policy` limited to same-origin scripts and styles. The library contains no `eval` +or `new Function` — `npm run test:csp` enforces that — so it runs cleanly under that policy. The +inline JSON-LD is a data block rather than an executable script, so `script-src 'self'` does not +block it. + +If you add a third-party script, an analytics snippet, or a web font, the CSP has to be widened to +match or the browser will silently block it. diff --git a/package.json b/package.json index 89a272f..c7b09bb 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "type": "git", "url": "git+https://github.com/cmm-cmm/ForgeDialog.git" }, - "homepage": "https://github.com/cmm-cmm/ForgeDialog#readme", + "homepage": "https://forgedialog.konexforge.com", "bugs": "https://github.com/cmm-cmm/ForgeDialog/issues", "engines": { "node": ">=20" @@ -226,6 +226,9 @@ "validate": "npm run typecheck && npm run lint && npm run test:coverage && npm run size && npm run test:treeshake && npm run check:package && npm run api:check && npm run api:subpaths && npm run test:consumers && npm run test:capabilities && npm run test:csp && npm run package:size", "validate:all": "npm run validate && npm run test:e2e", "demo": "npm run build && serve .", + "site:build": "npm run build && node scripts/build-site.mjs", + "site:icons": "node scripts/build-icons.mjs", + "site": "npm run site:build && serve site", "prepublishOnly": "npm run build" }, "keywords": [ diff --git a/scripts/build-icons.mjs b/scripts/build-icons.mjs new file mode 100644 index 0000000..28eab81 --- /dev/null +++ b/scripts/build-icons.mjs @@ -0,0 +1,73 @@ +import { chromium } from '@playwright/test'; +import { readFile, writeFile } from 'node:fs/promises'; + +/** + * Rasterizes `site/icon.svg` into the bitmap icons the web still needs. + * + * The SVG is the source of truth and is what browsers actually load as the + * favicon; only iOS home screens and link-preview scrapers need PNGs. Those + * PNGs are committed, so neither the site build nor a Cloudflare deploy runs + * this — run it by hand (`npm run site:icons`) whenever the icon changes. + * + * Rendering goes through Chromium, which is already a dev dependency for the + * end-to-end tests, rather than a hand-rolled rasterizer. + */ +const root = new URL('../', import.meta.url); +const site = new URL('site/', root); +const icon = await readFile(new URL('icon.svg', site), 'utf8'); + +const browser = await chromium.launch(); + +/** Renders `html` at exactly `width`×`height` and writes it out as a PNG. */ +async function render(name, width, height, html) { + const page = await browser.newPage({ viewport: { width, height } }); + await page.setContent( + ` + ${html}`, + { waitUntil: 'load' }, + ); + const buffer = await page.screenshot({ type: 'png' }); + await page.close(); + await writeFile(new URL(name, site), buffer); + console.log(`icons: ${name} (${width}×${height}, ${buffer.length} bytes)`); +} + +// iOS applies its own rounded mask to home-screen icons, so this variant +// squares off the ground the mark sits on rather than being rounded twice. +await render( + 'apple-touch-icon.png', + 180, + 180, + `${icon}`, +); + +// The link preview: the mark, the name, and what the thing is. The ground is +// a flat colour rather than a gradient — it looks the same at preview size and +// costs a fraction of the bytes in a repository that advertises being small. +await render( + 'og-image.png', + 1200, + 630, + ` +
${icon}

Forge Dialog

+

Accessible, dependency-free dialogs for the web.

+ npm install forgedialog`, +); + +await browser.close(); diff --git a/scripts/build-site.mjs b/scripts/build-site.mjs new file mode 100644 index 0000000..1682e11 --- /dev/null +++ b/scripts/build-site.mjs @@ -0,0 +1,150 @@ +import { execFileSync } from 'node:child_process'; +import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +/** + * Copies the built library into the static site so the page runs the code from + * this checkout rather than a CDN, then writes the files that have to name the + * site's own address: robots.txt, sitemap.xml, and llms.txt. + * + * Uses only Node built-ins: the site deliberately has no dependencies of its + * own. The page reads its version from the library's `VERSION` export at + * runtime, so nothing here rewrites a source file. + */ +const root = new URL('../', import.meta.url); +const site = new URL('site/', root); +const vendor = new URL('vendor/', site); +const pkg = JSON.parse(await readFile(new URL('package.json', root), 'utf8')); + +await mkdir(vendor, { recursive: true }); +for (const file of ['index.global.js', 'index.css']) { + await copyFile(new URL(`dist/${file}`, root), new URL(file, vendor)); +} + +/** + * The canonical tag in index.html is the single source of truth for the site's + * address, so moving the site is a one-line edit there rather than a hunt + * through generated files. + */ +const indexHtml = await readFile(new URL('index.html', site), 'utf8'); +const canonical = indexHtml.match(/, cannot build'); +const origin = new URL(canonical).origin; + +const pages = [ + { + path: '/', + file: 'site/index.html', + priority: '1.0', + title: 'Forge Dialog', + summary: 'What it is, live demos, and an interactive appearance playground.', + }, + { + path: '/docs', + file: 'site/docs.html', + priority: '0.9', + title: 'API reference', + summary: + 'Every option, method, and entry point: dialogs, appearance, dragging, forms, wizards, toasts, theming, plugins, and framework adapters.', + }, +]; + +/** + * `lastmod` has to be the day the page's content actually changed. Stamping + * every build with today's date tells a crawler both pages change daily, which + * is false, and a sitemap that is wrong about it is one a crawler learns to + * ignore. Git knows the real answer; a shallow clone or a tarball export might + * not, so the entry is written without a `lastmod` rather than with a + * plausible-looking lie. + */ +function lastModified(file) { + try { + return ( + execFileSync('git', ['log', '-1', '--format=%cs', '--', file], { + cwd: fileURLToPath(root), + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() || null + ); + } catch { + return null; + } +} + +await writeFile( + new URL('sitemap.xml', site), + ` + +${pages + .map((page) => { + const lastmod = lastModified(page.file); + return ` \n ${origin}${page.path}\n${ + lastmod ? ` ${lastmod}\n` : '' + } ${page.priority}\n `; + }) + .join('\n')} + +`, +); + +// Answer engines are welcome here: the point of the site is to be quoted +// accurately, so the crawlers behind them are named rather than left to the +// default. +const aiCrawlers = [ + 'ClaudeBot', + 'Claude-User', + 'GPTBot', + 'OAI-SearchBot', + 'ChatGPT-User', + 'PerplexityBot', + 'Google-Extended', + 'Applebot-Extended', + 'CCBot', +]; + +await writeFile( + new URL('robots.txt', site), + // /vendor/ holds the library build the pages actually render with, so it stays + // crawlable: blocking a site's own CSS and JS is what breaks rendering. + `User-agent: * +Allow: / + +${aiCrawlers.map((agent) => `User-agent: ${agent}\nAllow: /`).join('\n\n')} + +Sitemap: ${origin}/sitemap.xml +`, +); + +// https://llmstxt.org — a short, stable map of the site for language models, +// with the facts they most often get wrong stated once, plainly. +await writeFile( + new URL('llms.txt', site), + `# Forge Dialog + +> Forge Dialog is an open-source TypeScript library for building dialogs on the web: alerts, confirmations, prompts, drawers, bottom sheets, lightboxes, loading states, toasts, command palettes, and multi-step wizards. It has no runtime dependencies. + +The product is named **Forge Dialog** (two words). The npm package is \`forgedialog\` (one word), and the browser global is \`ForgeDialog\`, because both are identifiers. + +- Version: ${pkg.version} +- License: MIT (commercial use permitted) +- Install: \`npm install forgedialog\` +- Runtime dependencies: none +- Size: about 15 KB gzipped for the full bundle, about 5.5 KB for an alert-only build +- Browsers: Chrome/Edge 88+, Firefox 78+, Safari 15.4+ +- Node.js: 20 or newer, for package tooling and server-side imports +- Accessibility: native \`\`, focus trapped and restored, Escape and backdrop dismissal, \`role="dialog"\` or \`role="alertdialog"\` set automatically +- Frameworks: works standalone; adapters for React, Vue, Svelte, and Web Components + +## Documentation + +${pages.map((page) => `- [${page.title}](${origin}${page.path}): ${page.summary}`).join('\n')} + +## Source + +- [GitHub repository](${pkg.repository.url.replace(/^git\+/, '').replace(/\.git$/, '')}): source, issue tracker, and changelog. +- [npm package](https://www.npmjs.com/package/${pkg.name}): published builds for ESM, CommonJS, and the browser. +`, +); + +console.log(`site: vendored ${pkg.name}@${pkg.version} into site/vendor`); +console.log(`site: wrote sitemap.xml, robots.txt, and llms.txt for ${origin}`); diff --git a/site/404.html b/site/404.html new file mode 100644 index 0000000..c477886 --- /dev/null +++ b/site/404.html @@ -0,0 +1,66 @@ + + + + + + Page not found — Forge Dialog + + + + + + + + + + + + + +
+
+

That page isn’t here.

+

+ The address you followed does not match anything on this site. The two pages that do exist + are the overview and the API reference. +

+ + +
+
+ +
+
+ MIT licensed. Built with no runtime dependencies. + + GitHub · + npm · + Changelog + +
+
+ + + + + diff --git a/site/_headers b/site/_headers new file mode 100644 index 0000000..667fa39 --- /dev/null +++ b/site/_headers @@ -0,0 +1,8 @@ +/* + X-Content-Type-Options: nosniff + Referrer-Policy: strict-origin-when-cross-origin + X-Frame-Options: DENY + Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none' + +/vendor/* + Cache-Control: public, max-age=3600 diff --git a/site/apple-touch-icon.png b/site/apple-touch-icon.png new file mode 100644 index 0000000..3274785 Binary files /dev/null and b/site/apple-touch-icon.png differ diff --git a/site/common.js b/site/common.js new file mode 100644 index 0000000..cdb1462 --- /dev/null +++ b/site/common.js @@ -0,0 +1,47 @@ +/* Shared site chrome: version badge, theme toggle, copy buttons. */ +/* Loaded by every page; the page-specific scripts run after it. */ +/* global ForgeDialog */ + +(() => { + const { setTheme, VERSION } = ForgeDialog; + const $ = (selector) => document.querySelector(selector); + + $('#version').textContent = `v${VERSION}`; + + /* Theme ---------------------------------------------------------------- */ + + const themeToggle = $('#theme-toggle'); + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); + + function applyTheme(dark) { + // setTheme drives the library's own tokens; the attribute it sets on + // is the same hook this page's stylesheet reads, so both stay in step. + setTheme(dark ? 'dark' : 'light'); + themeToggle.textContent = dark ? 'Light' : 'Dark'; + themeToggle.setAttribute('aria-pressed', String(dark)); + } + + applyTheme(prefersDark.matches); + themeToggle.addEventListener('click', () => { + applyTheme(themeToggle.getAttribute('aria-pressed') !== 'true'); + }); + + /* Copy buttons --------------------------------------------------------- */ + + for (const button of document.querySelectorAll('.copy')) { + button.addEventListener('click', async () => { + const text = $(button.dataset.copy).textContent; + const original = button.textContent; + try { + await navigator.clipboard.writeText(text); + button.textContent = 'Copied'; + } catch { + // Clipboard access can be refused; say so rather than looking broken. + button.textContent = 'Press Ctrl+C'; + } + setTimeout(() => { + button.textContent = original; + }, 1600); + }); + } +})(); diff --git a/site/docs.html b/site/docs.html new file mode 100644 index 0000000..94b3587 --- /dev/null +++ b/site/docs.html @@ -0,0 +1,1403 @@ + + + + + + API reference — Forge Dialog + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+

API reference

+

+ Forge Dialog is a dependency-free TypeScript dialog library built on the native + <dialog> element. Everything on this page is in the published package — + there are no plugins to install and no peer dependencies to satisfy. +

+ +
+

Installation

+

Install from npm. The package name is one word; the library is called Forge Dialog.

+
+ +
npm install forgedialog
+
+

Import the API and a stylesheet:

+
+ +
import { alert, confirm, prompt, open } from 'forgedialog';
+import 'forgedialog/style.css';
+
+

+ Or load the browser build directly. It defines the global + ForgeDialog — a JavaScript identifier, so it stays one word. +

+
+ +
<link rel="stylesheet" href="https://unpkg.com/forgedialog/dist/index.css" />
+<script src="https://unpkg.com/forgedialog/dist/index.global.js"></script>
+<script>
+  ForgeDialog.alert('Hello!');
+</script>
+
+

+ Requires Chrome/Edge 88+, Firefox 78+, Safari 15.4+, or Node.js 20+ for package tooling + and server-side imports. +

+
+ +
+

Entry points

+

+ Every entry point is tree-shakable and has its own gzip budget enforced in CI. Import + the narrowest one that covers what you need. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ImportContains
forgedialog + Everything, with dragging, animations, and the full appearance applier already + enabled. +
forgedialog/core + open(), alert, confirm, + prompt, theming, labels, plugins, and the full appearance applier. +
forgedialog/alertalert() alone.
forgedialog/confirmconfirm() alone.
forgedialog/promptprompt() alone.
forgedialog/presentation + drawer, bottomSheet, lightbox, + loading, toast, commandPalette, + notificationCenter. +
forgedialog/workflowsform, formWizard, wizard.
forgedialog/interactionsThe draggable controller. Side-effectful: importing it enables dragging.
forgedialog/animationsAnimation presets. Side-effectful: importing it enables animations.
forgedialog/appearance + The full appearance applier. Side-effectful: importing it upgrades the focused + entries. +
forgedialog/reactuseForgeDialog() for React.
forgedialog/vueuseForgeDialog() for Vue.
forgedialog/svelteThe dialogTrigger action.
forgedialog/web-componentdefineForgeDialog() and ForgeDialogElement.
+
+

Capability imports

+

+ The single-purpose entries (/alert, /confirm, + /prompt) ship a lightweight appearance applier covering surface opacity, + backdrop, border, and shadow presets. Add capabilities by importing them for their side + effect: +

+
+ +
import 'forgedialog/appearance';   // per-component colour, radius, hover
+import 'forgedialog/interactions'; // dragging
+import 'forgedialog/animations';   // animation presets
+import { alert } from 'forgedialog/alert';
+
+

+ forgedialog and forgedialog/core already include the full + appearance applier, so those imports are only needed alongside a focused entry. +

+
+ +
+

Stylesheets

+

+ forgedialog/style.css is the all-in-one sheet. To ship less CSS, compose + only the layers a page uses — core.css is always required. +

+
+ +
import 'forgedialog/style/core.css';      // required
+import 'forgedialog/style/forms.css';     // form fields and file dropzones
+import 'forgedialog/style/workflows.css'; // wizard stepper
+import 'forgedialog/style/toast.css';
+import 'forgedialog/style/lightbox.css';
+import 'forgedialog/style/command.css';
+import 'forgedialog/style/draggable.css';
+
+

+ All rules live in the forgedialog cascade layer, so your own unlayered CSS + wins without needing !important. +

+
+ +
+

alert, confirm, prompt

+

Promise-returning replacements for the browser built-ins.

+
+ + + + + + + + + + + + + + + + + + + + + +
SignatureResolves to
alert(message, options?)Promise<void>
confirm(message, options?) + Promise<boolean> — false on Escape or backdrop + close +
prompt(message, options?) + Promise<string | null> — null when cancelled +
+
+
+ +
await alert('Saved successfully.', { title: 'Success' });
+
+const ok = await confirm('Delete this item?', { title: 'Please confirm' });
+
+const name = await prompt('What is your name?', {
+  defaultValue: 'Ada Lovelace',
+  inputType: 'text',
+  inputLabel: 'Full name',
+  placeholder: 'Type a name',
+  validate: (value) => (value.trim() ? true : 'Name is required'),
+});
+
+

+ alert and confirm get + role="alertdialog" automatically. prompt accepts the extra + options above on top of every DialogOptions field. +

+
+ +
+

open()

+

+ The low-level API. It returns a DialogInstance immediately + rather than a promise, so the dialog can be updated, moved, or closed from anywhere. +

+
+ +
import { open } from 'forgedialog';
+
+const dialog = open({
+  title: 'Custom dialog',
+  content: (container) => container.append(renderForm()),
+  buttons: [
+    { text: 'Cancel', onClick: (i) => i.close(null) },
+    { text: 'Save', role: 'primary', autoFocus: true, onClick: (i) => i.close(read()) },
+  ],
+});
+
+const result = await dialog.whenClosed();          // the result, or undefined
+const { result: r, reason } = await dialog.whenSettled(); // result plus why it closed
+
+

Content and untrusted HTML

+

+ content accepts a string (rendered as text), an element, + or a builder function. Markup needs an explicit decision: +

+
+ +
// Untrusted markup: pass a sanitizer.
+open({ html: userContent, sanitizeHtml: (h) => DOMPurify.sanitize(h) });
+
+// Markup you already trust.
+open({ unsafeHtml: '<p>Generated by us</p>' });
+
+

Close reasons

+

+ Every close carries a reason: 'button', 'escape', + 'backdrop', 'api', 'abort', or + 'destroy'. Read it with whenSettled(). +

+
+ +
+

DialogOptions

+

Accepted by open() and by every helper built on it.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OptionTypeDescription
titlestring + Header text. Omitted titles fall back to a visually hidden label so the dialog + stays named for assistive technology. +
messagestring | HTMLElementBody text or element.
contentstring | HTMLElement | (container) => voidBody content. Strings render as text.
htmlstringMarkup, rendered only when sanitizeHtml is also supplied.
unsafeHtmlstringMarkup inserted as-is. Only for content you control.
sanitizeHtml(html: string) => stringSanitizer applied to html.
buttonsButtonConfig[]Footer buttons. See below.
type + 'alert' | 'confirm' | 'prompt' | 'form' | 'wizard' | 'custom' + + Semantic kind. alert and confirm default to + role="alertdialog". +
role'dialog' | 'alertdialog'Overrides the role derived from type.
size'sm' | 'md' | 'lg' | 'xl' | 'fullscreen'Width preset.
presentation + 'modal' | 'drawer-left' | 'drawer-right' | 'bottom-sheet' | 'lightbox' + How the dialog enters and where it sits.
animation + 'fade' | 'scale' | 'slide' | 'spring' | 'bounce' | 'blur' | 'none' + + Requires the animations capability. Honours prefers-reduced-motion. +
appearanceDialogAppearancePer-dialog styling. See Appearance.
draggableboolean | DraggableOptionsSee Dragging.
closablebooleanShows the header close button. Default true.
closeOnEscapebooleanDefault true.
closeOnOverlayClickbooleanDefault true.
initialFocusstring | HTMLElement | (root) => HTMLElement | nullWhat receives focus on open. Defaults to the first tabbable element.
restoreFocusbooleanReturns focus to the trigger on close. Default true.
portalTargetHTMLElementWhere the dialog mounts. Defaults to document.body.
classNamestringExtra classes on the dialog element.
labelsPartial<DialogLabels>Per-dialog label overrides.
signalAbortSignalAborting closes the dialog with reason 'abort'.
dataunknownArbitrary payload, readable from hooks.
onOpen(instance) => void | Promise<void>After the dialog opens.
onBeforeClose(instance, result) => boolean | void | Promise<…>Return false to keep it open.
onClose(instance, result) => void | Promise<void>After the dialog closes.
onError(error, instance) => voidObserves lifecycle failures, which are cleaned up automatically.
+
+

ButtonConfig

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
textstringLabel. Required.
role'primary' | 'secondary' | 'danger'Visual weight.
onClick(instance) => void | Promise<void>Click handler.
closesDialogbooleanCloses the dialog after onClick.
resultTResultValue the dialog resolves with when this button closes it.
autoFocusbooleanReceives focus on open.
disabledbooleanRenders the button disabled.
idstringElement id, useful for tests.
+
+
+ +
+

DialogInstance

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MemberReturnsDescription
open()Promise<void>Opens a dialog that is not open yet.
close(result?, reason?)Promise<void>Closes with a result.
cancel(reason?)Promise<void>Closes without a result.
destroy()Promise<void>Closes and removes the element and its listeners.
update(partialOptions)voidRestyles or re-renders a dialog in place, while it is open.
whenClosed()Promise<TResult | undefined>Resolves with the result.
whenSettled()Promise<{ result, reason }>Resolves with the result and the close reason.
isOpen()booleanWhether it is currently open.
getState()DialogState + 'idle' | 'opening' | 'open' | 'closing' | 'closed' | 'destroyed'. +
getPosition(){ x, y }Current drag offset.
setPosition({ x, y }){ x, y }Moves the dialog, clamped to its bounds.
resetPosition()voidReturns it to centre.
elementHTMLElementThe dialog element.
idstringUnique id.
+
+
+ +
+

Appearance

+

+ Appearance overrides are scoped to one dialog and can be changed at runtime with + update(). Opacity values are clamped to 0..1; plain numbers + for widths, radii, and blur are read as pixels. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeApplies to
opacitynumberThe whole dialog surface.
surfaceColorstringDialog background.
overlayOpacitynumberThe backdrop behind it.
backdropBlurnumber | stringBackdrop blur radius.
titleColorstringHeader text.
titleBackgroundstringHeader fill. Transparent by default and clipped by the dialog's corners.
titleOpacitynumberHeader.
contentColorstringBody text.
contentOpacitynumberBody.
borderColorstringBorder.
borderOpacitynumberBorder.
borderWidthnumber | stringBorder.
borderStyle'none' | 'solid' | 'dashed' | 'dotted' | 'double'Border.
radiusnumber | string | DialogCornerRadiusCorners. See Corner radius.
shadowShadowPreset | string | DialogShadowConfigSee Shadows.
hoverDialogHoverAppearanceSee Hover.
+
+
+ +
open({
+  title: 'Styled',
+  appearance: {
+    opacity: 0.96,
+    surfaceColor: '#12141a',
+    overlayOpacity: 0.55,
+    backdropBlur: 12,
+    titleColor: '#ffd166',
+    titleBackground: '#1b1740',
+    contentColor: '#c8ccd4',
+    borderColor: '#7c5cff',
+    borderWidth: 2,
+    radius: 20,
+    shadow: { angle: 135, distance: 24, blur: 60, opacity: 0.45 },
+  },
+});
+
+

+ The full applier ships with forgedialog and forgedialog/core. + On a focused entry, add import 'forgedialog/appearance' — without it the + richer fields are ignored rather than throwing. +

+
+ +
+

Shadows

+

shadow takes three shapes.

+
+ + + + + + + + + + + + + + + + + + + + + +
FormExample
A preset + shadow: 'xl' — one of none, sm, + md, lg, xl +
Any CSS box-shadowshadow: '0 10px 40px rgb(0 0 0 / 30%)'
Composed from partsshadow: { angle: 90, distance: 18, blur: 48, opacity: 0.3 }
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PartTypeMeaning
anglenumber + Direction the shadow falls, in degrees: 0 up, + 90 right, 180 down (the default), + 270 left. +
distancenumber | stringHow far it is thrown.
blurnumber | stringSoftness.
spreadnumber | stringGrowth before blurring.
colorstringShadow colour.
opacitynumberStrength, 0..1.
insetbooleanCasts the shadow inwards.
+
+
+ +
+

Corner radius

+

+ radius takes one value for every corner, any CSS + border-radius string, or a per-corner object. Corners left out of that + object keep the theme radius rather than collapsing to zero. +

+
+ +
appearance: { radius: 20 }
+appearance: { radius: '20px 20px 4px 4px' }
+appearance: { radius: { topLeft: 24, topRight: 24, bottomRight: 4, bottomLeft: 4 } }
+
+

+ Drawers stay square and bottom sheets round only their top corners unless + radius says otherwise. +

+
+ +
+

Hover

+

+ hover restyles a dialog only while the pointer is over it, and only for + dialogs that ask for it — every other dialog stays completely static. It reuses the + colour, radius, and shadow fields and adds three of its own. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
liftnumber | stringHow far the dialog rises.
scalenumberScale factor, e.g. 1.02.
durationnumber | stringTransition length.
+
+

+ Hover transforms honour prefers-reduced-motion and are suspended while a + dialog is being dragged, so they cannot push it outside its bounds. +

+
+ +
+

Dragging

+

+ draggable: true is enough to move a dialog by its header. The object form + adds constraints, keyboard support, and persistence. Dragging needs the interactions + capability, which forgedialog enables for you. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OptionTypeDescription
handle'header' | string | HTMLElementWhat starts a drag. Defaults to the header.
axis'both' | 'x' | 'y'Locks movement to one axis.
bounds'viewport' | HTMLElement | DOMRectArea the dialog is clamped to.
initialPosition{ x, y }Starting offset.
keyboardbooleanArrow-key movement when the handle has focus.
keyboardStepnumberPixels per key press.
persistKeystringRemembers the position across sessions under this key.
onDragStart / onDrag / onDragEnd(event) => voidEach receives { position, originalEvent }.
+
+

+ Bottom sheets keep their own swipe-to-dismiss gesture and ignore general dragging. +

+
+ +
+

Drawers, sheets, lightboxes, loading

+
+ +
import { drawer, bottomSheet, lightbox, loading } from 'forgedialog';
+
+drawer({ title: 'Settings', side: 'right', content: renderSettings });
+
+bottomSheet({ title: 'Share', content: renderShare });
+
+lightbox('/photo.jpg', { alt: 'A photo', caption: 'Taken in 2026' });
+
+const task = loading('Uploading…');
+task.update('Almost there…');
+await task.close();
+
+

+ These are open() with a presentation preset, so every + DialogOptions field still applies. +

+
+ +
+

Forms

+

+ form() builds a validated dialog from a field list and resolves with the + values, or null if cancelled. Field names are inferred into the result + type, so values.email is typed without a cast. +

+
+ +
import { form } from 'forgedialog';
+
+const values = await form(
+  [
+    { name: 'email', type: 'email', label: 'Email', required: true },
+    { name: 'plan', type: 'select', label: 'Plan', options: [
+      { value: 'free', label: 'Free' },
+      { value: 'pro', label: 'Pro' },
+    ] },
+    { name: 'notes', type: 'textarea', label: 'Notes', rows: 4 },
+  ],
+  { title: 'Sign up', submitText: 'Create account' },
+);
+
+

+ Field types: text, password, email, + number, date, textarea, select, + checkbox, radio, file. Each field takes + label, defaultValue, placeholder, + helpText, required, and a validate function; + numbers add min, max, step, and files add + accept, multiple, maxFiles, and + maxSizeBytes. +

+

+ formWizard(steps, options?) spreads the same fields over several steps, + with nextText, backText, finishText, and an + onStepChange callback. +

+
+ +
+

Wizards

+

+ wizard() is the general form: each step renders whatever it likes, can + validate, and can choose the next step from the data collected so far. +

+
+ +
import { wizard } from 'forgedialog';
+
+const flow = wizard({
+  initialData: { email: '', plan: 'free' },
+  persistKey: 'signup',      // resumes where the user left off
+  confirmUnsaved: true,      // asks before discarding progress
+  steps: [
+    { id: 'account', title: 'Account', render: renderAccount, validate: validateAccount },
+    { id: 'plan', title: 'Plan', render: renderPlan,
+      next: (data) => (data.plan === 'pro' ? 'billing' : 'review') },
+    { id: 'billing', title: 'Billing', render: renderBilling },
+    { id: 'review', title: 'Review', render: renderReview },
+  ],
+});
+
+const data = await flow.result; // the data, or null if cancelled
+
+

+ The controller also exposes next(), back(), + goTo(stepId), getData(), and the underlying + instance. +

+
+ +
+

Toasts and notifications

+
+ +
import { toast, notificationCenter, getNotificationHistory } from 'forgedialog';
+
+toast('Saved', { tone: 'success' });
+
+// A duration of 0 or Infinity keeps it up until dismiss() is called.
+const upload = toast('Uploading…', { duration: 0 });
+upload.dismiss();
+
+toast('Message sent', { action: { text: 'Undo', onClick: undoSend } });
+
+notificationCenter();          // a dialog listing past toasts
+getNotificationHistory();      // the same list as data
+
+

+ Tones are info, success, warning, and + danger. clearNotificationHistory() empties the log. +

+
+ +
+

Command palette

+
+ +
import { commandPalette } from 'forgedialog';
+
+commandPalette([
+  { id: 'new', label: 'New document', shortcut: '⌘N', run: createDocument },
+  { id: 'search', label: 'Search', keywords: ['find', 'filter'], run: openSearch },
+]);
+
+

+ Typing filters on the label and on keywords; the palette resolves with the + id of the command that ran. +

+
+ +
+

Theming

+
+ +
import { setTheme, getTheme, setThemePreset } from 'forgedialog';
+
+setTheme('dark');    // 'light' | 'dark' | 'system'
+getTheme();
+
+setThemePreset('glass'); // 'default' | 'minimal' | 'glass' | 'material'
+
+

+ setTheme() writes data-fd-theme on <html>. + Your own stylesheet can read the same attribute so the page and the dialogs switch + together — that is exactly what this site does. +

+
+ +
+

CSS custom properties

+

+ Everything visual is a variable on :root, so a theme can be written without + touching JavaScript. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefault
--fd-color-surface#ffffff
--fd-color-text#1a1d23
--fd-color-text-muted#5b6270
--fd-color-border#e2e4e9
--fd-color-overlayrgba(15, 17, 21, 0.5)
--fd-color-primary / --fd-color-primary-text#315fce / #ffffff
+ --fd-color-secondary / --fd-color-secondary-text + #eceef2 / #1a1d23
--fd-color-danger / --fd-color-danger-text#e5484d / #ffffff
--fd-radius10px
--fd-shadow0 20px 60px rgba(15, 17, 21, 0.25)
--fd-spacing-sm / md / lg8px / 16px / 24px
--fd-font-familysystem UI stack
--fd-duration-fast / --fd-duration-normal120ms / 200ms
--fd-easingcubic-bezier(0.16, 1, 0.3, 1)
--fd-z-index-base1000
+
+

+ The per-dialog variables the appearance API writes (--fd-dialog-radius, + --fd-dialog-shadow, --fd-dialog-title-background, and the + rest) can also be set by hand if you would rather style in CSS than in JavaScript. +

+
+ +
+

Labels and i18n

+
+ +
import { setLabels, getLabels } from 'forgedialog';
+
+setLabels({
+  ok: 'Đồng ý',
+  cancel: 'Huỷ',
+  close: 'Đóng',
+  submit: 'Gửi',
+  fieldRequired: 'Bắt buộc',
+  promptPlaceholder: 'Nhập nội dung',
+  notifications: 'Thông báo',
+});
+
+

+ Labels set this way apply everywhere; a single dialog can override them with its own + labels option. +

+
+ +
+

Plugins and hooks

+

+ Five lifecycle hooks fire for every dialog: beforeOpen, + afterOpen, beforeClose, afterClose, and + beforeDestroy. +

+
+ +
import { on, off, registerPlugin } from 'forgedialog';
+
+// A single hook.
+on('afterOpen', ({ instance, options }) => track('dialog', options.title));
+
+// Or a plugin bundling several.
+registerPlugin({
+  name: 'analytics',
+  install: (api) => api.on('afterClose', ({ reason }) => track('close', reason)),
+  hooks: {
+    beforeClose: ({ preventClose, result }) => {
+      if (result === undefined) preventClose?.();
+    },
+  },
+});
+
+

+ A hook receives { instance, options, result, reason, preventClose }. + Calling preventClose() in beforeClose keeps the dialog open. +

+
+ +
+

Framework adapters

+

+ Each adapter is a thin wrapper over open() that ties a dialog's lifetime to + the component's, so nothing is left mounted after unmount. +

+
+ +
// React and Vue share the same hook shape.
+import { useForgeDialog } from 'forgedialog/react';
+const { open } = useForgeDialog();
+await open({ title: 'Hi' }).whenClosed();
+
+// Svelte: an action on the triggering element.
+import { dialogTrigger } from 'forgedialog/svelte';
+// <button use:dialogTrigger={{ title: 'Hi' }}>Open</button>
+
+// Web component.
+import { defineForgeDialog } from 'forgedialog/web-component';
+defineForgeDialog(); // <forge-dialog open title="Hi" message="…" size="md">
+
+
+ +
+

TypeScript, SSR, and CSP

+
    +
  • + Types ship for both ESM and CJS. open<T>() threads its result type + through buttons, whenClosed(), and the hooks. +
  • +
  • + Importing the package on a server does not touch the DOM, so it is safe in SSR builds; + the dialog is only created when you call the API in a browser. +
  • +
  • + The bundle contains no eval and no new Function, which is + enforced in CI, so it runs under a strict Content Security Policy. This site is served + under one. +
  • +
  • Every entry point has an enforced gzip budget, checked on each pull request.
  • +
+
+ + +
+
+ +
+
+ MIT licensed. Built with no runtime dependencies. + + GitHub · + npm · + Changelog + +
+
+ + + + + + diff --git a/site/docs.js b/site/docs.js new file mode 100644 index 0000000..4f4bceb --- /dev/null +++ b/site/docs.js @@ -0,0 +1,30 @@ +/* Docs-page behaviour: keeps the sidebar in step with what is on screen. */ + +(() => { + const links = [...document.querySelectorAll('.docs-nav a')]; + const byId = new Map(links.map((link) => [link.getAttribute('href').slice(1), link])); + const sections = [...document.querySelectorAll('.docs-body section[id]')].filter((section) => + byId.has(section.id), + ); + if (sections.length === 0) return; + + let current; + + function highlight(id) { + if (id === current) return; + byId.get(current)?.removeAttribute('aria-current'); + byId.get(id)?.setAttribute('aria-current', 'true'); + current = id; + } + + // The topmost section still intersecting the viewport is the one being read. + const observer = new IntersectionObserver( + () => { + const heading = sections.find((section) => section.getBoundingClientRect().bottom > 90); + if (heading) highlight(heading.id); + }, + { rootMargin: '-80px 0px 0px 0px', threshold: [0, 1] }, + ); + + for (const section of sections) observer.observe(section); +})(); diff --git a/site/icon.svg b/site/icon.svg new file mode 100644 index 0000000..4b2c554 --- /dev/null +++ b/site/icon.svg @@ -0,0 +1,25 @@ + + Forge Dialog + + + + + + + + + + + + + + + + + + + + + + + diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..106979f --- /dev/null +++ b/site/index.html @@ -0,0 +1,537 @@ + + + + + + Forge Dialog — accessible, dependency-free dialogs for the web + + + + + + + + + + + + + + + + + + + + + + + + +
+
+

Dialogs that behave themselves.

+

+ Forge Dialog is a dependency-free TypeScript dialog library built on the + native <dialog> element. Accessible by default, themeable down to + individual components, and small enough that an alert-only bundle stays under 6 KB + gzipped. +

+ +
+ + + + + Read the docs +
+ +
+ npm install forgedialog + +
+
+ +
+
+

What you get

+

+ Everything below is in the published package — no plugins to install, no peer + dependencies to satisfy. +

+ +
+
+

Accessible by default

+

+ Focus is trapped and restored, Escape and backdrop clicks are wired up, and ARIA + roles are set for you. Focus detection skips targets that only look focusable. +

+
+
+

Styled per component

+

+ Colour and opacity for the surface, title, body, and border. Corner radius per + corner. Shadows composed from an angle, distance, blur, and strength. +

+
+
+

Hover, drag, and gestures

+

+ Opt-in hover restyling with lift and scale. Draggable dialogs with axis locks, + bounds, keyboard movement, and persisted positions. Swipe-to-dismiss sheets. +

+
+
+

More than alerts

+

+ Drawers, bottom sheets, lightboxes, loading states, toasts, a command palette, and + persisted branching wizards — all from the same core. +

+
+
+

Honest bundles

+

+ Tree-shakable entry points with enforced gzip budgets. Import + forgedialog/alert and you pay for an alert, not the whole library. +

+
+
+

Fits your stack

+

+ ESM, CJS, and a browser global, with adapters for React, Vue, Svelte, and Web + Components. Typed throughout, SSR-safe, and clean under a strict CSP. +

+
+
+
+
+ +
+
+

Playground

+

+ Change anything below and the dialog restyles live. The code updates with it — copy it + and you have the same dialog in your app. +

+ +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + + +
+ +
+ +
+
+
+
+
+ +
+
+

Examples

+

+ The everyday calls. Each returns a promise, so there is no callback nesting. +

+ +
+
+

Replace the browser built-ins

+
+ +
import { alert, confirm, prompt } from 'forgedialog';
+import 'forgedialog/style.css';
+
+await alert('Saved successfully.', { title: 'Success' });
+
+const ok = await confirm('Delete this item?');
+
+const name = await prompt('What is your name?', {
+  validate: (value) => value.trim() ? true : 'Name is required',
+});
+
+
+ +
+

Build your own dialog

+
+ +
import { open } from 'forgedialog';
+
+const dialog = open({
+  title: 'Custom dialog',
+  content: (container) => container.append(renderForm()),
+  buttons: [
+    { text: 'Cancel', onClick: (i) => i.close(null) },
+    { text: 'Save', role: 'primary', onClick: (i) => i.close(read()) },
+  ],
+});
+
+const result = await dialog.whenClosed();
+
+
+ +
+

Pay only for what you use

+
+ +
// Just an alert, and nothing else.
+import { alert } from 'forgedialog/alert';
+import 'forgedialog/style/core.css';
+
+// Opt into the richer appearance options on a focused entry.
+import 'forgedialog/appearance';
+
+
+ +
+

Beyond dialogs

+
+ +
import { drawer, toast, wizard } from 'forgedialog';
+
+// A duration of 0 keeps the toast until dismiss() is called.
+const upload = toast('Uploading…', { duration: 0 });
+
+drawer({ title: 'Settings', side: 'right', content: renderSettings });
+
+const data = await wizard({ steps }).result;
+
+
+
+
+
+ +
+
+

At a glance

+

+ The short, factual version — the numbers come from the size budgets enforced on every + pull request. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameForge Dialog
npm packageforgedialog
LicenseMIT
Runtime dependenciesNone
Size + ≈15 KB gzipped for the full bundle; ≈5.5 KB for an alert-only build +
Written inTypeScript, with types for both ESM and CommonJS
BrowsersChrome/Edge 88+, Firefox 78+, Safari 15.4+
Node.js20 or newer, for package tooling and SSR imports
FrameworksWorks standalone; adapters for React, Vue, Svelte, and Web Components
Source + github.com/cmm-cmm/ForgeDialog +
+
+
+
+ +
+
+

Frequently asked questions

+ +
+

What is Forge Dialog?

+

+ Forge Dialog is an open-source TypeScript library for building dialogs on the web: + alerts, confirmations, prompts, drawers, bottom sheets, lightboxes, loading states, + toasts, command palettes, and multi-step wizards. It is published on npm as + forgedialog, licensed MIT, and has no runtime dependencies. +

+ +

How large is Forge Dialog?

+

+ The complete bundle is roughly 15 KB gzipped. An application that imports only + forgedialog/alert ships roughly 5.5 KB gzipped, because every entry + point is tree-shakable and carries a size budget that continuous integration enforces + on each pull request. +

+ +

Does Forge Dialog need a framework?

+

+ No. Forge Dialog is framework-agnostic and works with plain JavaScript or a + <script> tag. Optional adapters for React, Vue, Svelte, and Web + Components ship in the same package. +

+ +

Is Forge Dialog accessible?

+

+ Yes. It renders the native <dialog> element, traps focus while a + dialog is open and restores it to the trigger on close, wires up Escape and backdrop + dismissal, and sets role="dialog" or + role="alertdialog" automatically. Motion honours the + prefers-reduced-motion setting. +

+ +

How is it different from window.alert, confirm, and prompt?

+

+ The browser built-ins block the main thread, cannot be styled, and offer three fixed + shapes. Forge Dialog returns promises instead of blocking, can be styled down to + individual components, and covers drawers, sheets, forms, and wizards as well. +

+ +

Can the appearance be customised?

+

+ Yes. Colour and opacity are set independently for the surface, title, body, and + border; corner radius can be set per corner; shadows are composed from an angle, + distance, blur, spread, and strength; hover effects are opt-in per dialog. All of it + can be changed while a dialog is open by calling update(). +

+ +

Which browsers and runtimes are supported?

+

+ Chrome and Edge 88+, Firefox 78+, and Safari 15.4+, plus Node.js 20 or newer for + package tooling and server-side imports. The bundle contains no eval and + no new Function, so it runs under a strict Content Security Policy. +

+ +

Is Forge Dialog free to use commercially?

+

+ Yes. Forge Dialog is released under the MIT license, which permits commercial use, + modification, and redistribution. +

+
+
+
+
+ +
+
+ MIT licensed. Built with no runtime dependencies. + + GitHub · + npm · + Changelog + +
+
+ + + + + + diff --git a/site/og-image.png b/site/og-image.png new file mode 100644 index 0000000..959bc9d Binary files /dev/null and b/site/og-image.png differ diff --git a/site/site.js b/site/site.js new file mode 100644 index 0000000..6d01b06 --- /dev/null +++ b/site/site.js @@ -0,0 +1,130 @@ +/* Landing-page behaviour: the hero demos and the appearance playground. */ +/* The shared chrome (version badge, theme, copy buttons) lives in common.js. */ +/* global ForgeDialog */ + +(() => { + const { alert, confirm, prompt, open } = ForgeDialog; + const $ = (selector) => document.querySelector(selector); + + /* Hero demos ----------------------------------------------------------- */ + + $('#try-alert').addEventListener('click', () => { + void alert('Focus is trapped here, and returns to the button when this closes.', { + title: 'Heads up', + }); + }); + + $('#try-confirm').addEventListener('click', async () => { + const result = await confirm('Escape and the backdrop both resolve this to false.', { + title: 'Please confirm', + }); + void alert(`confirm() resolved ${result}.`, { title: 'Result' }); + }); + + $('#try-prompt').addEventListener('click', async () => { + const value = await prompt('Try submitting it empty.', { + title: 'What should we call you?', + defaultValue: 'Ada Lovelace', + validate: (input) => (input.trim() ? true : 'A name is required'), + }); + if (value !== null) void alert(`Nice to meet you, ${value}.`, { title: 'Result' }); + }); + + $('#try-wizard').addEventListener('click', () => openPlayground()); + + /* Playground ----------------------------------------------------------- */ + + const controls = { + surface: $('#c-surface'), + title: $('#c-title'), + titleBg: $('#c-title-bg'), + titleBgOn: $('#c-title-bg-on'), + border: $('#c-border'), + borderWidth: $('#c-border-width'), + radius: $('#c-radius'), + angle: $('#c-angle'), + distance: $('#c-distance'), + strength: $('#c-strength'), + hover: $('#c-hover'), + draggable: $('#c-draggable'), + }; + + const outputs = { + borderWidth: $('#o-border-width'), + radius: $('#o-radius'), + angle: $('#o-angle'), + distance: $('#o-distance'), + strength: $('#o-strength'), + }; + + function readOptions() { + const appearance = { + surfaceColor: controls.surface.value, + titleColor: controls.title.value, + borderColor: controls.border.value, + borderWidth: Number(controls.borderWidth.value), + radius: Number(controls.radius.value), + shadow: { + angle: Number(controls.angle.value), + distance: Number(controls.distance.value), + blur: 48, + opacity: Number(controls.strength.value), + }, + }; + + if (controls.titleBgOn.checked) appearance.titleBackground = controls.titleBg.value; + if (controls.hover.checked) { + appearance.hover = { + borderColor: '#ff9f1c', + titleColor: '#ff9f1c', + lift: 6, + scale: 1.02, + shadow: 'xl', + }; + } + + return { + title: 'Styled with the appearance API', + message: + 'Every colour, the corner radius, and the shadow direction come from the controls on the left. Drag me by the header.', + appearance, + draggable: controls.draggable.checked ? { bounds: 'viewport' } : false, + buttons: [{ text: 'Close', role: 'primary', closesDialog: true }], + }; + } + + function toSource(options) { + // JSON gives stable, readable output; the small fixups below turn it into + // something that can be pasted straight into a source file. + const body = JSON.stringify(options, null, 2) + .replace(/"([A-Za-z][A-Za-z0-9]*)":/g, '$1:') + .replace(/"/g, "'"); + return `import { open } from 'forgedialog';\nimport 'forgedialog/style.css';\n\nopen(${body});`; + } + + let preview; + + function refresh() { + outputs.borderWidth.textContent = `${controls.borderWidth.value}px`; + outputs.radius.textContent = `${controls.radius.value}px`; + outputs.angle.textContent = `${controls.angle.value}°`; + outputs.distance.textContent = `${controls.distance.value}px`; + outputs.strength.textContent = Number(controls.strength.value).toFixed(2); + + const options = readOptions(); + $('#playground-code').textContent = toSource(options); + preview?.update(options); + } + + function openPlayground() { + if (preview?.isOpen()) return; + preview = open(readOptions()); + preview.whenClosed().then(() => { + preview = undefined; + }); + } + + $('#controls').addEventListener('input', refresh); + $('#open-playground').addEventListener('click', openPlayground); + refresh(); +})(); diff --git a/site/styles.css b/site/styles.css new file mode 100644 index 0000000..bbdf92c --- /dev/null +++ b/site/styles.css @@ -0,0 +1,640 @@ +/* ForgeDialog site. Hand-written, no build step, no dependencies. */ + +:root { + color-scheme: light dark; + + --page: #f6f7f9; + --surface: #ffffff; + --surface-2: #f0f2f5; + --text: #14171c; + --muted: #5b6270; + --border: #e0e3e9; + --accent: #4c5fd7; + --accent-text: #ffffff; + --code-bg: #14161d; + --code-text: #e6e8ef; + --radius: 12px; + --wrap: 1080px; + --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-fd-theme='light']) { + --page: #0e1014; + --surface: #171a21; + --surface-2: #1f232c; + --text: #eceef3; + --muted: #9aa1ad; + --border: #2b303a; + --accent: #8896ff; + --accent-text: #0e1014; + } +} + +:root[data-fd-theme='dark'] { + --page: #0e1014; + --surface: #171a21; + --surface-2: #1f232c; + --text: #eceef3; + --muted: #9aa1ad; + --border: #2b303a; + --accent: #8896ff; + --accent-text: #0e1014; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: var(--font); + color: var(--text); + background: var(--page); + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +a { + color: inherit; +} + +.wrap { + width: 100%; + max-width: var(--wrap); + margin-inline: auto; + padding-inline: 20px; +} + +/* Header ------------------------------------------------------------------ */ + +.site-header { + position: sticky; + top: 0; + z-index: 10; + background: color-mix(in srgb, var(--page) 88%, transparent); + backdrop-filter: blur(10px); + border-bottom: 1px solid var(--border); +} + +.site-header .wrap { + display: flex; + align-items: center; + /* Wraps rather than overflowing: on a narrow phone the nav takes a second + row instead of pushing the page sideways. */ + flex-wrap: wrap; + gap: 8px 16px; + min-height: 60px; + padding-block: 8px; +} + +@media (max-width: 560px) { + .site-header .wrap { + gap: 6px 12px; + } + + .brand { + /* Own the first row so the links wrap together underneath it. */ + flex: 1 0 100%; + margin-inline-end: 0; + } +} + +.brand { + display: flex; + align-items: baseline; + gap: 8px; + font-weight: 700; + font-size: 1.05rem; + text-decoration: none; + margin-inline-end: auto; +} + +.version { + font: 600 0.72rem/1 var(--mono); + color: var(--muted); + padding: 4px 7px; + border: 1px solid var(--border); + border-radius: 999px; +} + +.nav-link { + color: var(--muted); + text-decoration: none; + font-size: 0.92rem; +} + +.nav-link:hover, +.nav-link:focus-visible { + color: var(--text); +} + +/* Buttons ----------------------------------------------------------------- */ + +.btn { + font: inherit; + font-size: 0.92rem; + padding: 9px 16px; + border-radius: 9px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + cursor: pointer; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 8px; + transition: + background-color 140ms ease, + border-color 140ms ease; +} + +.btn:hover { + background: var(--surface-2); +} + +.btn--primary { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-text); + font-weight: 600; +} + +.btn--primary:hover { + filter: brightness(1.08); + background: var(--accent); +} + +.btn--sm { + padding: 6px 11px; + font-size: 0.85rem; +} + +/* Hero -------------------------------------------------------------------- */ + +.hero { + padding: 72px 0 56px; + text-align: center; +} + +.hero h1 { + font-size: clamp(2.1rem, 6vw, 3.3rem); + line-height: 1.12; + letter-spacing: -0.02em; + margin: 0 0 16px; +} + +.hero p.lead { + font-size: clamp(1rem, 2.4vw, 1.18rem); + color: var(--muted); + max-width: 62ch; + margin: 0 auto 28px; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: center; + margin-bottom: 28px; +} + +.install { + display: inline-flex; + align-items: center; + gap: 12px; + background: var(--code-bg); + color: var(--code-text); + font: 0.92rem/1 var(--mono); + padding: 12px 12px 12px 18px; + border-radius: 10px; +} + +.install code { + font: inherit; +} + +.copy { + font: inherit; + font-size: 0.78rem; + padding: 6px 10px; + border-radius: 6px; + border: 1px solid #3a3f4d; + background: #232833; + color: var(--code-text); + cursor: pointer; +} + +.copy:hover { + background: #2d323e; +} + +/* Sections ---------------------------------------------------------------- */ + +section { + padding: 56px 0; + border-top: 1px solid var(--border); +} + +section h2 { + font-size: clamp(1.4rem, 3.4vw, 1.9rem); + letter-spacing: -0.01em; + margin: 0 0 8px; +} + +section p.section-lead { + color: var(--muted); + margin: 0 0 28px; + max-width: 66ch; +} + +/* Features ---------------------------------------------------------------- */ + +.features { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(250px, 100%), 1fr)); + gap: 16px; +} + +.feature { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; +} + +.feature h3 { + margin: 0 0 6px; + font-size: 1rem; +} + +.feature p { + margin: 0; + color: var(--muted); + font-size: 0.92rem; +} + +/* Playground -------------------------------------------------------------- */ + +.playground { + display: grid; + grid-template-columns: minmax(0, 320px) minmax(0, 1fr); + gap: 20px; + align-items: start; +} + +@media (max-width: 820px) { + .playground { + grid-template-columns: minmax(0, 1fr); + } +} + +.controls { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 18px; + display: grid; + gap: 14px; +} + +.control { + display: grid; + gap: 6px; + font-size: 0.85rem; +} + +.control label { + color: var(--muted); + display: flex; + justify-content: space-between; + gap: 10px; +} + +.control output { + font: 0.8rem/1 var(--mono); + color: var(--text); +} + +.control input[type='range'] { + width: 100%; + accent-color: var(--accent); +} + +.control input[type='color'] { + width: 100%; + height: 34px; + padding: 2px; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--surface-2); + cursor: pointer; +} + +.control--row { + display: flex; + align-items: center; + gap: 9px; + font-size: 0.85rem; + color: var(--muted); +} + +.control--row input { + accent-color: var(--accent); +} + +pre { + margin: 0; + background: var(--code-bg); + color: var(--code-text); + padding: 18px; + border-radius: var(--radius); + overflow-x: auto; + font: 0.86rem/1.65 var(--mono); + tab-size: 2; +} + +pre code { + font: inherit; +} + +.snippet { + position: relative; + /* The code inside scrolls; the snippet itself must not widen its column. */ + min-width: 0; +} + +.snippet .copy { + position: absolute; + top: 10px; + inset-inline-end: 10px; +} + +.examples { + display: grid; + gap: 18px; + grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); +} + +.example h3 { + font-size: 0.95rem; + margin: 0 0 10px; +} + +/* Footer ------------------------------------------------------------------ */ + +.site-footer { + border-top: 1px solid var(--border); + padding: 32px 0 48px; + color: var(--muted); + font-size: 0.88rem; +} + +.site-footer .wrap { + display: flex; + flex-wrap: wrap; + gap: 12px 20px; + align-items: center; + justify-content: space-between; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (prefers-reduced-motion: reduce) { + * { + transition-duration: 1ms !important; + } +} + +/* Docs -------------------------------------------------------------------- */ + +.docs-layout { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 40px; + align-items: start; + padding-top: 40px; + padding-bottom: 64px; +} + +@media (max-width: 900px) { + .docs-layout { + grid-template-columns: 1fr; + gap: 24px; + } +} + +.docs-nav { + position: sticky; + top: 84px; + max-height: calc(100vh - 110px); + overflow-y: auto; + font-size: 0.86rem; +} + +@media (max-width: 900px) { + .docs-nav { + position: static; + max-height: none; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 16px; + } +} + +.docs-nav a { + display: block; + padding: 4px 0 4px 10px; + color: var(--muted); + text-decoration: none; + border-inline-start: 2px solid transparent; +} + +.docs-nav a:hover, +.docs-nav a:focus-visible { + color: var(--text); +} + +.docs-nav a[aria-current] { + color: var(--accent); + border-inline-start-color: var(--accent); + font-weight: 600; +} + +.docs-nav__group { + margin: 16px 0 4px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--text); +} + +.docs-nav__group:first-child { + margin-top: 0; +} + +.docs-body { + min-width: 0; +} + +.docs-body h1 { + font-size: clamp(1.8rem, 4vw, 2.4rem); + letter-spacing: -0.02em; + margin: 0 0 10px; +} + +.docs-lead { + color: var(--muted); + font-size: 1.02rem; + margin: 0 0 8px; + max-width: 68ch; +} + +.docs-body section { + padding: 32px 0 8px; + border-top: 1px solid var(--border); + scroll-margin-top: 76px; +} + +.docs-body h2 { + font-size: 1.35rem; + letter-spacing: -0.01em; + margin: 0 0 12px; +} + +.docs-body h3 { + font-size: 1rem; + margin: 26px 0 8px; +} + +.docs-body p, +.docs-body li { + max-width: 74ch; +} + +.docs-body ul { + padding-inline-start: 20px; +} + +.docs-body li { + margin-bottom: 8px; +} + +.docs-body :not(pre) > code { + font: 0.86em/1.4 var(--mono); + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 5px; + padding: 1px 5px; + overflow-wrap: anywhere; +} + +.docs-body .snippet { + margin: 14px 0 18px; +} + +.docs-body .note { + background: var(--surface); + border: 1px solid var(--border); + border-inline-start: 3px solid var(--accent); + border-radius: 0 var(--radius) var(--radius) 0; + padding: 12px 16px; + color: var(--muted); + font-size: 0.92rem; +} + +/* Wide tables scroll inside their own box; the page never scrolls sideways. */ +.table-scroll { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--radius); + margin: 14px 0 18px; + background: var(--surface); +} + +.table-scroll table { + border-collapse: collapse; + width: 100%; + font-size: 0.88rem; +} + +.table-scroll th, +.table-scroll td { + text-align: start; + vertical-align: top; + padding: 10px 14px; + border-bottom: 1px solid var(--border); +} + +.table-scroll th { + background: var(--surface-2); + font-size: 0.78rem; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--muted); + white-space: nowrap; +} + +.table-scroll tr:last-child td { + border-bottom: 0; +} + +.table-scroll td:first-child { + white-space: nowrap; +} + +.docs-footer-note { + margin-top: 36px; + padding-top: 20px; + border-top: 1px solid var(--border); + color: var(--muted); + font-size: 0.9rem; +} + +/* FAQ and facts ----------------------------------------------------------- */ + +.faq h3 { + font-size: 1.02rem; + margin: 26px 0 6px; +} + +.faq h3:first-child { + margin-top: 0; +} + +.faq p { + margin: 0; + color: var(--muted); + max-width: 74ch; +} + +.faq code, +#at-a-glance code { + font: 0.86em/1.4 var(--mono); + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 5px; + padding: 1px 5px; +} + +#at-a-glance th[scope='row'] { + background: var(--surface-2); + white-space: nowrap; + text-transform: none; + letter-spacing: 0; + font-size: 0.88rem; + color: var(--text); +} diff --git a/wrangler.jsonc b/wrangler.jsonc index 6439941..9a456b2 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,8 +1,26 @@ { - "$schema": "./node_modules/wrangler/config-schema.json", + // The site is static: there is no Worker script, so every request is answered + // straight from the assets below and `site/_headers` supplies the CSP. + // + // No "$schema" key: it would have to point into node_modules, and wrangler is + // deliberately not a dependency here — deploys run it through npx. "name": "forge-dialog", - "compatibility_date": "2026-08-31", + "compatibility_date": "2026-09-02", + + // `site/` is what `npm run site:build` fills — the two HTML pages, the icon, + // and the library vendored out of `dist/`. Deploying `dist/` itself ships the + // library's .mjs/.d.ts files with no index.html, which serves nothing. "assets": { - "directory": "./dist", + "directory": "./site", + + // Without this an unmatched path gets a bare, unstyled 404 from the + // platform. site/404.html is the site's own, and it carries noindex. + "not_found_handling": "404-page", }, + + // Declared rather than left to the default so a deploy stops warning about + // them. workers.dev stays on as a fallback URL for checking a deploy; the + // canonical tags keep search engines pointed at the custom domain. + "workers_dev": true, + "preview_urls": true, }