From c7ead3bb6ae5aa9444c15d0d9f17d12cab25892b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 23:24:28 +0000 Subject: [PATCH 1/7] Add a dependency-free marketing site ForgeDialog had a README and a manual test page but nothing to point people at. site/ is a landing page with an interactive appearance playground, written in plain HTML, CSS, and browser JavaScript so the site carries no dependencies of its own, matching the library it advertises. The page runs the build from this checkout rather than a CDN: scripts/build-site.mjs copies dist/index.global.js and dist/index.css into site/vendor/, which is git-ignored. It uses only Node built-ins and rewrites no source files. The version badge reads the library's own VERSION export at runtime, so nothing has to be kept in sync by hand. The playground calls instance.update() on every control change, so the dialog restyles while it is open, and it prints the matching source alongside, so the demo doubles as documentation for the appearance API. site/_headers ships a strict CSP for Cloudflare Pages limited to same-origin scripts and styles, which the library already satisfies: test:csp enforces that dist contains no eval or new Function. docs/website.md covers local use and the Cloudflare build settings. Verified in Chromium: no console errors or failed requests, the alert traps focus and returns it to the trigger on Escape, the playground applies a 30px radius and casts its shadow right at 90 degrees, live updates reach an open dialog, and the theme toggle drives the library and the page together. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MtJ3nDatGiv7dHjvZL1uZ4 --- .gitignore | 1 + README.md | 7 + docs/website.md | 60 +++++++ package.json | 2 + scripts/build-site.mjs | 20 +++ site/_headers | 8 + site/index.html | 279 ++++++++++++++++++++++++++++ site/site.js | 168 +++++++++++++++++ site/styles.css | 400 +++++++++++++++++++++++++++++++++++++++++ 9 files changed, 945 insertions(+) create mode 100644 docs/website.md create mode 100644 scripts/build-site.mjs create mode 100644 site/_headers create mode 100644 site/index.html create mode 100644 site/site.js create mode 100644 site/styles.css diff --git a/.gitignore b/.gitignore index b99dd22..674b271 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ temp # Claude Code .claude/ CLAUDE.md +site/vendor diff --git a/README.md b/README.md index d74c8b5..36b1d78 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,7 @@ 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/ ``` Releases use Changesets (`npm run changeset`) and the protected GitHub release workflow. The @@ -267,6 +268,12 @@ 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, built and +served with `npm run site`. Deployment and Cloudflare Pages settings are in +[docs/website.md](docs/website.md). + ## Contributing Setup, the checks CI runs, and the gotchas worth knowing are in diff --git a/docs/website.md b/docs/website.md new file mode 100644 index 0000000..12301f4 --- /dev/null +++ b/docs/website.md @@ -0,0 +1,60 @@ +# 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/`, 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 page. No templating, no placeholders to substitute. | +| `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/site.js` | Demos, the appearance playground, copy buttons, theme toggle. | +| `site/_headers` | Cloudflare Pages response headers, including a strict CSP. | + +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. + +## Deploying to Cloudflare Pages + +Connect the repository in the Cloudflare dashboard (**Workers & Pages → Create → Pages → Connect to +Git**) and use: + +| Setting | Value | +| ---------------------- | -------------------- | +| Framework preset | None | +| Build command | `npm run site:build` | +| Build output directory | `site` | +| 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. + +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/`. + +### 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. + +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..b4723d6 100644 --- a/package.json +++ b/package.json @@ -226,6 +226,8 @@ "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": "npm run site:build && serve site", "prepublishOnly": "npm run build" }, "keywords": [ diff --git a/scripts/build-site.mjs b/scripts/build-site.mjs new file mode 100644 index 0000000..b51a117 --- /dev/null +++ b/scripts/build-site.mjs @@ -0,0 +1,20 @@ +import { copyFile, mkdir, readFile } from 'node:fs/promises'; + +/** + * Copies the built library into the static site so the page runs the code from + * this checkout rather than a CDN. Uses only Node built-ins: the site + * deliberately has no dependencies of its own. + * + * The page reads its version from the library's own `VERSION` export at + * runtime, so nothing here rewrites source files. + */ +const root = new URL('../', import.meta.url); +const vendor = new URL('site/vendor/', root); +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)); +} + +console.log(`site: vendored ${pkg.name}@${pkg.version} into site/vendor`); 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/index.html b/site/index.html new file mode 100644 index 0000000..0e93fa5 --- /dev/null +++ b/site/index.html @@ -0,0 +1,279 @@ + + + + + + ForgeDialog — accessible, dependency-free dialogs for the web + + + + + + + + + + + +
+
+

Dialogs that behave themselves.

+

+ 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. +

+ +
+ + + + +
+ +
+ 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;
+
+
+
+
+
+
+ +
+
+ MIT licensed. Built with no runtime dependencies. + + GitHub · + npm · + Changelog + +
+
+ + + + + diff --git a/site/site.js b/site/site.js new file mode 100644 index 0000000..bb9ebab --- /dev/null +++ b/site/site.js @@ -0,0 +1,168 @@ +/* ForgeDialog site behaviour. Plain browser JS against the global build. */ +/* global ForgeDialog */ + +(() => { + const { alert, confirm, prompt, open, 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); + }); + } + + /* 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..0fbdfe1 --- /dev/null +++ b/site/styles.css @@ -0,0 +1,400 @@ +/* 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; + gap: 16px; + min-height: 60px; +} + +.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(250px, 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: 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; +} + +.snippet .copy { + position: absolute; + top: 10px; + inset-inline-end: 10px; +} + +.examples { + display: grid; + gap: 18px; + grid-template-columns: repeat(auto-fit, minmax(320px, 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; + } +} From d9c567d4714399bb7a1655b492c469339f530bed Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 23:55:17 +0000 Subject: [PATCH 2/7] Add site docs and icon, rename the product to Forge Dialog, and optimise for search and answer engines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The site had a landing page and an emoji favicon; it now has a full API reference, a real mark, and the metadata that search engines and answer engines read. Naming: the product is Forge Dialog, two words, and every page and document now says so. Identifiers keep the one-word spelling because that is what they are: the npm package (forgedialog), the browser global (ForgeDialog), the custom element (forge-dialog), and the repository. Nothing under src/ changed, so the published API and its runtime strings are untouched. Docs: site/docs.html is a 23-section reference written against the reviewed API report in etc/, covering every option, method, and entry point. The shared chrome (version badge, theme toggle, copy buttons) moved out of site.js into common.js so both pages use it without the landing page's demo wiring. Icon: site/icon.svg is the source of truth and the favicon browsers load. scripts/build-icons.mjs renders the committed apple-touch-icon.png and og-image.png from it through Chromium, which is already a dev dependency. The touch variant squares off the mark's ground because iOS applies its own mask. The first draft of the OG image used a gradient and cost 296 KB; a flat ground looks the same at preview size and costs 37 KB. SEO: canonical, og:url, and og:image are absolute. The canonical tag in index.html is the single source of truth for the domain — build-site.mjs reads it back to generate sitemap.xml, robots.txt, and llms.txt, so moving the site is an edit to the canonical tags and nothing else. Both pages carry JSON-LD; the FAQ entries are generated from the visible questions so the markup and the page cannot disagree. Answer engines: llms.txt follows the llmstxt.org convention and states the facts models most often get wrong, with the version read from package.json so it cannot drift. robots.txt names the AI crawlers explicitly rather than leaving them to the default, and deliberately does not disallow /vendor/ — blocking a site's own CSS and JS is what breaks rendering for crawlers. Two layout bugs found while measuring, both pre-existing: - The header never wrapped, so at 380px the page scrolled sideways by 102px. - The playground's grid track had an automatic minimum of min-content, which the long code lines inside .snippet pushed open — 812px of horizontal overflow at 320px. Capping the track at minmax(0, 1fr) and giving .snippet min-width: 0 fixes it. Both pages now fit at 320, 380, 480, and 768px with zero horizontal overflow. Verified in Chromium against the production CSP: no console errors and no failed requests on either page, the inline JSON-LD survives script-src 'self' (it is a data block, not an executable script), all 23 sidebar links resolve to real sections, scroll-spy tracks the section being read, the hero alert still gets role="alertdialog" with focus returning to its trigger, and robots.txt, sitemap.xml, llms.txt, and all three icons serve with the right content types. npm run validate and format:check pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MtJ3nDatGiv7dHjvZL1uZ4 --- .github/ISSUE_TEMPLATE/bug_report.yml | 4 +- .github/ISSUE_TEMPLATE/feature_request.yml | 4 +- .gitignore | 5 + .prettierignore | 4 + AGENTS.md | 4 +- CONTRIBUTING.md | 4 +- README.md | 16 +- SECURITY.md | 4 +- demo/index.html | 4 +- docs/architecture.md | 4 +- docs/migration-0.5.md | 2 +- docs/releasing.md | 4 +- docs/website.md | 83 +- package.json | 1 + scripts/build-icons.mjs | 73 + scripts/build-site.mjs | 115 +- site/apple-touch-icon.png | Bin 0 -> 7993 bytes site/common.js | 47 + site/docs.html | 1403 ++++++++++++++++++++ site/docs.js | 30 + site/icon.svg | 25 + site/index.html | 276 +++- site/og-image.png | Bin 0 -> 37622 bytes site/site.js | 44 +- site/styles.css | 248 +++- 25 files changed, 2310 insertions(+), 94 deletions(-) create mode 100644 scripts/build-icons.mjs create mode 100644 site/apple-touch-icon.png create mode 100644 site/common.js create mode 100644 site/docs.html create mode 100644 site/docs.js create mode 100644 site/icon.svg create mode 100644 site/og-image.png 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 674b271..c910341 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,8 @@ temp .claude/ CLAUDE.md site/vendor + +# Generated by scripts/build-site.mjs +site/robots.txt +site/sitemap.xml +site/llms.txt diff --git a/.prettierignore b/.prettierignore index acb948d..7fcc0a2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,3 +6,7 @@ playwright-report test-results temp .consumer-builds +site/vendor +site/robots.txt +site/sitemap.xml +site/llms.txt 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/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 36b1d78..09c079c 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. @@ -258,6 +258,7 @@ 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 @@ -270,9 +271,10 @@ tooling and SSR imports. ## Website -`site/` holds a dependency-free landing page with an interactive appearance playground, built and -served with `npm run site`. Deployment and Cloudflare Pages settings are in -[docs/website.md](docs/website.md). +`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 Pages +settings are covered in [docs/website.md](docs/website.md). ## Contributing 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..a60310c 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 @@ -69,7 +69,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 ``` diff --git a/docs/website.md b/docs/website.md index 12301f4..2b6d027 100644 --- a/docs/website.md +++ b/docs/website.md @@ -9,21 +9,26 @@ dependencies and no framework, matching the library it advertises. npm run site ``` -That builds the library, copies `dist/index.global.js` and `dist/index.css` into `site/vendor/`, and -serves `site/`. Use `npm run site:build` on its own if you only need the files. +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 page. No templating, no placeholders to substitute. | -| `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/site.js` | Demos, the appearance playground, copy buttons, theme toggle. | -| `site/_headers` | Cloudflare Pages response headers, including a strict CSP. | +| 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/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. | +| `site/_headers` | Cloudflare Pages response headers, including a strict CSP. | 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. @@ -31,6 +36,59 @@ 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 with a `lastmod` date. +- **`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 Pages Connect the repository in the Cloudflare dashboard (**Workers & Pages → Create → Pages → Connect to @@ -50,11 +108,16 @@ package's `engines` field requires. 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/`. +Point the custom domain at the project in **Pages → Custom domains**. It has to match the canonical +tags, or search engines will be told the page lives somewhere it does not. + ### 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. +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 b4723d6..e6340a9 100644 --- a/package.json +++ b/package.json @@ -227,6 +227,7 @@ "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" }, 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 index b51a117..d8df470 100644 --- a/scripts/build-site.mjs +++ b/scripts/build-site.mjs @@ -1,15 +1,17 @@ -import { copyFile, mkdir, readFile } from 'node:fs/promises'; +import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'; /** * Copies the built library into the static site so the page runs the code from - * this checkout rather than a CDN. Uses only Node built-ins: the site - * deliberately has no dependencies of its own. + * 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. * - * The page reads its version from the library's own `VERSION` export at - * runtime, so nothing here rewrites source files. + * 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 vendor = new URL('site/vendor/', root); +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 }); @@ -17,4 +19,105 @@ 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: '/', + priority: '1.0', + title: 'Forge Dialog', + summary: 'What it is, live demos, and an interactive appearance playground.', + }, + { + path: '/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.', + }, +]; +const lastmod = new Date().toISOString().slice(0, 10); + +await writeFile( + new URL('sitemap.xml', site), + ` + +${pages + .map( + (page) => + ` \n ${origin}${page.path}\n ${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/apple-touch-icon.png b/site/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..32747858fc627ea386e27d4587350e698947e283 GIT binary patch literal 7993 zcmZvBc|4Tu+dh$eH5K)!Zd*hpD(N=N*ajm@*@;4;Wo}FMeH&XQNp{(WA=%54{SlH~ z%?#Nd`%aeZ+hBNa&-;7d&-=&k4`V*}=bmdhuH!n+^E}QsI$Ely*tpmj7#L2et10Wj z|4WblSdYPfEIh%SxCC^y%cTTBE6rQBh|-zIJt0)V1lTh$`Yt(0JLq zlEl7$h(U*m#KirX8nXk9K@GM<{nGl<&!}wQOj}%*ZGY;SFeS;lwg&Y5K|>79vcsUHc^;#BcW#?BnPKro0NB$r_UD zrh_lbubO*%K78xu!e|6YVm7ON^KH3jCH|CU9KTC@y&Oj)dyfxTjozo)mX8AggEJ3G ztgdaIebBsey-afpJ?+Aomdi25cFxys=40RN?Tp-5%j8Ap_QVC}yU$)T8-fg#+X;ep zcDkau>_0gMkKJP}_|K^WM6$j!-*YNYs#3_yEnUz4%ECWY0meTB8#9hyJ7s4&jB?b{ z)MK*aCc7q+oT58I%B==fW(lTR zTuuk;=9JXvI>@yXk2N8l>aCrCz@> zLEP#uBI*AMiy{WBqOnF3h`x8NmMQ{}f;CIg8Dane;->Py>bhX}9ztP{qp|sK&<(P$m6$0Vo|LplOS^I(0X~R99L3~G&iM|A zv;xA@2&w!`Tta!KXk8Vx z99NzU>!qfBiL^Nt@ys{^l;|n6aXdxgCo^zOARa`4Il)>n{?8Vn4%PuxN}@&#XpDIl zN@O(H0*R?KbQcd~?nCK_Lp8Vx-bw1*1@n)xvfp&V$IvBY
Q2O~nPsU?wbM9&&gQf_xR5AqoAyM;!l~10chz3YvyO z+8KtdEeix5e5rjI7w1S|b5dD=6nM2T*jb5&DQ%kUV|pMag9U>i%wPLkq_YQHRK)V+5%DB?=r}V(JC*H8ALD?nXw6;CXvP^uoO1O z7866zhw^`6??g9j(Qca<2mk*7x84P01IIXcaQDlelq9O=A^sRLQ(8mkXD-RjIDPLa zJj;8`XmX47ZQEGfwF|dpl7If>Pf_1JoJblQ5v?|=pEKQZwkY(xzf;AvZ+A9{Q`p`l zOM9oPYT;UO*|FmGf6}|YG*;Ty6fA9a40jC|3=AxC&YV~;o9R*RR`ndbnF+! zO|Gky8G)}>e0-gJoFmIeT$%`Ut@29E(v@~^+St_gR`e#a#!Yo}xcd=>VL?Lfj054;1j3*jQ4jEwZ`*3W zMGB%~(p(d9Q~70qxJsi`H0V6;a|aV|S(Wr^;Og^6M-@ezrVI@3%4U>Y;f;*vP9C%& z>eI}V3y(&FH5_M5on}5Qxj0N_E)DQLR}FCc+=I;62`K~;*W2G2g$9j7L+-|pzeZB1 z`8*J79y>AW(v%LKOF;glUwFLmYn0f%3pt2ae7Y%MV_r56TJ30ZuOvJn z@JI8x`-3tg{`5`omRzivr-z#kz`2j`4pq=WM~d-Hp9gYz?ncRpIo6`d{)fMD;l3B> z&+zuq7o-Ovg$qu8;I;a$iLIVy2bE;BFjx#UsNJ;JO zgTwNFfA2$x+z$4@6Q&R=2hYC!F<$)aOjb$9h4`lNh7RDH_kGrU3sW8&e;5s(7dvZb zP+F(1K_tD!S9q-~GG~9_0EILMqFb8+x)I))X_pPXm;v5MeW6(ZUmmuogf% z2Y_YS=pq|BSjJs`PTx8M&oZ>l;7}W1{!{!q6ha6aLv#h!zVx5TG!-&HT|0AZ+glCX z`ELN=%gWw?n6_d`i=0Gob|Ye)2tvwz`1Occqv%Sa#o$#Wwn{$RHSY;e*Cx0dk^R1V z;)8TKp5;4{V*`AdeM{`xwF~v=j6EO_^k63c|mNf*WRHG1QW(ASDv9bqUuq3uom)&nu3+JpmviwyKgG;|B!cEtpp6eo;(L>zxBFwnED zfDV;lD&9xPH{nwc+&`NwSKtYw#+|vbiMtlG+K&Vre6;*%#F1*tran{{^}P5Q*rX%c0Cx1b=Y83CDE{4uw9AkK@@JN0%MLN1 zlc;<4L_an1I7==t27JH~PAj-l6p$FK6U&%Kh}=C^a>xwS>e+vIIK-2K4?=sgSB_$n z>RWP6;xMB@x)gxblCUO$)eX-UXTYiUM5fDZ8q#B2)j+d^u?;3(^g>%PzQ+=;{dtN;px>31y2R(jH z_zUf^*bQ>w;Xh4&GRd-X*}c%F*MN!s-^zDRyST(;hD#6Y{z$HgD=e&?Y~H=6`yzoa zA$d7Mi+b1`)GJewB<#tX!&BeE`ILS0&#dlJcttCo#b;gu%o=PG!Z#1z^4skJ55GDr z=ngeUy$K$ld^8ri8&vapbWLfupwQHBd!VKSAhY|*2ZGHj;3u4LJd~i9o#xtS^=VK& zU%Y@RxDfjVKW-CUr{!%efW%rX|1mmPbshA}TlyLz$U8SMX|%Ev7aZ~R>so*+$?vdt z$!hAn=(j$Et}+sd4G-*_b^CO}DA3{NijcnAO_Q>av#(TeNXjwiwWygUv z1o3m@qmRw#CrTNA4%0{^nZ4R3+9?Gm7E@A}ns<_J3$+NtO1?drATbWD@H=m|az!t& zD&d>G3l5roeWs?J3QZ(u`)$>q5!v8c0|{*pL56G|Mv4Le!A#Xzcyn{;QJhn=iBYYO z=VpUtL&Uj?x}BxI{oEmJ{J!Kjn^|WdlW!^b7oE>* zHZvkk2h@oJT>!}cVz)?(B_-h}b!`8#N$RsAiHc&U*-6U`)cM{6diKah*>AiGjt6b> zD}*F~4l=x4rB1bOUYV2G{nFX5z@51Y8p)p@rglingwLq``wq6s)o!QPAD57Q!Cn5f zq)OBNjFGn3XBl$H_)$V-W(2=g&&FIZdW@3R;`}zhZC9$ZIY+04GB~n9g4LXr!d~4s z?kj3R`NoVV$W6E$WBbMOj6A0Au~4PTIgs<0z3g70BR%XZPGMw~P&91_a$ycwf-Zr^&m%?AEvzALA3SQzkl$L)gBqKGs3-}<9n_m+JTbPNu zQ_yoXcGd3gi=CjPR5!b{ccGnway6xr9%tL&ORUHyaWPRS2Z-5)CDfFD+Jqk+M0<1! zVj9TdF}h_M@f&)j;;@n+F#*BrPEuT6LpHEkEI4$(nT|>3pd(rUoQC>vOcokzNgz-G z|Dp9#U9ro%Kr}d%u;wdzY6n1*BW3t^5tI>?;gH`jK;|MkTj3S|;8o1P-_>%{JxC(H zR99w#2Jn@2uv#yR=|zkoG`<1lnn@B$1=nrEfK;5u6#Tof;e5Rru3vC^eBGYh6`S4@ zCS0FX?`wCuZyWrGW6;`*xknTaCt0NUJ$KES4pXbgetPCaXw8k*qui&zuw-C3J#pj( zBs3n0+ntIej+^kD(V#MAoY&q>Yj3-3VDCetv+=u~TB&P)h)PeICsn;Hwk|%;e39n{~mGA}zg8MOrly zPUNQ%hT(Y1#HfsXE~(!8T!H29_Y#-ynqru$93^f0xy3Ix0v89V{5R8H zmY!7SS_i)pUE)8TPJv9sCzX{kL9QXrTm@>}>!2*f!F$0}r>EjykQ;%o&x$4!(yn6~ zW)ao_h+@5RT^Shsv|_>Ro^bPlkEC%c0_8s^jI{H4p26c_#UYQ6Ip;LId{z9xhbas@ z)q>tn^+4n4i(&@!1uzta^ zzdzypx2d+>ZxfEl$=7m_UaE+JQyIe;A*eD>iF@-y0yw`1A7}`N1}q64j~$cJ7w0F9 zMAzN8%Pwaft!nT5d37wFrSND&A|u%{F2k*H z6E3|RCnPq>S84G+}X;+%+LUHpU7#wjOJGbGqit><5IvQd$U%)#TAkwxZ~87N-^tC~HslL%S-+7w0{8s+`*4 zF~C%rR?FEq5!aJCWQ5xak`3P{67HVV>rSjucPq?me7ptEDhpNaA~Bun z!^87(bl#e(P7`TlP*QQR=dz4;9qMbsvwNMRezjXsakWi>zVALG_cFPk?f~q>dNkQx zlmo>?t5FXDoorHTBtKCvNj+Ig$GsdLK4`8;O~Jh|-?_3EqrHhK4WhsO<4s@YwX)X> z1`XbWIzNH50llCf%&;N_5-qe_|N0IwG}HMr4KLZiA2(? zzgV_XSsh^e;Ah^(eu=k@yY;6VgEA>*_M>j!Qfln7OYDted}iNhnPM8kt}ovjuKA+5 z2RwpfR6X$}AIaxyv(m_khW=8?Z_S}GC)Z0#_SWa}#Wt4^2Wxg3N5;ieigMdu`-qrR zXtMeA?FTzF>LQ}>Tt1iR1&TRoVuH3;q`mrk^pDB}f*Fmi^3>e^{2a;DHHMgr+Bh?6 z_6JXUi!8|US(%{Ifw0^b-yd%;@cl!Y@SxF_&K>sTHUN1KL34L$c}OAbnKTk3*aCcy zNhgx_Hnt)T8i&5CKgi79U8dJdnbuVOT`rlqXV|^Jzevx%KppX8+xv?|Ur2%Tp*En% zS|a4HOg>=|QCOIllH-?qpc`?a(DiWcoXja2P>UCOyo9VZV zUC@>-Cq1#?c~yDva$B&@cYE-#x-W-h5fO>|=jFGb5&J94uS8CxwE`-xM&Z&TL0PV| zn~V>x46f*PwH#D;`T3Byv+u!qaRuM#UgIUZ(czy?EFz8sO(ikiXsP=stcH8F$s0Fc z6`cKt_QFGkl=R+}ZJDeldY{ihmyEW2`Cb7DLuXQ`F3#KsONnCaAn_- z;bq73A~+GLOm8k6a>{SJ{kw|idWf-el;?5vcwenc6PEFZj0$EaL%ZvVZSB}L?OJ-{ z!l;DzVZWfL;CA_jdtImR{{0ktKi@MW`vlB;GmN)29Pf7O$#N-ub1#dYtS!{}*zDp7 zYwjh|AA!5ea?W-$oAz2pd`W2MTj)K7wQo(#uO)l!=8WvN%pUbfnf=BuCl6mJaGjRA zs=p38^)IdlIK?_O^XL6^%Dsf9T)dWidit+8BU(+rV1G}14qEEu$+f)}Y<~`!h9*BX zzBvdN@H1hHCyanDpX3R%T`;#r%(DWra{ga)gh6M*wl;ZgFJk-V%ky*-cMSFulS1YO+m2r?pRoivrL@^$Q=UY6)%At zK=PWU;XjW~uf7YADsO-tYDEtMImW!5VF;%b3%0qzEsyHCh3O&&ci1-O~u(Da2n+1W`nAB<`Cv zK`8{((FK_y{z8Bg9pp_ewS|+Z;~AGBe>Bs@CP4ZJ;GlKk7%_mAXyNVj&wX`6FwX(@ zd0}52d-k%hpFUAQMnUm+BFdR_3egF}5>SlB(uj=JQUQg4`5z8q`g2mr_i3cyRmieZ zTr(JF4zIC^nC_wmt`P$+Qxr5QX##6#rcE@H@NAfdWZXDnMCRLIa^pCHGX;EJ&Mb~@ zuqsRlzCvVt99gmnW*sGK0!~?$bj0);11Bn|Izt5Faltff32Ac~GoCe=f?ayl-$x1J zKfR~JGtW7*grb=^(bzJ4bL}qh2{87yQTw$E3`GQjrODHH$q?OHU1i22s8HYVEIeXs zTz7sKmxh5ZkzmIz5c}l31_5*k*+UoSH8o`gVSmvW(?C|c$=VM+S+=0B=F!R_LRL-+_+f zXcNpN|B<*^E}a^Q&4WkGcjdW%q+#6$LO)uE<199Bo zkIo((O$D+dXx#QNkwm%?eAhxKr)^YH;Td7kKk=RFKZdYg9}ClSpZQD@kidqYYezAu zemh#TWF6muq@um=St4W4F0dlOH0C)sQ!$(j8w^ETtdudzufTxQ7a^UdGkjfo6c}kK z!7_eC1cE&ZH1L5NF5FGP4UV)?s%|5o<=u4S#yDaM-SDG#7wB(iwQeq@BfcZ-dG;`w z@=z(BWj6k}pwq9=ISJTui<}PABl|C|Uw0ym&c=cPEr4+u{(mlz>JKV|PfvD#@(hZp zi5L({OgF|%>d*kw0(&sFO%*YKi+%sAG5!mLy5?(|~Z!{0_a*#h^ zL-<4PrLLfv-0Y7%0ly6hqJm`ZpJb7?BwC23h6`(hKUuTNTi`{1CItvCLpem;!)bVP zu}&-x!Pr6o?zl7&>wRx=s(JdF#e+J*;IT0bMG>nA9a3&rR-l { + 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..c011056 --- /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 index 0e93fa5..3147d26 100644 --- a/site/index.html +++ b/site/index.html @@ -3,34 +3,159 @@ - ForgeDialog — accessible, dependency-free dialogs for the web + Forge Dialog — accessible, dependency-free dialogs for the web - + - + + + + + + + + + + + + +
@@ -176,7 +176,7 @@

Dialogs that behave themselves.

- Read the docs + Read the docs
diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 index 0000000..c404f77 --- /dev/null +++ b/wrangler.jsonc @@ -0,0 +1,19 @@ +{ + // 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. + "name": "forge-dialog", + "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": "./site", + }, + + // 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, +} From ee1dd8f59d05b40b5247ec7c27677a038eb4d485 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 04:57:18 +0000 Subject: [PATCH 4/7] Add a 404 page, correct sitemap lastmod, and fix stale docs A repo-wide pass before the worker is recreated from scratch. An unmatched path got the platform's bare 404, so site/404.html now answers for them through not_found_handling. It links its assets root-relative rather than with "./" because it also answers for nested paths, where a relative href would resolve against the missing directory, and it carries noindex with no canonical tag since it has no URL of its own. The sitemap stamped every entry with the build date, which told crawlers both pages change daily. lastmod now comes from the last commit that touched each page's HTML. A build with no reachable git history writes the entry without a lastmod rather than with a plausible-looking date. npm run check:release reported what the docs had not caught up with: forgedialog 0.7.0 was published on 1 September. docs/releasing.md still said the package was unpublished and framed the manual first publish and the E404 troubleshooting entry as the likely path; both are now scoped to a fork starting from an unpublished name. package.json homepage moves from the README anchor to the site, listed under Unreleased because 0.7.0 is already out and cannot carry it. README no longer calls the deployment Cloudflare Pages. Verified against wrangler dev with the production _headers: /, /docs and every asset return 200; /nope and /deep/nested/nope return 404 with the site's own page, its assets loading and the CSP applied; both pages and the 404 render with no console errors, no failed requests, and zero horizontal overflow at 320, 380, 480 and 768px. npm run validate passes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MtJ3nDatGiv7dHjvZL1uZ4 --- CHANGELOG.md | 5 ++++ README.md | 4 +-- docs/releasing.md | 23 +++++++-------- docs/website.md | 14 ++++++++- package.json | 2 +- scripts/build-site.mjs | 37 +++++++++++++++++++---- site/404.html | 66 ++++++++++++++++++++++++++++++++++++++++++ wrangler.jsonc | 4 +++ 8 files changed, 134 insertions(+), 21 deletions(-) create mode 100644 site/404.html 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/README.md b/README.md index 09c079c..aebb226 100644 --- a/README.md +++ b/README.md @@ -273,8 +273,8 @@ tooling and SSR imports. `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 Pages -settings are covered in [docs/website.md](docs/website.md). +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 diff --git a/docs/releasing.md b/docs/releasing.md index a60310c..a610752 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -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 @@ -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 index 2789a99..d4de9c7 100644 --- a/docs/website.md +++ b/docs/website.md @@ -22,6 +22,7 @@ CDN, so what you see is always the build you have. | ----------------- | --------------------------------------------------------------------- | | `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. | @@ -81,7 +82,10 @@ else** — the sitemap, `robots.txt`, and `llms.txt` follow automatically. Generated on each build, and git-ignored: -- **`sitemap.xml`** — both pages with a `lastmod` date. +- **`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 @@ -143,6 +147,14 @@ every page carries an absolute canonical pointing at the custom domain, so searc 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 diff --git a/package.json b/package.json index e6340a9..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" diff --git a/scripts/build-site.mjs b/scripts/build-site.mjs index bc70894..1682e11 100644 --- a/scripts/build-site.mjs +++ b/scripts/build-site.mjs @@ -1,4 +1,6 @@ +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 @@ -32,29 +34,54 @@ 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.', }, ]; -const lastmod = new Date().toISOString().slice(0, 10); + +/** + * `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) => - ` \n ${origin}${page.path}\n ${lastmod}\n ${page.priority}\n `, - ) + .map((page) => { + const lastmod = lastModified(page.file); + return ` \n ${origin}${page.path}\n${ + lastmod ? ` ${lastmod}\n` : '' + } ${page.priority}\n `; + }) .join('\n')} `, 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/wrangler.jsonc b/wrangler.jsonc index c404f77..a0c2397 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -9,6 +9,10 @@ // library's .mjs/.d.ts files with no index.html, which serves nothing. "assets": { "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 From 1db1b0a9853fa8c5399eca092f5321e196adbaf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 06:03:31 +0000 Subject: [PATCH 5/7] Add a live demo page and show the mark in the page header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The site advertised the library but barely demonstrated it. The landing page had four hero buttons and the appearance playground; the "examples" section was static code with a Copy button and nothing that ran. The full demo — the one covering drawers, sheets, toasts, the palette, forms and wizards — was demo/index.html at the repository root, which loads ../dist/ directly and is never deployed. site/demo.html runs all of it at /demo, against the same vendored build the rest of the site uses: alert, confirm, prompt, validated prompt, open(), stacking, animation options, drawers on both sides, bottom sheets, three toast tones, the lightbox, loading states, the command palette, the notification centre, form(), wizard(), and formWizard(). Each card pairs a control that really runs with the snippet of the call being made, so the page cannot drift from what it documents — a wrong snippet would show up as a misbehaving button beside it. A plugin registered on the page logs afterOpen and afterClose, and every demo prints its return value, so the result log is also evidence the hooks fire. demo.js uses a single delegated listener keyed by data-demo. The site's CSP is script-src 'self', so inline handlers would be blocked; a demo that throws prints the failure into the log rather than only the console. The mark was only ever a favicon and an apple-touch icon: no page displayed it. It now sits in the header of every page beside the wordmark, with alt="" because the adjacent text already names the product and a screen reader should not announce it twice. The API calls were taken from the reviewed report in etc/ and the manual demo page rather than written from memory, and the global surface was read out of a real browser first, so nothing here calls an export that does not exist. Verified in Chromium against wrangler dev with the production _headers CSP: all 22 demo controls surface what they claim with no console errors and no failed requests, the header mark renders on all four pages, the version badge reads from the library's own export, and every page fits at 320, 380, 480, 768 and 1280px with zero horizontal overflow. npm run validate passes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MtJ3nDatGiv7dHjvZL1uZ4 --- README.md | 9 +- docs/website.md | 32 ++- scripts/build-site.mjs | 8 + site/404.html | 2 + site/demo.html | 551 +++++++++++++++++++++++++++++++++++++++++ site/demo.js | 307 +++++++++++++++++++++++ site/docs.html | 2 + site/index.html | 2 + site/styles.css | 71 +++++- 9 files changed, 975 insertions(+), 9 deletions(-) create mode 100644 site/demo.html create mode 100644 site/demo.js diff --git a/README.md b/README.md index aebb226..2d9a421 100644 --- a/README.md +++ b/README.md @@ -271,10 +271,11 @@ 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). +`site/` holds a dependency-free landing page with an interactive appearance playground, a live +demo page that runs every surface the library ships, and a full API reference. Build and serve all +three 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 diff --git a/docs/website.md b/docs/website.md index d4de9c7..e4ebb73 100644 --- a/docs/website.md +++ b/docs/website.md @@ -21,6 +21,7 @@ CDN, so what you see is always the build you have. | File | Role | | ----------------- | --------------------------------------------------------------------- | | `site/index.html` | The landing page: demos, appearance playground, facts table, and FAQ. | +| `site/demo.html` | Live demos: every surface running, each beside its code. | | `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 | @@ -28,13 +29,15 @@ CDN, so what you see is always the build you have. | `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. | +| `site/demo.js` | Runs the demos. One delegated listener, no inline handlers. | +| `site/icon.svg` | The mark: the favicon, and the logo in every page header. | | `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 +The demo and docs pages are served at **`/demo`** and **`/docs`**, not `/demo.html` and +`/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 @@ -53,10 +56,31 @@ refers to it. The npm package (`forgedialog`), the browser global (`ForgeDialog` element (`forge-dialog`), and the repository name stay as they are: they are identifiers, not prose. +## The demo page + +`site/demo.html` runs every surface the library ships — the four dialog entry points, `open()`, +stacking, animations, drawers, bottom sheets, toasts, the lightbox, loading states, the command +palette, the notification centre, `form()`, `wizard()`, and `formWizard()` — against the same +vendored build the rest of the site uses. + +Each card pairs a control that really runs with the snippet of the call being made, so the page +cannot drift from what it documents: if a snippet were wrong, the button beside it would misbehave. +A plugin registered on the page logs `afterOpen` and `afterClose`, and every demo prints its return +value, so the result log doubles as evidence that the hooks fire. + +`demo.js` uses one delegated listener on `document` keyed by `data-demo`, because the site's CSP is +`script-src 'self'` and inline `onclick` attributes would be blocked. A demo that throws prints the +failure into the log rather than only the console. + +This is separate from `demo/index.html` at the repository root, which is the manual verification +page the visual regression test drives. That one loads `../dist/` directly and is not deployed. + ## 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: +`site/icon.svg` is the source of truth. It is what browsers load as the favicon and what every page +header shows beside the wordmark — with `alt=""`, since the adjacent text already names the product +and a screen reader should not announce it twice. Two bitmaps are committed beside it because SVG +is not enough everywhere: | File | Size | Used by | | --------------------------- | -------- | ------------------------------ | diff --git a/scripts/build-site.mjs b/scripts/build-site.mjs index 1682e11..194b096 100644 --- a/scripts/build-site.mjs +++ b/scripts/build-site.mjs @@ -39,6 +39,14 @@ const pages = [ title: 'Forge Dialog', summary: 'What it is, live demos, and an interactive appearance playground.', }, + { + path: '/demo', + file: 'site/demo.html', + priority: '0.9', + title: 'Live demos', + summary: + 'Every surface running in the browser — dialogs, drawers, sheets, lightboxes, loading states, toasts, the command palette, forms, and wizards — each beside the code that produced it.', + }, { path: '/docs', file: 'site/docs.html', diff --git a/site/404.html b/site/404.html index c477886..38700d3 100644 --- a/site/404.html +++ b/site/404.html @@ -20,11 +20,13 @@