diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..529ad46 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,72 @@ +name: Bug report +description: Something in ForgeDialog behaves differently than documented +labels: ['bug'] +body: + - type: markdown + attributes: + value: | + Thanks for the report. Please do not use this form for security + vulnerabilities — report those privately through a + [draft security advisory](https://github.com/cmm-cmm/ForgeDialog/security/advisories/new). + + - type: input + id: version + attributes: + label: ForgeDialog version + placeholder: 0.7.0 + validations: + required: true + + - type: dropdown + id: entry + attributes: + label: Entry point + description: | + Which import did you use? This matters: the single-purpose entries ship a + lightweight appearance applier and ignore the richer options unless + `forgedialog/appearance` is imported alongside them. + options: + - forgedialog + - forgedialog/core + - forgedialog/alert, /confirm or /prompt + - forgedialog/presentation + - forgedialog/workflows + - A framework adapter (react, vue, svelte, web-component) + - Script tag / IIFE global + validations: + required: true + + - type: input + id: environment + attributes: + label: Browser and bundler + description: Include versions where you can. + placeholder: Chrome 141, Vite 7 + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Reproduction + description: | + The smallest code that shows the problem, or a link to a running example. + `demo/index.html` in this repository is a good starting point. + render: ts + validations: + required: true + + - type: textarea + id: expected + attributes: + label: What you expected, and what happened instead + validations: + required: true + + - type: textarea + id: extra + attributes: + label: Anything else + description: Console errors, screenshots, or a recording for visual issues. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..5738507 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/cmm-cmm/ForgeDialog/security/advisories/new + about: Report privately through a draft security advisory, not a public issue. + - name: Contributing guide + url: https://github.com/cmm-cmm/ForgeDialog/blob/main/CONTRIBUTING.md + about: Setup, the checks CI runs, and the gotchas worth knowing before a pull request. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..7857d7e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,42 @@ +name: Feature request +description: Suggest a capability ForgeDialog does not have yet +labels: ['enhancement'] +body: + - type: textarea + id: problem + attributes: + label: What are you trying to do? + description: Describe the situation rather than the API you have in mind, so alternatives stay open. + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: What would the API look like? + render: ts + validations: + required: false + + - type: textarea + id: alternatives + attributes: + label: What have you tried? + description: | + Existing options that came close, or workarounds you are using today — + `appearance`, a plugin hook, `content`, or your own CSS on the `fd-` classes. + validations: + required: false + + - type: checkboxes + id: constraints + attributes: + label: Does this fit the project's constraints? + description: | + ForgeDialog 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: + - label: This can work without adding a runtime dependency + - label: This does not require a specific framework + - label: I am aware this may need to ship as an opt-in entry point to stay within bundle budgets diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..64251cf --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,23 @@ +## Summary + + + +## Verification + + + +- [ ] `npm run validate` +- [ ] `npm run test:e2e` (needs `npx playwright install`), or a note below on why it was skipped +- [ ] Added or updated tests covering the change + +## Checklist + +- [ ] Added a changeset (`npm run changeset`) for user-facing changes +- [ ] Refreshed API reports (`npm run api:update`, `npm run api:subpaths:update`) if the public + surface changed +- [ ] Raised a gzip budget in this PR, with a reason, if the change needed more room +- [ ] Refreshed the visual baseline, and said so, if a demo change shifted the screenshot + +## Screenshots + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3000ef..976896c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,7 @@ jobs: - run: npm run api:check - run: npm run api:subpaths - run: npm run test:consumers + - run: npm run test:capabilities - run: npm run test:csp - run: npm run package:size - run: npm pack --dry-run diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5f28cc..5a57f24 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,8 @@ on: permissions: contents: read + # Required for npm trusted publishing: npm exchanges this token for a + # short-lived credential, so no NPM_TOKEN secret is needed. id-token: write jobs: @@ -24,7 +26,17 @@ jobs: node-version: 24 registry-url: https://registry.npmjs.org package-manager-cache: false + + # Trusted publishing needs a recent npm; the version bundled with Node + # varies by patch release, so pin it here rather than inherit it. + - name: Use an npm that supports trusted publishing + run: | + npm install -g npm@latest + npm --version + - run: npm ci + - name: Verify the release is publishable + run: npm run check:release - run: npx playwright install --with-deps - run: npm run validate:all - run: npm sbom --sbom-format cyclonedx > forgedialog-sbom.json @@ -32,9 +44,12 @@ jobs: with: name: forgedialog-sbom path: forgedialog-sbom.json + - name: Publish to npm with OIDC if: vars.NPM_PUBLISH_ENABLED == 'true' - run: npm publish --access public + run: npm publish - name: Skip npm publishing until trusted publishing is enabled if: vars.NPM_PUBLISH_ENABLED != 'true' - run: echo "Set the NPM_PUBLISH_ENABLED repository variable after configuring npm trusted publishing." + run: | + echo "NPM_PUBLISH_ENABLED is not 'true', so nothing was published." + echo "See docs/releasing.md for the one-time npm setup." diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fecef3..dea9d29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## 0.7.0 + +- Added per-component appearance controls: `surfaceColor`, `titleColor`, `titleBackground`, + `titleOpacity`, `contentColor`, `contentOpacity`, and `borderOpacity`, so color and transparency + can be set on the title, body, and border instead of only on the dialog surface and backdrop. +- Added `appearance.radius` for corner rounding, which was previously fixed to the global + `--fd-radius` token. It takes one value for every corner, any CSS `border-radius` value, or a + per-corner object whose omitted corners keep the theme radius. Drawers and bottom sheets keep + their shapes unless a radius is given explicitly. +- Added composable shadows: `appearance.shadow` now also accepts `angle`, which chooses the + direction the shadow falls, alongside `distance`, `blur`, `spread`, `color`, `opacity`, and + `inset`. Presets and raw CSS `box-shadow` values keep working unchanged. +- Added `appearance.hover`, applied only while the pointer is over the dialog and only for dialogs + that opt in, accepting the same color and shadow options plus `lift`, `scale`, and `duration`. +- Hover transforms honour `prefers-reduced-motion` and are suspended while a dialog is being + dragged, so a hover lift or scale can no longer push a dragged dialog outside its bounds. +- Added a `forgedialog/appearance` entry point. Both `forgedialog` and `forgedialog/core` ship the + full appearance applier, so the richer options need no extra import; the single-purpose entries + (`forgedialog/alert`, `/confirm`, `/prompt`) keep a lightweight applier covering surface opacity, + backdrop, border, and shadow presets, and can be upgraded by importing `forgedialog/appearance`. +- Fixed capability imports being dropped by bundlers. Entries are published unsplit, because ESM + code splitting moved the `registerAppearanceApplier` call into a shared chunk that `sideEffects` + cannot name, so a bundler discarded the bare import and `import 'forgedialog/appearance'` was + silently a no-op. `forgedialog/interactions` and `forgedialog/animations` happened to escape this + only through how their code was chunked. +- Added `npm run test:capabilities`, which bundles the published entries and asserts each capability + actually registers while `forgedialog/alert` stays on the lightweight applier. +- Widened the gzip and tarball budgets. Several artifacts sat within 0.1 KiB of their limits, so + ordinary changes tripped them; the budgets now carry real headroom and cover both the full + appearance applier in the core entry and the unsplit output. +- Expanded the demo's appearance builder with per-component color, shadow direction, and hover + controls, and refreshed the visual regression baseline for the taller page. + ## 0.6.0 - Fixed focus trapping skipping over targets that cannot receive focus: hidden inputs and anything diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..3aa0e06 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,114 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a +harassment-free experience for everyone, regardless of age, body size, visible or invisible +disability, ethnicity, sex characteristics, gender identity and expression, level of experience, +education, socio-economic status, nationality, personal appearance, race, caste, color, religion, +or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, +and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the + experience +- Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their + explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior +and will take appropriate and fair corrective action in response to any behavior that they deem +inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, +code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and +will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is +officially representing the community in public spaces. Examples of representing our community +include using an official email address, posting via an official social media account, or acting as +an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the +maintainers privately through the repository's **Security** tab, using **Report a vulnerability**, +which opens a private thread visible only to the maintainers. + +All complaints will be reviewed and investigated promptly and fairly. All community leaders are +obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for +any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or +unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the +nature of the violation and an explanation of why the behavior was inappropriate. A public apology +may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people +involved, including unsolicited interaction with those enforcing the Code of Conduct, for a +specified period of time. This includes avoiding interactions in community spaces as well as +external channels like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate +behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the +community for a specified period of time. No public or private interaction with the people involved, +including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this +period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including +sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement +of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e5f2003 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,103 @@ +# Contributing to ForgeDialog + +Thanks for helping out. ForgeDialog 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 + +Node 20 or newer is required (see `engines` in `package.json`). + +```sh +npm install +npm run dev # rebuild with tsup while editing +npm test # vitest in jsdom +npm run demo # build and serve demo/index.html for manual checks +``` + +## Where things live + +| Path | What it holds | +| ---------------- | ------------------------------------------------------------------ | +| `src/api/` | High-level helpers: `alert`, `confirm`, `prompt`, `form`, and more | +| `src/core/` | Dialog lifecycle, focus trap, appearance, dragging, stacking | +| `src/theme/` | CSS, split into the sheets exposed under `forgedialog/style/*` | +| `src/*-entry.ts` | Published entry points, including opt-in capabilities | +| `tests/` | Vitest suites, mirroring the `src/` layout | +| `e2e/` | Playwright suites that need a real browser | +| `scripts/` | The packaging and budget checks that run in `validate` | + +## Before you open a pull request + +```sh +npm run validate +``` + +That is the same gate CI runs: typecheck, lint, coverage, gzip budgets, tree-shaking, `publint` and +`attw`, API reports, consumer builds, capability registration, a CSP scan, and the tarball size. +Run `npm run validate:all` to add the browser suite, which needs `npx playwright install` first. + +Individual pieces are also available on their own — `npm run typecheck`, `npm run lint`, +`npm run format`, `npm run test:coverage`. + +## Things that trip people up + +**Public API changes need refreshed reports.** `npm run api:check` compares `dist/` against the +committed snapshots in `etc/`. When you intentionally change the public surface, regenerate them: + +```sh +npm run api:update +npm run api:subpaths:update +``` + +**User-facing changes need a changeset.** Run `npm run changeset` and describe the change the way a +consumer would read it. Version bumps come from `npm run version-packages`, not by hand. + +**Capabilities register through side-effectful imports.** `forgedialog/appearance`, +`/interactions`, and `/animations` work by running a `register*` call on import. A bundler only +keeps such an import when the file it resolves to is listed in `sideEffects` in `package.json`, and +a shared chunk emitted by code splitting cannot be named there. That is why entries are published +unsplit. `npm run test:capabilities` bundles the published entries and fails if a registration gets +dropped — if you touch `tsup.config.ts`, `sideEffects`, or an entry file, expect it to have an +opinion. + +**Bundle budgets are enforced.** `scripts/check-size.mjs` and `scripts/check-treeshaking.mjs` cap +the gzipped output, including an alert-only fixture that must stay small. If a change genuinely +needs more room, raise the budget in the same pull request and say why. + +**The visual baseline lives in the repo.** `e2e/quality.spec.ts` compares against +`e2e/quality.spec.ts-snapshots/`. When a demo-page change legitimately shifts the screenshot, +refresh it with `npx playwright test --update-snapshots` and mention it in the pull request, so a +reviewer can tell a fixture change from a rendering regression. + +## Style + +Prettier and ESLint own formatting and lint rules; run `npm run format` rather than hand-aligning. +Two-space indentation, single quotes, semicolons, trailing commas, 100-character lines. `PascalCase` +for classes and exported types, `camelCase` for functions and variables, `fd-`-prefixed CSS class +names. Prefix intentionally unused parameters with `_`. + +Keep comments for things the code cannot say itself — a constraint, a workaround, the reason behind +a non-obvious choice. + +## Tests + +Vitest runs in jsdom and discovers `tests/**/*.test.ts`. Cover observable behavior: keyboard and +focus handling, ARIA state, cleanup, and promise results. Every behavior change should come with a +regression test. + +jsdom has no layout or cascade, so anything that depends on computed styles — radii, shadows, +hover, drag bounds — belongs in `e2e/` instead, where a real browser can measure it. + +## Commits and pull requests + +Commit summaries are short and imperative, describing the user-visible outcome +(`Fix focus trapping, className updates, and toast lifetimes`). Explain the why in the body when it +is not obvious from the diff. + +Pull requests should say what changed, how you verified it, and call out API or accessibility +impact explicitly. Screenshots or a short recording help for visual, animation, or demo changes. + +## Reporting problems + +Bugs and feature ideas go to [issues](https://github.com/cmm-cmm/ForgeDialog/issues). Security +vulnerabilities do not — see [SECURITY.md](SECURITY.md). diff --git a/README.md b/README.md index a199edd..d74c8b5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ForgeDialog 0.6 +# ForgeDialog 0.7 _A modern, lightweight, and highly customizable JavaScript dialog library for alerts, confirmations, modals, drawers, and interactive workflows._ @@ -111,9 +111,26 @@ const dialog = open({ opacity: 0.9, overlayOpacity: 0.55, backdropBlur: 12, + surfaceColor: '#12141a', + radius: { topLeft: 24, topRight: 24, bottomRight: 4, bottomLeft: 4 }, + titleColor: '#ffd166', + titleBackground: '#1b1740', + titleOpacity: 0.95, + contentColor: '#c8ccd4', + contentOpacity: 0.8, borderColor: '#7c5cff', + borderOpacity: 0.6, borderWidth: 2, - shadow: 'xl', // none | sm | md | lg | xl | any CSS box-shadow + // A preset, any CSS box-shadow, or a shadow composed from its parts. + shadow: { angle: 135, distance: 24, blur: 60, color: '#000', opacity: 0.45 }, + hover: { + borderColor: '#9f7bff', + titleColor: '#ffffff', + shadow: 'xl', + lift: 6, + scale: 1.02, + duration: 150, + }, }, draggable: { axis: 'both', @@ -132,6 +149,31 @@ dialog.update({ appearance: { opacity: 1, shadow: 'md' } }); be constrained to `x`, `y`, the viewport, an element, or a `DOMRect`. Bottom sheets keep their dedicated swipe-to-dismiss gesture and ignore general dragging. +Colors and opacity are scoped per component, so the surface, title, body, and border can each be +tuned on their own. `titleBackground` paints the header, which is transparent by default and is +clipped by the dialog's corners. `radius` takes one value for every corner, any CSS `border-radius` +value, or a per-corner object; corners left out of that object keep the theme radius. Drawers stay +square and bottom sheets keep rounding only their top corners unless `radius` says otherwise. + +`shadow` accepts a preset name, a raw CSS `box-shadow`, or the parts of one: +`angle` picks the direction the shadow falls (`0` up, `90` right, `180` down, `270` left), while +`distance`, `blur`, `spread`, `color`, `opacity`, and `inset` control its shape and strength. + +`hover` restyles the dialog only while the pointer is over it, and only for dialogs that ask for it, +so every other dialog stays completely static. It reuses the same color and shadow options and adds +`lift`, `scale`, and `duration`. Hover transforms honour `prefers-reduced-motion` and are suspended +while a dialog is being dragged so they cannot push it outside its bounds. + +Both `forgedialog` and `forgedialog/core` ship the full appearance applier, so none of this needs an +extra import. The single-purpose entries (`forgedialog/alert`, `/confirm`, `/prompt`) stay on a +lightweight applier that covers surface opacity, backdrop, border, and shadow presets, and ignore +the richer options; import `forgedialog/appearance` alongside one of them to upgrade it: + +```ts +import 'forgedialog/appearance'; +import { alert } from 'forgedialog/alert'; +``` + ## Advanced UI and workflows ForgeDialog uses the native `` top layer and adds typed outcomes, close reasons, @@ -159,7 +201,7 @@ const data = await flow.result; ``` Tree-shakable entry points are available at `forgedialog/core`, `forgedialog/interactions`, -`forgedialog/animations`, +`forgedialog/animations`, `forgedialog/appearance`, `forgedialog/presentation`, and `forgedialog/workflows`. `forgedialog/interactions` exposes the standalone draggable controller without pulling in dialog APIs. Framework integrations are exported from `forgedialog/react`, `forgedialog/vue`, `forgedialog/svelte`, and `forgedialog/web-component`. @@ -219,11 +261,20 @@ npm run demo # build and serve the demo/ page Releases use Changesets (`npm run changeset`) and the protected GitHub release workflow. The workflow generates a CycloneDX SBOM and publishes with npm provenance through trusted publishing; -local development and pull requests never publish packages. +local development and pull requests never publish packages. The process, and the one-time npm setup +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. +## Contributing + +Setup, the checks CI runs, and the gotchas worth knowing are in +[CONTRIBUTING.md](CONTRIBUTING.md). Participation is governed by our +[Code of Conduct](CODE_OF_CONDUCT.md). + +Found a security issue? Please report it privately — see [SECURITY.md](SECURITY.md). + ## License MIT diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c9b67f8 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,53 @@ +# Security Policy + +## Supported versions + +ForgeDialog is pre-1.0. Fixes land on the most recent minor line; older `0.x` lines are not +patched. + +| Version | Supported | +| ------- | --------- | +| 0.7.x | Yes | +| < 0.7 | No | + +## Reporting a vulnerability + +Please report privately rather than opening a public issue. + +Use GitHub's private vulnerability reporting: go to the repository's **Security** tab and choose +**Report a vulnerability**. That opens a draft advisory visible only to you and the maintainers. + +Helpful things to include: the affected version and entry point, a minimal reproduction, the impact +you believe it has, and any suggested fix. + +Expect an acknowledgement within a few days. You will be kept updated while a fix is prepared, and +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 +how untrusted content reaches the DOM. + +**In scope** + +- Content passed through documented APIs escaping into the DOM as markup when it should be text. +- Anything letting a dialog break out of its accessibility or focus containment in a way that + enables clickjacking or input capture. +- Supply-chain issues in what the package publishes: unexpected files in the tarball, or a build + artifact that does not match this source. + +**Out of scope** + +- `unsafeHtml`. It is documented as trusted-input-only and performs no sanitization by design; + passing user input to it is a bug in the calling application. Use `html` together with + `sanitizeHtml`, which refuses to render until a sanitizer is supplied. +- Vulnerabilities in an application's own sanitizer implementation. +- Findings that require an attacker to already execute script on the page. + +## What the project does on its own behalf + +- `content` and `message` strings are rendered as text, never parsed as markup. +- `npm run test:csp` fails the build if `eval` or `new Function` appears in `dist/`, so the package + stays usable under a strict Content Security Policy. +- Releases publish with npm provenance and attach a CycloneDX SBOM. +- Development dependencies are kept free of known advisories; `npm audit` is expected to be clean. diff --git a/demo/index.html b/demo/index.html index 66a36fa..6b7f2c5 100644 --- a/demo/index.html +++ b/demo/index.html @@ -130,15 +130,70 @@

ForgeDialog Demo

>Border color + >Border opacity + + + + + + + + + + + + + + + + + + + +
@@ -207,13 +262,44 @@

ForgeDialog Demo

}); const value = (id) => document.getElementById(id).value; + const checked = (id) => document.getElementById(id).checked; const currentAppearance = () => ({ opacity: Number(value('appearance-opacity')), overlayOpacity: Number(value('appearance-overlay')), backdropBlur: Number(value('appearance-blur')), + surfaceColor: value('appearance-surface-color'), + radius: { + topLeft: Number(value('appearance-radius-tl')), + topRight: Number(value('appearance-radius')), + bottomRight: Number(value('appearance-radius')), + bottomLeft: Number(value('appearance-radius')), + }, + titleBackground: checked('appearance-title-bg-on') + ? value('appearance-title-bg') + : undefined, + titleColor: value('appearance-title-color'), + titleOpacity: Number(value('appearance-title-opacity')), + contentColor: value('appearance-content-color'), + contentOpacity: Number(value('appearance-content-opacity')), borderWidth: Number(value('appearance-border-width')), borderColor: value('appearance-border-color'), - shadow: value('appearance-shadow'), + borderOpacity: Number(value('appearance-border-opacity')), + shadow: { + angle: Number(value('appearance-shadow-angle')), + distance: Number(value('appearance-shadow-distance')), + blur: Number(value('appearance-shadow-blur')), + color: value('appearance-shadow-color'), + opacity: Number(value('appearance-shadow-opacity')), + }, + hover: checked('appearance-hover') + ? { + borderColor: value('appearance-hover-border'), + titleColor: value('appearance-hover-title'), + lift: Number(value('appearance-hover-lift')), + scale: Number(value('appearance-hover-scale')), + shadow: 'xl', + } + : undefined, }); let appearancePreview; document diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..ca5e495 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,114 @@ +# Releasing + +ForgeDialog 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 + +These steps need access this repository's automation does not have. Until they are done, the release +workflow runs every check and then deliberately skips publishing. + +### 1. Create and configure the `npm` environment + +In **Settings → Environments**, create an environment named `npm`. The release job declares +`environment: npm`, so this is the gate that decides which workflow runs may obtain the OIDC +credential. The name must match the Environment field of the trusted publisher in step 2 exactly. + +Suggested configuration: + +| Setting | Value | +| ------------------------------ | ---------------------------------------- | +| Required reviewers | Yourself | +| Wait timer | 0 | +| Allow administrators to bypass | Off | +| Deployment branches and tags | Selected: tag `v*` **and** branch `main` | + +A published version cannot be replaced, so a required reviewer is the one control worth having: it +turns every publish into a deliberate click. Leaving administrator bypass on would make that gate +decorative for the person most likely to trigger a release. + +Both deployment rules are needed. Releases run from a `v*` tag, but the workflow also offers +`workflow_dispatch` for a dry run from `main`; restricting the environment to tags alone would stop +that run at the gate and fail the job rather than letting it validate and skip publishing. + +### 2. Configure the trusted publisher on npmjs.com + +On the package's **Settings → Trusted publishers** page on npmjs.com, add a GitHub Actions +publisher with: + +| Field | Value | +| ----------------- | ------------- | +| Organization/user | `cmm-cmm` | +| Repository | `ForgeDialog` | +| 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. + +### 3. Enable publishing in the workflow + +Add a repository **variable** (not a secret) in **Settings → Secrets and variables → Actions → +Variables**: + +``` +NPM_PUBLISH_ENABLED = true +``` + +The workflow checks this so that setting up the pipeline, or re-running it, cannot publish by +accident before you are ready. + +## Cutting a release + +1. Land the changes, each with a changeset (`npm run changeset`). +2. Apply the version bump and changelog: `npm run version-packages`, then commit the result. +3. Merge to `main`. +4. Tag the merge commit and push the tag: + + ```sh + git tag -a v0.7.0 -m "ForgeDialog v0.7.0" + git push origin v0.7.0 + ``` + +The tag push triggers the release workflow. `workflow_dispatch` runs the same job by hand, which is +useful for a dry run: with `NPM_PUBLISH_ENABLED` unset it validates everything and publishes +nothing. + +## What the workflow checks before publishing + +- `npm run check:release` — the tag matches `package.json` version, the package is not private, the + required metadata is present, and that exact version is not already on the registry. Publishing a + duplicate version fails at npm; this fails first, with a clearer message. +- `npm run validate:all` — the full gate, including the browser suite. +- A CycloneDX SBOM is generated and uploaded as a build artifact. + +Provenance is attested by npm as part of trusted publishing; nothing in `package.json` needs to +request it. + +## First publish without trusted publishing + +Only if step 2 above turns out to require an existing package: + +```sh +npm run validate:all +npm run check:release +npm publish # prompts for auth; use a granular access token scoped to this package +``` + +`publishConfig.access` is already `public`, so no `--access` flag is needed. Afterwards, add the +trusted publisher and set `NPM_PUBLISH_ENABLED=true` so later releases go through Actions, and +delete the token you used. + +## Troubleshooting + +**The publish step was skipped.** `NPM_PUBLISH_ENABLED` is not `true`. That is the default. + +**`ENEEDAUTH` or an OIDC error.** The trusted publisher entry on npmjs.com must match the +repository, workflow filename, and environment name exactly. The job also needs +`permissions: id-token: write`, which is set at the top of the workflow. + +**npm rejects the version.** It is already published. Versions on npm are immutable — bump and tag +again rather than trying to overwrite. diff --git a/e2e/dialog.spec.ts b/e2e/dialog.spec.ts index c22c856..7d18134 100644 --- a/e2e/dialog.spec.ts +++ b/e2e/dialog.spec.ts @@ -72,6 +72,123 @@ test('appearance builder applies scoped styling and keeps dragging inside the vi await expect(dialog).toHaveCSS('--fd-drag-y', '0px'); }); +test('hover overrides restyle the dialog and revert when the pointer leaves', async ({ page }) => { + await page.goto('/demo/'); + await page.locator('#appearance-hover-title').fill('#ff9f1c'); + await page.locator('#appearance-hover-border').fill('#ff9f1c'); + await page.getByRole('button', { name: 'Open interactive preview' }).click(); + const dialog = page.getByRole('dialog', { name: 'Interactive appearance preview' }); + await expect(dialog).toBeVisible(); + + const title = dialog.locator('.fd-dialog__title'); + // Park the pointer away from the dialog so the idle state is measured first. + await page.mouse.move(2, 2); + await expect(title).not.toHaveCSS('color', 'rgb(255, 159, 28)'); + await expect(dialog).toHaveCSS('transform', 'none'); + + await dialog.hover(); + await expect(title).toHaveCSS('color', 'rgb(255, 159, 28)'); + // The border is blended through color-mix, which computes to color(srgb ...). + await expect + .poll(() => dialog.evaluate((element) => getComputedStyle(element).borderTopColor)) + .toMatch(/^color\(srgb 1 0\.62/); + // Lift and scale are applied together as a single transform matrix. + await expect(dialog).not.toHaveCSS('transform', 'none'); + + await page.mouse.move(2, 2); + await expect(title).not.toHaveCSS('color', 'rgb(255, 159, 28)'); + await expect(dialog).toHaveCSS('transform', 'none'); +}); + +test('corner radius applies per corner and survives sheet and drawer shapes', async ({ page }) => { + await page.goto('/demo/'); + await page.waitForFunction(() => 'ForgeDialog' in window); + + const radii = await page.evaluate(async () => { + const { bottomSheet, drawer, open } = ( + window as unknown as { ForgeDialog: typeof import('../src/index') } + ).ForgeDialog; + const read = (element: Element) => { + const style = getComputedStyle(element); + return [ + style.borderTopLeftRadius, + style.borderTopRightRadius, + style.borderBottomRightRadius, + style.borderBottomLeftRadius, + ].join(' '); + }; + const surfaceOf = (instance: { element: HTMLElement }) => + instance.element.querySelector('.fd-dialog')!; + + const modal = open({ appearance: { radius: { topLeft: 32, bottomRight: 0 } } }); + // Regression: substituting the radius variable twice voided the whole + // declaration, leaving a per-corner sheet with square corners. + const sheet = bottomSheet({ appearance: { radius: { topLeft: 24, topRight: 24 } } }); + const plainSheet = bottomSheet({}); + const side = drawer({}); + await new Promise((resolve) => setTimeout(resolve, 250)); + + const result = { + modal: read(surfaceOf(modal)), + sheet: read(surfaceOf(sheet)), + plainSheet: read(surfaceOf(plainSheet)), + drawer: read(surfaceOf(side)), + }; + await Promise.all([modal.close(), sheet.close(), plainSheet.close(), side.close()]); + return result; + }); + + // Omitted corners fall back to the theme radius rather than collapsing. + expect(radii.modal).toBe('32px 10px 0px 10px'); + expect(radii.sheet).toBe('24px 24px 0px 0px'); + expect(radii.plainSheet).toBe('10px 10px 0px 0px'); + expect(radii.drawer).toBe('0px 0px 0px 0px'); +}); + +test('a header background is painted and reserves its own padding', async ({ page }) => { + await page.goto('/demo/'); + await page.locator('#appearance-title-bg').fill('#2f2a5a'); + await page.locator('#appearance-title-bg-on').check(); + await page.getByRole('button', { name: 'Open interactive preview' }).click(); + const dialog = page.getByRole('dialog', { name: 'Interactive appearance preview' }); + const header = dialog.locator('.fd-dialog__header'); + await expect(header).toHaveCSS('background-color', 'rgb(47, 42, 90)'); + await expect(header).toHaveCSS('padding-bottom', '16px'); + + // The open modal covers the builder, so drive the control directly rather + // than clicking through the overlay. + await page.locator('#appearance-title-bg-on').evaluate((element) => { + const checkbox = element as HTMLInputElement; + checkbox.checked = false; + checkbox.dispatchEvent(new Event('input', { bubbles: true })); + }); + await expect(header).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); + await expect(header).toHaveCSS('padding-bottom', '0px'); +}); + +test('shadow direction follows the configured angle', async ({ page }) => { + await page.goto('/demo/'); + await page.getByRole('button', { name: 'Open interactive preview' }).click(); + const dialog = page.getByRole('dialog', { name: 'Interactive appearance preview' }); + await expect(dialog).toBeVisible(); + await page.mouse.move(2, 2); + + // box-shadow is transitioned, so the settled value has to be polled for + // rather than read straight after the slider changes. + const expectShadowAt = async (angle: string, offsets: string) => { + await page.locator('#appearance-shadow-angle').fill(angle); + await expect + .poll(() => dialog.evaluate((element) => getComputedStyle(element).boxShadow)) + .toContain(offsets); + }; + + await page.locator('#appearance-shadow-distance').fill('40'); + await expectShadowAt('90', '40px 0px'); + await expectShadowAt('270', '-40px 0px'); + await expectShadowAt('0', '0px -40px'); + await expectShadowAt('180', '0px 40px'); +}); + test('touch pointer dragging stays constrained after a mobile viewport resize', async ({ page, }, testInfo) => { diff --git a/e2e/quality.spec.ts-snapshots/alert-dialog.png b/e2e/quality.spec.ts-snapshots/alert-dialog.png index 4546e80..5d34c1c 100644 Binary files a/e2e/quality.spec.ts-snapshots/alert-dialog.png and b/e2e/quality.spec.ts-snapshots/alert-dialog.png differ diff --git a/etc/alert.api.md b/etc/alert.api.md index d0d30c4..b8d48fb 100644 --- a/etc/alert.api.md +++ b/etc/alert.api.md @@ -13,13 +13,22 @@ export interface DialogAppearance { backdropBlur?: number | string; // (undocumented) borderColor?: string; + borderOpacity?: number; // (undocumented) borderStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double'; // (undocumented) borderWidth?: number | string; + contentColor?: string; + contentOpacity?: number; + hover?: DialogHoverAppearance; opacity?: number; overlayOpacity?: number; - shadow?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | string; + radius?: DialogRadius; + shadow?: DialogShadow; + surfaceColor?: string; + titleBackground?: string; + titleColor?: string; + titleOpacity?: number; } // @public (undocumented) diff --git a/etc/appearance.api.md b/etc/appearance.api.md new file mode 100644 index 0000000..dfbd811 --- /dev/null +++ b/etc/appearance.api.md @@ -0,0 +1,74 @@ +## API Report File for "forgedialog" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +// @public (undocumented) +export const applyAppearance: AppearanceApplier; + +// @public (undocumented) +export interface DialogAppearance { + backdropBlur?: number | string; + // (undocumented) + borderColor?: string; + borderOpacity?: number; + // (undocumented) + borderStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double'; + // (undocumented) + borderWidth?: number | string; + contentColor?: string; + contentOpacity?: number; + hover?: DialogHoverAppearance; + opacity?: number; + overlayOpacity?: number; + radius?: DialogRadius; + shadow?: DialogShadow; + surfaceColor?: string; + titleBackground?: string; + titleColor?: string; + titleOpacity?: number; +} + +// @public +export interface DialogHoverAppearance { + // (undocumented) + borderColor?: string; + // (undocumented) + contentColor?: string; + duration?: number | string; + lift?: number | string; + // (undocumented) + opacity?: number; + radius?: DialogRadius; + scale?: number; + // (undocumented) + shadow?: DialogShadow; + // (undocumented) + surfaceColor?: string; + titleBackground?: string; + // (undocumented) + titleColor?: string; +} + +// @public +export type DialogShadow = ShadowPreset | string | DialogShadowConfig; + +// @public +export interface DialogShadowConfig { + angle?: number; + blur?: number | string; + // (undocumented) + color?: string; + distance?: number | string; + inset?: boolean; + opacity?: number; + spread?: number | string; +} + +// @public (undocumented) +export type ShadowPreset = 'none' | 'sm' | 'md' | 'lg' | 'xl'; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/etc/confirm.api.md b/etc/confirm.api.md index 69335f2..4904eab 100644 --- a/etc/confirm.api.md +++ b/etc/confirm.api.md @@ -13,13 +13,22 @@ export interface DialogAppearance { backdropBlur?: number | string; // (undocumented) borderColor?: string; + borderOpacity?: number; // (undocumented) borderStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double'; // (undocumented) borderWidth?: number | string; + contentColor?: string; + contentOpacity?: number; + hover?: DialogHoverAppearance; opacity?: number; overlayOpacity?: number; - shadow?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | string; + radius?: DialogRadius; + shadow?: DialogShadow; + surfaceColor?: string; + titleBackground?: string; + titleColor?: string; + titleOpacity?: number; } // @public (undocumented) diff --git a/etc/core.api.md b/etc/core.api.md index a718cff..b7dc39a 100644 --- a/etc/core.api.md +++ b/etc/core.api.md @@ -46,13 +46,34 @@ export interface DialogAppearance { backdropBlur?: number | string; // (undocumented) borderColor?: string; + borderOpacity?: number; // (undocumented) borderStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double'; // (undocumented) borderWidth?: number | string; + contentColor?: string; + contentOpacity?: number; + hover?: DialogHoverAppearance; opacity?: number; overlayOpacity?: number; - shadow?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | string; + radius?: DialogRadius; + shadow?: DialogShadow; + surfaceColor?: string; + titleBackground?: string; + titleColor?: string; + titleOpacity?: number; +} + +// @public +export interface DialogCornerRadius { + // (undocumented) + bottomLeft?: number | string; + // (undocumented) + bottomRight?: number | string; + // (undocumented) + topLeft?: number | string; + // (undocumented) + topRight?: number | string; } // @public (undocumented) @@ -63,6 +84,27 @@ export interface DialogDragEvent { position: DialogPosition; } +// @public +export interface DialogHoverAppearance { + // (undocumented) + borderColor?: string; + // (undocumented) + contentColor?: string; + duration?: number | string; + lift?: number | string; + // (undocumented) + opacity?: number; + radius?: DialogRadius; + scale?: number; + // (undocumented) + shadow?: DialogShadow; + // (undocumented) + surfaceColor?: string; + titleBackground?: string; + // (undocumented) + titleColor?: string; +} + // @public (undocumented) export interface DialogInstance { // (undocumented) @@ -189,9 +231,27 @@ export interface DialogPosition { // @public (undocumented) export type DialogPresentation = 'modal' | 'drawer-left' | 'drawer-right' | 'bottom-sheet' | 'lightbox'; +// @public +export type DialogRadius = number | string | DialogCornerRadius; + // @public (undocumented) export type DialogRole = 'dialog' | 'alertdialog'; +// @public +export type DialogShadow = ShadowPreset | string | DialogShadowConfig; + +// @public +export interface DialogShadowConfig { + angle?: number; + blur?: number | string; + // (undocumented) + color?: string; + distance?: number | string; + inset?: boolean; + opacity?: number; + spread?: number | string; +} + // @public (undocumented) export type DialogSize = 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen'; @@ -393,6 +453,9 @@ export interface PromptOptions extends DialogOptions { validate?: (value: string) => boolean | string | Promise; } +// @public (undocumented) +export type ShadowPreset = 'none' | 'sm' | 'md' | 'lg' | 'xl'; + // @public export interface TrustedHtmlLike { // (undocumented) diff --git a/etc/forgedialog.api.md b/etc/forgedialog.api.md index 5d147b9..34f389c 100644 --- a/etc/forgedialog.api.md +++ b/etc/forgedialog.api.md @@ -69,13 +69,23 @@ export interface DialogAppearance { backdropBlur?: number | string; // (undocumented) borderColor?: string; + borderOpacity?: number; // (undocumented) borderStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double'; // (undocumented) borderWidth?: number | string; + contentColor?: string; + contentOpacity?: number; + hover?: DialogHoverAppearance; opacity?: number; overlayOpacity?: number; - shadow?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | string; + // Warning: (ae-forgotten-export) The symbol "DialogRadius" needs to be exported by the entry point index.d.ts + radius?: DialogRadius; + shadow?: DialogShadow; + surfaceColor?: string; + titleBackground?: string; + titleColor?: string; + titleOpacity?: number; } // @public (undocumented) @@ -86,6 +96,27 @@ export interface DialogDragEvent { position: DialogPosition; } +// @public +export interface DialogHoverAppearance { + // (undocumented) + borderColor?: string; + // (undocumented) + contentColor?: string; + duration?: number | string; + lift?: number | string; + // (undocumented) + opacity?: number; + radius?: DialogRadius; + scale?: number; + // (undocumented) + shadow?: DialogShadow; + // (undocumented) + surfaceColor?: string; + titleBackground?: string; + // (undocumented) + titleColor?: string; +} + // @public (undocumented) export interface DialogInstance { // (undocumented) @@ -217,6 +248,23 @@ export type DialogPresentation = 'modal' | 'drawer-left' | 'drawer-right' | 'bot // @public (undocumented) export type DialogRole = 'dialog' | 'alertdialog'; +// Warning: (ae-forgotten-export) The symbol "ShadowPreset" needs to be exported by the entry point index.d.ts +// +// @public +export type DialogShadow = ShadowPreset | string | DialogShadowConfig; + +// @public +export interface DialogShadowConfig { + angle?: number; + blur?: number | string; + // (undocumented) + color?: string; + distance?: number | string; + inset?: boolean; + opacity?: number; + spread?: number | string; +} + // @public (undocumented) export type DialogSize = 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen'; diff --git a/etc/prompt.api.md b/etc/prompt.api.md index 61520e2..3f8f2f8 100644 --- a/etc/prompt.api.md +++ b/etc/prompt.api.md @@ -9,13 +9,22 @@ export interface DialogAppearance { backdropBlur?: number | string; // (undocumented) borderColor?: string; + borderOpacity?: number; // (undocumented) borderStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double'; // (undocumented) borderWidth?: number | string; + contentColor?: string; + contentOpacity?: number; + hover?: DialogHoverAppearance; opacity?: number; overlayOpacity?: number; - shadow?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | string; + radius?: DialogRadius; + shadow?: DialogShadow; + surfaceColor?: string; + titleBackground?: string; + titleColor?: string; + titleOpacity?: number; } // @public (undocumented) diff --git a/package.json b/package.json index d454a74..89a272f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "forgedialog", - "version": "0.6.0", + "version": "0.7.0", "description": "A modern, lightweight, and highly customizable JavaScript dialog library for alerts, confirmations, modals, drawers, and interactive workflows.", "type": "module", "license": "MIT", @@ -26,13 +26,20 @@ "./dist/interactions.cjs", "./src/animations-entry.ts", "./dist/animations.mjs", - "./dist/animations.cjs" + "./dist/animations.cjs", + "./src/appearance-entry.ts", + "./dist/appearance.mjs", + "./dist/appearance.cjs" ], "files": [ "dist", "!dist/**/*.map", - "!dist/style-*.mjs" + "!dist/style-*.mjs", + "CHANGELOG.md" ], + "publishConfig": { + "access": "public" + }, "main": "./dist/index.cjs", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", @@ -118,6 +125,16 @@ "default": "./dist/animations.cjs" } }, + "./appearance": { + "import": { + "types": "./dist/appearance.d.ts", + "default": "./dist/appearance.mjs" + }, + "require": { + "types": "./dist/appearance.d.cts", + "default": "./dist/appearance.cjs" + } + }, "./workflows": { "import": { "types": "./dist/workflows.d.ts", @@ -195,16 +212,18 @@ "api:subpaths": "node scripts/check-subpath-apis.mjs", "api:subpaths:update": "node scripts/check-subpath-apis.mjs --local", "test:consumers": "node scripts/check-consumers.mjs", + "test:capabilities": "node scripts/check-capabilities.mjs", "test:csp": "node scripts/check-csp.mjs", "package:size": "node scripts/check-tarball.mjs", + "check:release": "node scripts/check-release.mjs", "changeset": "changeset", "version-packages": "changeset version", - "release": "npm run validate:all && npm publish --access public", + "release": "npm run validate:all && npm run check:release && npm publish", "typecheck": "tsc --noEmit", "lint": "eslint src tests e2e", "format": "prettier --write .", - "format:check": "prettier --check src tests e2e scripts docs .github README.md CHANGELOG.md package.json playwright.config.ts tsup.config.ts vitest.config.ts", - "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:csp && npm run package:size", + "format:check": "prettier --check .", + "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 .", "prepublishOnly": "npm run build" diff --git a/scripts/check-capabilities.mjs b/scripts/check-capabilities.mjs new file mode 100644 index 0000000..71ef9fc --- /dev/null +++ b/scripts/check-capabilities.mjs @@ -0,0 +1,83 @@ +import { build } from 'esbuild'; + +/** + * Capabilities register themselves through a bare, side-effectful import. A + * bundler will drop that import unless the file it resolves to is covered by + * `sideEffects` in package.json, and shared chunks emitted by code splitting + * cannot be named there. This checks the published ESM actually keeps each + * registration, so `import 'forgedialog/appearance'` is not silently a no-op. + */ +const cases = [ + { + name: 'forgedialog', + contents: "import { open } from './dist/index.mjs'; globalThis.o = open;", + markers: ['title-color', 'fd-dialog--dragging', 'prefers-reduced-motion'], + }, + { + name: 'forgedialog/core', + contents: "import { open } from './dist/core.mjs'; globalThis.o = open;", + markers: ['title-color'], + }, + { + name: 'forgedialog/appearance + /alert', + contents: + "import './dist/appearance.mjs'; import { alert } from './dist/alert.mjs'; globalThis.a = alert;", + markers: ['title-color'], + }, + { + name: 'forgedialog/interactions + /alert', + contents: + "import './dist/interactions.mjs'; import { alert } from './dist/alert.mjs'; globalThis.a = alert;", + markers: ['fd-dialog--dragging'], + }, + { + name: 'forgedialog/animations + /alert', + contents: + "import './dist/animations.mjs'; import { alert } from './dist/alert.mjs'; globalThis.a = alert;", + markers: ['prefers-reduced-motion'], + }, +]; + +let failed = false; +for (const { name, contents, markers } of cases) { + const result = await build({ + stdin: { contents, resolveDir: process.cwd() }, + bundle: true, + minify: true, + platform: 'browser', + format: 'esm', + write: false, + logLevel: 'silent', + }); + const code = result.outputFiles[0].text; + const missing = markers.filter((marker) => !code.includes(marker)); + if (missing.length) { + failed = true; + console.error(`${name}: capability dropped by the bundler (missing ${missing.join(', ')})`); + } else { + console.log(`${name}: capabilities registered`); + } +} + +// The lightweight entries must stay lightweight: importing only forgedialog/alert +// must not drag the full appearance applier in. +const lean = await build({ + stdin: { + contents: "import { alert } from './dist/alert.mjs'; globalThis.a = alert;", + resolveDir: process.cwd(), + }, + bundle: true, + minify: true, + platform: 'browser', + format: 'esm', + write: false, + logLevel: 'silent', +}); +if (lean.outputFiles[0].text.includes('title-color')) { + failed = true; + console.error('forgedialog/alert: pulled in the full appearance applier'); +} else { + console.log('forgedialog/alert: stays on the lightweight applier'); +} + +if (failed) process.exitCode = 1; diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs new file mode 100644 index 0000000..a9586c0 --- /dev/null +++ b/scripts/check-release.mjs @@ -0,0 +1,60 @@ +import { readFileSync } from 'node:fs'; + +/** + * Pre-publish guard. `publint` and `attw` already check the shape of the + * package; this checks the things that only matter at the moment of release: + * that the version being published is the one the tag promises, and that it is + * not already on the registry. + * + * Pass a tag explicitly, or let it read GITHUB_REF_NAME on a tag build. + * Without a tag it only runs the registry check, so it is safe to run locally. + */ +const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); +const problems = []; + +const tag = + process.argv[2] ?? (process.env.GITHUB_REF_TYPE === 'tag' ? process.env.GITHUB_REF_NAME : ''); + +for (const field of ['name', 'version', 'license', 'repository']) { + if (!pkg[field]) problems.push(`package.json is missing "${field}"`); +} + +if (pkg.private) problems.push('package.json is marked private and cannot be published'); + +if (tag) { + const expected = tag.replace(/^v/, ''); + if (expected !== pkg.version) { + problems.push(`tag ${tag} does not match package.json version ${pkg.version}`); + } +} + +// A version already on the registry is immutable, so publishing would fail +// halfway through the release rather than here. +const response = await fetch(`https://registry.npmjs.org/${pkg.name}`, { + headers: { accept: 'application/json' }, +}); + +if (response.status === 404) { + console.log(`${pkg.name} is not published yet; ${pkg.version} would be the first release`); +} else if (response.ok) { + const meta = await response.json(); + const versions = Object.keys(meta.versions ?? {}); + if (versions.includes(pkg.version)) { + problems.push(`${pkg.name}@${pkg.version} is already published and cannot be republished`); + } else { + console.log(`${pkg.name}: latest published is ${meta['dist-tags']?.latest ?? 'unknown'}`); + } +} else { + // Do not fail the release on a registry hiccup; npm publish will still refuse + // a duplicate version on its own. + console.warn( + `could not reach the registry (HTTP ${response.status}); skipping the version check`, + ); +} + +if (problems.length) { + for (const problem of problems) console.error(`release check: ${problem}`); + process.exitCode = 1; +} else { + console.log(`release check passed for ${pkg.name}@${pkg.version}${tag ? ` (tag ${tag})` : ''}`); +} diff --git a/scripts/check-size.mjs b/scripts/check-size.mjs index b10955c..8e0128a 100644 --- a/scripts/check-size.mjs +++ b/scripts/check-size.mjs @@ -1,13 +1,16 @@ import { gzipSync } from 'node:zlib'; import { readFileSync } from 'node:fs'; +// Budgets are guard rails against unintended growth, not targets to sit on. +// They were widened in 0.7.0 so the full appearance applier ships from both the +// main and core entries, and so routine changes have room before a budget trips. const budgets = { - 'dist/core.cjs': 6.5 * 1024, - 'dist/index.cjs': 14 * 1024, - 'dist/interactions.cjs': 2 * 1024, - 'dist/style-core.css': 2.5 * 1024, - 'dist/style-forms.css': 0.9 * 1024, - 'dist/style-workflows.css': 0.7 * 1024, + 'dist/core.cjs': 8 * 1024, + 'dist/index.cjs': 16 * 1024, + 'dist/interactions.cjs': 2.5 * 1024, + 'dist/style-core.css': 3 * 1024, + 'dist/style-forms.css': 1.1 * 1024, + 'dist/style-workflows.css': 0.9 * 1024, }; let failed = false; diff --git a/scripts/check-subpath-apis.mjs b/scripts/check-subpath-apis.mjs index 2a1ac87..fd6a510 100644 --- a/scripts/check-subpath-apis.mjs +++ b/scripts/check-subpath-apis.mjs @@ -7,6 +7,7 @@ const entries = [ 'core', 'interactions', 'animations', + 'appearance', 'presentation', 'workflows', ]; diff --git a/scripts/check-tarball.mjs b/scripts/check-tarball.mjs index facc690..f3c1af4 100644 --- a/scripts/check-tarball.mjs +++ b/scripts/check-tarball.mjs @@ -5,6 +5,9 @@ const result = Array.isArray(output) ? output[0] : output.files ? output : Objec if (!result?.files) throw new Error('npm pack did not return a file manifest'); const empty = result.files.filter((file) => file.size === 0); console.log(`tarball: ${(result.size / 1024).toFixed(1)} KiB, ${result.entryCount} files`); -if (result.size > 110 * 1024) throw new Error('Tarball exceeds the 110 KiB budget'); +// Widened in 0.7.0: entries are published unsplit so capability registrations +// survive bundling, which duplicates shared code across entry files. Consumers +// still only pay for the entry they import; this bounds the published package. +if (result.size > 140 * 1024) throw new Error('Tarball exceeds the 140 KiB budget'); if (empty.length) throw new Error(`Empty tarball artifacts: ${empty.map((file) => file.path).join(', ')}`); diff --git a/scripts/check-treeshaking.mjs b/scripts/check-treeshaking.mjs index 08439c2..8dd6878 100644 --- a/scripts/check-treeshaking.mjs +++ b/scripts/check-treeshaking.mjs @@ -20,5 +20,6 @@ const included = Object.keys(result.metafile.inputs).map((path) => path.replaceA const leaked = forbidden.filter((path) => included.some((input) => input.endsWith(path))); const gzipBytes = gzipSync(result.outputFiles[0].contents).byteLength; console.log(`alert-only fixture: ${(gzipBytes / 1024).toFixed(2)} KiB gzip`); +const budget = 6.5 * 1024; if (leaked.length) throw new Error(`Tree-shaking regression: ${leaked.join(', ')}`); -if (gzipBytes > 5.5 * 1024) throw new Error('Alert-only fixture exceeds the 5.5 KiB gzip budget'); +if (gzipBytes > budget) throw new Error('Alert-only fixture exceeds the 6.5 KiB gzip budget'); diff --git a/src/appearance-entry.ts b/src/appearance-entry.ts new file mode 100644 index 0000000..1710151 --- /dev/null +++ b/src/appearance-entry.ts @@ -0,0 +1,13 @@ +import { applyAppearance } from './core/appearance'; +import { registerAppearanceApplier } from './core/appearanceRegistry'; + +registerAppearanceApplier(applyAppearance); + +export { applyAppearance } from './core/appearance'; +export type { + DialogAppearance, + DialogHoverAppearance, + DialogShadow, + DialogShadowConfig, + ShadowPreset, +} from './types'; diff --git a/src/core-entry.ts b/src/core-entry.ts index 6da0a7a..39b79d3 100644 --- a/src/core-entry.ts +++ b/src/core-entry.ts @@ -1,3 +1,8 @@ +// The full appearance applier ships with this entry, so per-component colors, +// composed shadows, and hover work without an extra import. The single-purpose +// entries (forgedialog/alert and friends) stay on the lightweight applier. +import './appearance-entry'; + export { alert } from './api/alert'; export { confirm } from './api/confirm'; export { open } from './api/open'; diff --git a/src/core/Dialog.ts b/src/core/Dialog.ts index 3f2a874..490386f 100644 --- a/src/core/Dialog.ts +++ b/src/core/Dialog.ts @@ -13,7 +13,7 @@ import type { import { generateId } from '../utils/id'; import { resolveRole } from './aria'; import { animateDialogIn, animateDialogOut } from './animationRegistry'; -import { applyAppearance } from './appearance'; +import { applyDialogAppearance } from './appearanceRegistry'; import type { DialogStackManager } from './DialogStack'; import { buildDialogDom, @@ -79,7 +79,7 @@ export class Dialog implements DialogInstance { this.bodyEl = built.body; this.buttonElements = built.buttonElements; this.focusTrap = new FocusTrap(this.dialogEl); - applyAppearance(this.element, this.dialogEl, options.appearance); + applyDialogAppearance(this.element, this.dialogEl, options.appearance); this.setupDraggable(options.draggable, built.header); this.closedPromise = new Promise((resolve) => { @@ -232,7 +232,8 @@ export class Dialog implements DialogInstance { const header = this.dialogEl.querySelector('.fd-dialog__header'); this.setupDraggable(partial.draggable, header ?? undefined); } - if ('appearance' in partial) applyAppearance(this.element, this.dialogEl, partial.appearance); + if ('appearance' in partial) + applyDialogAppearance(this.element, this.dialogEl, partial.appearance); if ('size' in partial) { this.dialogEl.classList.remove(`fd-dialog--${previous.size ?? 'md'}`); this.dialogEl.classList.add(`fd-dialog--${partial.size ?? 'md'}`); diff --git a/src/core/appearance-basic.ts b/src/core/appearance-basic.ts new file mode 100644 index 0000000..8d85fb0 --- /dev/null +++ b/src/core/appearance-basic.ts @@ -0,0 +1,63 @@ +import type { DialogAppearance } from '../types'; + +export type AppearanceApplier = ( + overlay: HTMLElement, + dialog: HTMLElement, + appearance?: DialogAppearance, +) => void; + +export const SHADOW_PRESETS = new Set(['none', 'sm', 'md', 'lg', 'xl']); +export const PREFIX = '--fd-dialog-'; +export const HOVER_PREFIX = '--fd-dialog-hover-'; + +/** Scalars, plus the structured values (radius, shadow) the full applier takes. */ +export type AppearanceValue = string | number | boolean | object; +export type Formatter = (value: AppearanceValue) => string; + +export function clamp(value: number): number { + return Math.min(1, Math.max(0, value)); +} + +export const percent: Formatter = (value) => `${clamp(Number(value)) * 100}%`; +export const text: Formatter = (value) => String(value); +export const length: Formatter = (value) => + typeof value === 'number' ? `${Math.max(0, value)}px` : String(value); + +/** + * The always-bundled appearance applier. It covers the surface, backdrop, and + * border so focused entries such as `forgedialog/alert` stay small; importing + * `forgedialog` (or `forgedialog/appearance`) upgrades this to the full applier + * with per-component colors, composed shadows, and hover styling. + */ +export const applyBasicAppearance: AppearanceApplier = (overlay, dialog, appearance) => { + const style = dialog.style; + style.removeProperty(`${PREFIX}opacity`); + style.removeProperty(`${PREFIX}border-color`); + style.removeProperty(`${PREFIX}border-width`); + style.removeProperty(`${PREFIX}border-style`); + style.removeProperty(`${PREFIX}shadow`); + dialog.removeAttribute('data-fd-shadow'); + overlay.style.removeProperty('--fd-overlay-opacity'); + overlay.style.removeProperty('--fd-backdrop-blur'); + if (!appearance) return; + + if (appearance.opacity !== undefined) { + style.setProperty(`${PREFIX}opacity`, percent(appearance.opacity)); + } + if (appearance.borderColor) style.setProperty(`${PREFIX}border-color`, appearance.borderColor); + if (appearance.borderWidth !== undefined) { + style.setProperty(`${PREFIX}border-width`, length(appearance.borderWidth)); + } + if (appearance.borderStyle) style.setProperty(`${PREFIX}border-style`, appearance.borderStyle); + if (appearance.overlayOpacity !== undefined) { + overlay.style.setProperty('--fd-overlay-opacity', percent(appearance.overlayOpacity)); + } + if (appearance.backdropBlur !== undefined) { + overlay.style.setProperty('--fd-backdrop-blur', length(appearance.backdropBlur)); + } + + const shadow = appearance.shadow; + if (typeof shadow !== 'string') return; + if (SHADOW_PRESETS.has(shadow)) dialog.dataset.fdShadow = shadow; + else style.setProperty(`${PREFIX}shadow`, shadow); +}; diff --git a/src/core/appearance.ts b/src/core/appearance.ts index 7a38a8c..a1d9e78 100644 --- a/src/core/appearance.ts +++ b/src/core/appearance.ts @@ -1,54 +1,163 @@ -import type { DialogAppearance } from '../types'; +import type { DialogAppearance, DialogHoverAppearance, DialogRadius, DialogShadow } from '../types'; +import { + type AppearanceApplier, + type AppearanceValue, + type Formatter, + HOVER_PREFIX, + PREFIX, + SHADOW_PRESETS, + clamp, + length, + percent, + text, +} from './appearance-basic'; -const SHADOW_PRESETS = new Set(['none', 'sm', 'md', 'lg', 'xl']); +type Field = readonly [keyof T & string, string, Formatter]; -const DIALOG_PROPERTIES = [ - '--fd-dialog-opacity', - '--fd-dialog-border-color', - '--fd-dialog-border-width', - '--fd-dialog-border-style', - '--fd-dialog-shadow', -] as const; +const ratio: Formatter = (value) => String(clamp(Number(value))); -function opacity(value: number): string { - return `${Math.min(1, Math.max(0, value)) * 100}%`; +/** + * Resolves a radius into a `border-radius` value. A per-corner object leaves + * omitted corners on the theme radius rather than collapsing them to zero. + */ +function resolveRadius(radius: DialogRadius): string { + if (typeof radius === 'number' || typeof radius === 'string') return length(radius); + const corner = (value: number | string | undefined): string => + value === undefined ? 'var(--fd-radius)' : length(value); + return [radius.topLeft, radius.topRight, radius.bottomRight, radius.bottomLeft] + .map(corner) + .join(' '); } -function length(value: number | string): string { - return typeof value === 'number' ? `${Math.max(0, value)}px` : value; -} +const radiusValue: Formatter = (value) => resolveRadius(value as DialogRadius); -export function applyAppearance( - overlay: HTMLElement, - dialog: HTMLElement, - appearance?: DialogAppearance, +function applyFields( + element: HTMLElement, + prefix: string, + source: T | undefined, + fields: ReadonlyArray>, ): void { - DIALOG_PROPERTIES.forEach((property) => dialog.style.removeProperty(property)); + for (const [key, suffix, format] of fields) { + const value = source?.[key]; + if (value === undefined) element.style.removeProperty(prefix + suffix); + else element.style.setProperty(prefix + suffix, format(value as AppearanceValue)); + } +} + +const DEFAULT_ANGLE = 180; +const DEFAULT_DISTANCE = 20; +const DEFAULT_BLUR = 60; +const DEFAULT_COLOR = 'rgb(15 17 21)'; +const DEFAULT_STRENGTH = 0.25; + +const time: Formatter = (value) => + typeof value === 'number' ? `${Math.max(0, value)}ms` : String(value); +const scale: Formatter = (value) => String(Math.max(0, Number(value))); +/** A positive lift raises the dialog, so the offset is negated. */ +const lift: Formatter = (value) => + typeof value === 'number' ? `${-value}px` : `calc(-1 * ${String(value)})`; + +const BASE_FIELDS: ReadonlyArray> = [ + ['opacity', 'opacity', percent], + ['surfaceColor', 'surface-color', text], + ['titleColor', 'title-color', text], + ['titleBackground', 'title-background', text], + ['titleOpacity', 'title-opacity', ratio], + ['contentColor', 'content-color', text], + ['contentOpacity', 'content-opacity', ratio], + ['borderColor', 'border-color', text], + ['borderOpacity', 'border-opacity', percent], + ['borderWidth', 'border-width', length], + ['borderStyle', 'border-style', text], + ['radius', 'radius', radiusValue], +]; + +const HOVER_FIELDS: ReadonlyArray> = [ + ['opacity', 'opacity', percent], + ['surfaceColor', 'surface-color', text], + ['titleColor', 'title-color', text], + ['titleBackground', 'title-background', text], + ['contentColor', 'content-color', text], + ['borderColor', 'border-color', text], + ['radius', 'radius', radiusValue], + ['lift', 'lift', lift], + ['scale', 'scale', scale], + ['duration', 'duration', time], +]; + +/** Blends `color` down to `strength` (0..1) without parsing it. */ +function fade(color: string, strength: number): string { + return `color-mix(in srgb, ${color} ${percent(strength)}, transparent)`; +} + +/** + * Resolves a composed shadow into a `box-shadow` value. `angle` is measured + * clockwise from "cast upwards", so 180deg drops the shadow straight down. + */ +function composeShadow(shadow: Exclude): string { + const radians = ((shadow.angle ?? DEFAULT_ANGLE) * Math.PI) / 180; + const blur = length(shadow.blur ?? DEFAULT_BLUR); + const spread = length(shadow.spread ?? 0); + const color = fade(shadow.color ?? DEFAULT_COLOR, shadow.opacity ?? DEFAULT_STRENGTH); + const inset = shadow.inset ? 'inset ' : ''; + const tail = `${blur} ${spread} ${color}`; + const x = Math.sin(radians); + const y = -Math.cos(radians); + + if (typeof shadow.distance === 'string') { + // A CSS length cannot be projected onto an angle, so honor the axis the + // angle points at and let the author's unit through untouched. + const horizontal = Math.abs(x) > Math.abs(y); + const along = (horizontal ? x : y) < 0 ? `calc(-1 * ${shadow.distance})` : shadow.distance; + return horizontal ? `${inset}${along} 0 ${tail}` : `${inset}0 ${along} ${tail}`; + } + + const distance = shadow.distance ?? DEFAULT_DISTANCE; + const round = (value: number): string => `${Math.round(value * distance * 100) / 100}px`; + return `${inset}${round(x)} ${round(y)} ${tail}`; +} + +function applyShadow(dialog: HTMLElement, shadow: DialogShadow, hover: boolean): void { + const property = hover ? `${HOVER_PREFIX}shadow` : `${PREFIX}shadow`; + if (typeof shadow !== 'string') { + dialog.style.setProperty(property, composeShadow(shadow)); + return; + } + // Presets live in CSS so they stay themeable; anything else is a raw value. + if (!SHADOW_PRESETS.has(shadow)) dialog.style.setProperty(property, shadow); + else if (hover) dialog.dataset.fdHoverShadow = shadow; + else dialog.dataset.fdShadow = shadow; +} + +export const applyAppearance: AppearanceApplier = (overlay, dialog, appearance) => { + const hover = appearance?.hover; + + applyFields(dialog, PREFIX, appearance, BASE_FIELDS); + applyFields(dialog, HOVER_PREFIX, hover, HOVER_FIELDS); + dialog.style.removeProperty(`${PREFIX}shadow`); + dialog.style.removeProperty(`${HOVER_PREFIX}shadow`); dialog.removeAttribute('data-fd-shadow'); + dialog.removeAttribute('data-fd-hover-shadow'); overlay.style.removeProperty('--fd-overlay-opacity'); overlay.style.removeProperty('--fd-backdrop-blur'); + + // A header that paints its own background needs the bottom padding a bare + // header would otherwise borrow from the body. + if (appearance?.titleBackground ?? hover?.titleBackground) dialog.dataset.fdTitleBackground = ''; + else dialog.removeAttribute('data-fd-title-background'); + + // Marks the dialog as opted into hover styling so every other dialog keeps + // its entirely static appearance. + if (hover) dialog.dataset.fdHover = ''; + else dialog.removeAttribute('data-fd-hover'); if (!appearance) return; - if (appearance.opacity !== undefined) { - dialog.style.setProperty('--fd-dialog-opacity', opacity(appearance.opacity)); - } if (appearance.overlayOpacity !== undefined) { - overlay.style.setProperty('--fd-overlay-opacity', opacity(appearance.overlayOpacity)); + overlay.style.setProperty('--fd-overlay-opacity', percent(appearance.overlayOpacity)); } if (appearance.backdropBlur !== undefined) { overlay.style.setProperty('--fd-backdrop-blur', length(appearance.backdropBlur)); } - if (appearance.borderColor) { - dialog.style.setProperty('--fd-dialog-border-color', appearance.borderColor); - } - if (appearance.borderWidth !== undefined) { - dialog.style.setProperty('--fd-dialog-border-width', length(appearance.borderWidth)); - } - if (appearance.borderStyle) { - dialog.style.setProperty('--fd-dialog-border-style', appearance.borderStyle); - } - if (appearance.shadow) { - if (SHADOW_PRESETS.has(appearance.shadow)) dialog.dataset.fdShadow = appearance.shadow; - else dialog.style.setProperty('--fd-dialog-shadow', appearance.shadow); - } -} + if (appearance.shadow) applyShadow(dialog, appearance.shadow, false); + if (hover?.shadow) applyShadow(dialog, hover.shadow, true); +}; diff --git a/src/core/appearanceRegistry.ts b/src/core/appearanceRegistry.ts new file mode 100644 index 0000000..6607c29 --- /dev/null +++ b/src/core/appearanceRegistry.ts @@ -0,0 +1,13 @@ +import { type AppearanceApplier, applyBasicAppearance } from './appearance-basic'; + +let applier: AppearanceApplier = applyBasicAppearance; + +export function registerAppearanceApplier(next: AppearanceApplier): void { + applier = next; +} + +export const applyDialogAppearance: AppearanceApplier = (overlay, dialog, appearance) => { + applier(overlay, dialog, appearance); +}; + +export type { AppearanceApplier } from './appearance-basic'; diff --git a/src/index.ts b/src/index.ts index 437c381..dddb141 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import type { HookFn, HookName, Plugin } from './types'; import { pluginManager } from './plugins/PluginManager'; import './interactions-entry'; import './animations-entry'; +import './appearance-entry'; declare const __FORGEDIALOG_VERSION__: string; @@ -36,6 +37,9 @@ export type { CloseReason, DialogOutcome, DialogAppearance, + DialogHoverAppearance, + DialogShadow, + DialogShadowConfig, DialogDragEvent, DialogPosition, DialogPresentation, diff --git a/src/theme/dialog.css b/src/theme/dialog.css index 26b1696..bd940a9 100644 --- a/src/theme/dialog.css +++ b/src/theme/dialog.css @@ -36,16 +36,104 @@ width: 100%; max-width: 420px; max-height: calc(100vh - var(--fd-spacing-lg) * 2); - background: color-mix(in srgb, var(--fd-color-surface) var(--fd-dialog-opacity), transparent); + background: color-mix( + in srgb, + var(--fd-dialog-surface-color) var(--fd-dialog-opacity), + transparent + ); color: var(--fd-color-text); border: var(--fd-dialog-border-width) var(--fd-dialog-border-style) - var(--fd-dialog-border-color); - border-radius: var(--fd-radius); + color-mix(in srgb, var(--fd-dialog-border-color) var(--fd-dialog-border-opacity), transparent); + border-radius: var(--fd-dialog-radius); box-shadow: var(--fd-dialog-shadow); translate: var(--fd-drag-x) var(--fd-drag-y); overflow: hidden; } + /* + * Only dialogs that opted into hover styling animate or restyle on hover. + * Hover rules must set real properties rather than redefine the `--fd-dialog-*` + * custom properties: those are written as inline styles by applyAppearance, + * and an inline declaration always beats a stylesheet rule on the same element. + */ + .fd-dialog[data-fd-hover] { + transition: + background-color var(--fd-dialog-hover-duration) var(--fd-easing), + border-color var(--fd-dialog-hover-duration) var(--fd-easing), + border-radius var(--fd-dialog-hover-duration) var(--fd-easing), + box-shadow var(--fd-dialog-hover-duration) var(--fd-easing), + transform var(--fd-dialog-hover-duration) var(--fd-easing); + } + + .fd-dialog[data-fd-hover]:hover { + background: color-mix( + in srgb, + var(--fd-dialog-hover-surface-color, var(--fd-dialog-surface-color)) + var(--fd-dialog-hover-opacity, var(--fd-dialog-opacity)), + transparent + ); + border-color: color-mix( + in srgb, + var(--fd-dialog-hover-border-color, var(--fd-dialog-border-color)) + var(--fd-dialog-border-opacity), + transparent + ); + border-radius: var(--fd-dialog-hover-radius, var(--fd-dialog-radius)); + box-shadow: var(--fd-dialog-hover-shadow, var(--fd-dialog-shadow)); + transform: translateY(var(--fd-dialog-hover-lift)) scale(var(--fd-dialog-hover-scale)); + } + + .fd-dialog[data-fd-hover]:hover .fd-dialog__header { + background: var(--fd-dialog-hover-title-background, var(--fd-dialog-title-background)); + } + + /* + * A hover lift or scale would resize the box the drag logic clamps against, + * letting a dragged dialog escape its bounds, so it is suspended while + * dragging. + */ + .fd-dialog[data-fd-hover].fd-dialog--dragging, + .fd-dialog[data-fd-hover].fd-dialog--dragging:hover { + transform: none; + transition: none; + } + + .fd-dialog[data-fd-hover]:hover .fd-dialog__title { + color: var(--fd-dialog-hover-title-color, var(--fd-dialog-title-color)); + } + + .fd-dialog[data-fd-hover]:hover .fd-dialog__body { + color: var(--fd-dialog-hover-content-color, var(--fd-dialog-content-color)); + } + + .fd-dialog[data-fd-hover-shadow='none']:hover { + --fd-dialog-hover-shadow: none; + } + + .fd-dialog[data-fd-hover-shadow='sm']:hover { + --fd-dialog-hover-shadow: 0 4px 12px rgb(15 17 21 / 14%); + } + + .fd-dialog[data-fd-hover-shadow='md']:hover { + --fd-dialog-hover-shadow: 0 12px 32px rgb(15 17 21 / 20%); + } + + .fd-dialog[data-fd-hover-shadow='lg']:hover { + --fd-dialog-hover-shadow: 0 20px 60px rgb(15 17 21 / 25%); + } + + .fd-dialog[data-fd-hover-shadow='xl']:hover { + --fd-dialog-hover-shadow: 0 28px 90px rgb(15 17 21 / 32%); + } + + @media (prefers-reduced-motion: reduce) { + .fd-dialog[data-fd-hover], + .fd-dialog[data-fd-hover]:hover { + transform: none; + transition-duration: 1ms; + } + } + .fd-dialog[data-fd-shadow='none'] { --fd-dialog-shadow: none; } @@ -98,7 +186,9 @@ max-width: none; height: 100vh; max-height: none; - border-radius: 0; + /* Set the variable, not the property, so an explicit `radius` still wins: + applyAppearance writes it inline, which outranks this rule. */ + --fd-dialog-radius: 0px; } .fd-overlay:has(.fd-dialog--bottom-sheet) { align-items: flex-end; @@ -107,7 +197,12 @@ .fd-dialog--bottom-sheet { width: 100%; max-width: 720px; - border-radius: var(--fd-radius) var(--fd-radius) 0 0; + /* Substituting the radius variable twice would expand a per-corner value + into ten lengths and void the declaration, so square off the bottom with + longhands instead. */ + border-radius: var(--fd-dialog-radius); + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; } .fd-dialog--bottom-sheet { touch-action: pan-x; @@ -141,6 +236,14 @@ justify-content: space-between; gap: var(--fd-spacing-md); padding: var(--fd-spacing-md) var(--fd-spacing-lg) 0; + background: var(--fd-dialog-title-background); + transition: background-color var(--fd-dialog-hover-duration) var(--fd-easing); + } + + /* A header with its own background needs the padding a bare header borrows + from the body below it, or the title sits flush against the colored edge. */ + .fd-dialog[data-fd-title-background] .fd-dialog__header { + padding-bottom: var(--fd-spacing-md); } .fd-dialog__title { @@ -148,6 +251,9 @@ font-size: 1.05rem; font-weight: 600; line-height: 1.4; + color: var(--fd-dialog-title-color); + opacity: var(--fd-dialog-title-opacity); + transition: color var(--fd-dialog-hover-duration) var(--fd-easing); } .fd-dialog__title--visually-hidden { @@ -188,7 +294,9 @@ overflow-y: auto; font-size: 0.95rem; line-height: 1.5; - color: var(--fd-color-text); + color: var(--fd-dialog-content-color); + opacity: var(--fd-dialog-content-opacity); + transition: color var(--fd-dialog-hover-duration) var(--fd-easing); } .fd-dialog__message { diff --git a/src/theme/tokens.css b/src/theme/tokens.css index bfc21bc..128440a 100644 --- a/src/theme/tokens.css +++ b/src/theme/tokens.css @@ -23,10 +23,21 @@ --fd-duration-normal: 200ms; --fd-easing: cubic-bezier(0.16, 1, 0.3, 1); --fd-dialog-opacity: 100%; + --fd-dialog-surface-color: var(--fd-color-surface); + --fd-dialog-title-color: var(--fd-color-text); + --fd-dialog-title-background: transparent; + --fd-dialog-title-opacity: 1; + --fd-dialog-radius: var(--fd-radius); + --fd-dialog-content-color: var(--fd-color-text); + --fd-dialog-content-opacity: 1; --fd-dialog-border-width: 1px; --fd-dialog-border-style: solid; --fd-dialog-border-color: var(--fd-color-border); + --fd-dialog-border-opacity: 100%; --fd-dialog-shadow: var(--fd-shadow); + --fd-dialog-hover-duration: var(--fd-duration-normal); + --fd-dialog-hover-lift: 0px; + --fd-dialog-hover-scale: 1; --fd-overlay-opacity: 100%; --fd-backdrop-blur: 0px; } diff --git a/src/types.ts b/src/types.ts index ca07805..9a8f1f9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,6 +16,69 @@ export interface DialogPosition { y: number; } +export type ShadowPreset = 'none' | 'sm' | 'md' | 'lg' | 'xl'; + +/** + * A shadow built from its parts, so both strength and direction can be tuned + * without hand-writing a `box-shadow` string. + */ +export interface DialogShadowConfig { + /** + * Direction the shadow falls, in degrees, as if light came from the opposite + * side. `0` casts it upwards, `90` to the right, `180` downwards (the + * default), `270` to the left. + */ + angle?: number; + /** How far the shadow travels along `angle`. Numbers are CSS pixels. */ + distance?: number | string; + /** Blur radius. Numbers are CSS pixels. */ + blur?: number | string; + /** Spread radius. Numbers are CSS pixels. */ + spread?: number | string; + color?: string; + /** Shadow strength from 0 (invisible) to 1 (fully opaque). */ + opacity?: number; + /** Draw the shadow inside the dialog instead of outside it. */ + inset?: boolean; +} + +/** A built-in preset, any valid CSS box-shadow value, or a composed shadow. */ +export type DialogShadow = ShadowPreset | string | DialogShadowConfig; + +/** Corner radii, set individually. Omitted corners keep the theme radius. */ +export interface DialogCornerRadius { + topLeft?: number | string; + topRight?: number | string; + bottomRight?: number | string; + bottomLeft?: number | string; +} + +/** + * One radius for every corner (numbers are CSS pixels), any CSS `border-radius` + * value, or a per-corner object. + */ +export type DialogRadius = number | string | DialogCornerRadius; + +/** Appearance overrides applied only while the pointer is over the dialog. */ +export interface DialogHoverAppearance { + opacity?: number; + surfaceColor?: string; + titleColor?: string; + /** Background behind the dialog header. */ + titleBackground?: string; + contentColor?: string; + borderColor?: string; + /** Corner rounding. */ + radius?: DialogRadius; + shadow?: DialogShadow; + /** Distance the dialog rises on hover. Numbers are CSS pixels. */ + lift?: number | string; + /** Scale factor on hover, for example `1.02`. */ + scale?: number; + /** Transition duration for hover changes. Numbers are milliseconds. */ + duration?: number | string; +} + export interface DialogAppearance { /** Dialog surface opacity from 0 (transparent) to 1 (opaque). */ opacity?: number; @@ -23,11 +86,29 @@ export interface DialogAppearance { overlayOpacity?: number; /** Backdrop blur in CSS pixels, or any CSS length. */ backdropBlur?: number | string; + /** Background color of the dialog surface. */ + surfaceColor?: string; + /** Color of the dialog title. */ + titleColor?: string; + /** Background behind the dialog header, transparent by default. */ + titleBackground?: string; + /** Title opacity from 0 (transparent) to 1 (opaque). */ + titleOpacity?: number; + /** Color of the dialog body, including the default message paragraph. */ + contentColor?: string; + /** Body opacity from 0 (transparent) to 1 (opaque). */ + contentOpacity?: number; borderColor?: string; + /** Border opacity from 0 (transparent) to 1 (opaque). */ + borderOpacity?: number; borderWidth?: number | string; borderStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double'; - /** A built-in preset or any valid CSS box-shadow value. */ - shadow?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | string; + /** Corner rounding: one value for every corner, a CSS value, or per corner. */ + radius?: DialogRadius; + /** A built-in preset, any valid CSS box-shadow value, or a composed shadow. */ + shadow?: DialogShadow; + /** Overrides applied only while the pointer is over the dialog. */ + hover?: DialogHoverAppearance; } export interface DialogDragEvent { diff --git a/tests/core/appearance.test.ts b/tests/core/appearance.test.ts index be40cff..7d74932 100644 --- a/tests/core/appearance.test.ts +++ b/tests/core/appearance.test.ts @@ -35,6 +35,132 @@ describe('applyAppearance', () => { expect(dialog.hasAttribute('data-fd-shadow')).toBe(false); }); + it('scopes color and opacity per component', () => { + const overlay = document.createElement('div'); + const dialog = document.createElement('div'); + applyAppearance(overlay, dialog, { + surfaceColor: '#101014', + titleColor: '#ffd166', + titleOpacity: 0.9, + contentColor: '#c8ccd4', + contentOpacity: 0.75, + borderColor: '#7c5cff', + borderOpacity: 0.5, + }); + expect(dialog.style.getPropertyValue('--fd-dialog-surface-color')).toBe('#101014'); + expect(dialog.style.getPropertyValue('--fd-dialog-title-color')).toBe('#ffd166'); + expect(dialog.style.getPropertyValue('--fd-dialog-title-opacity')).toBe('0.9'); + expect(dialog.style.getPropertyValue('--fd-dialog-content-color')).toBe('#c8ccd4'); + expect(dialog.style.getPropertyValue('--fd-dialog-content-opacity')).toBe('0.75'); + expect(dialog.style.getPropertyValue('--fd-dialog-border-opacity')).toBe('50%'); + }); + + it('accepts a radius as a number, a CSS value, or per corner', () => { + const overlay = document.createElement('div'); + const dialog = document.createElement('div'); + + applyAppearance(overlay, dialog, { radius: 20 }); + expect(dialog.style.getPropertyValue('--fd-dialog-radius')).toBe('20px'); + + applyAppearance(overlay, dialog, { radius: '2rem 0' }); + expect(dialog.style.getPropertyValue('--fd-dialog-radius')).toBe('2rem 0'); + + // Omitted corners stay on the theme radius rather than collapsing to zero. + applyAppearance(overlay, dialog, { radius: { topLeft: 24, bottomRight: '1rem' } }); + expect(dialog.style.getPropertyValue('--fd-dialog-radius')).toBe( + '24px var(--fd-radius) 1rem var(--fd-radius)', + ); + + applyAppearance(overlay, dialog); + expect(dialog.style.getPropertyValue('--fd-dialog-radius')).toBe(''); + }); + + it('paints a header background and flags it for the extra padding', () => { + const overlay = document.createElement('div'); + const dialog = document.createElement('div'); + + applyAppearance(overlay, dialog, { titleBackground: '#101014' }); + expect(dialog.style.getPropertyValue('--fd-dialog-title-background')).toBe('#101014'); + expect(dialog.hasAttribute('data-fd-title-background')).toBe(true); + + // A hover-only header background still needs the padding reserved. + applyAppearance(overlay, dialog, { hover: { titleBackground: '#1b1e26' } }); + expect(dialog.style.getPropertyValue('--fd-dialog-hover-title-background')).toBe('#1b1e26'); + expect(dialog.hasAttribute('data-fd-title-background')).toBe(true); + + applyAppearance(overlay, dialog, { titleColor: '#fff' }); + expect(dialog.hasAttribute('data-fd-title-background')).toBe(false); + }); + + it('composes a shadow from its direction and strength', () => { + const overlay = document.createElement('div'); + const dialog = document.createElement('div'); + + applyAppearance(overlay, dialog, { + shadow: { angle: 180, distance: 10, blur: 30, spread: 2, color: '#000', opacity: 0.4 }, + }); + expect(dialog.style.getPropertyValue('--fd-dialog-shadow')).toBe( + '0px 10px 30px 2px color-mix(in srgb, #000 40%, transparent)', + ); + + // 90deg casts the shadow to the right, 270deg to the left, 0deg upwards. + applyAppearance(overlay, dialog, { shadow: { angle: 90, distance: 12 } }); + expect(dialog.style.getPropertyValue('--fd-dialog-shadow')).toContain('12px 0px'); + applyAppearance(overlay, dialog, { shadow: { angle: 270, distance: 12 } }); + expect(dialog.style.getPropertyValue('--fd-dialog-shadow')).toContain('-12px 0px'); + applyAppearance(overlay, dialog, { shadow: { angle: 0, distance: 12 } }); + expect(dialog.style.getPropertyValue('--fd-dialog-shadow')).toContain('0px -12px'); + }); + + it('supports inset shadows and CSS length distances', () => { + const overlay = document.createElement('div'); + const dialog = document.createElement('div'); + + applyAppearance(overlay, dialog, { shadow: { inset: true, angle: 180, distance: 4 } }); + expect(dialog.style.getPropertyValue('--fd-dialog-shadow')).toContain('inset 0px 4px'); + + applyAppearance(overlay, dialog, { shadow: { angle: 270, distance: '1rem' } }); + expect(dialog.style.getPropertyValue('--fd-dialog-shadow')).toContain('calc(-1 * 1rem) 0'); + }); + + it('records hover overrides only when hover styling is requested', () => { + const overlay = document.createElement('div'); + const dialog = document.createElement('div'); + + applyAppearance(overlay, dialog, { opacity: 0.9 }); + expect(dialog.hasAttribute('data-fd-hover')).toBe(false); + + applyAppearance(overlay, dialog, { + hover: { + opacity: 1, + surfaceColor: '#1b1e26', + titleColor: '#ffffff', + contentColor: '#e6e8ec', + borderColor: '#9f7bff', + shadow: 'xl', + lift: 6, + scale: 1.02, + duration: 150, + }, + }); + expect(dialog.hasAttribute('data-fd-hover')).toBe(true); + expect(dialog.style.getPropertyValue('--fd-dialog-hover-opacity')).toBe('100%'); + expect(dialog.style.getPropertyValue('--fd-dialog-hover-surface-color')).toBe('#1b1e26'); + expect(dialog.style.getPropertyValue('--fd-dialog-hover-title-color')).toBe('#ffffff'); + expect(dialog.style.getPropertyValue('--fd-dialog-hover-content-color')).toBe('#e6e8ec'); + expect(dialog.style.getPropertyValue('--fd-dialog-hover-border-color')).toBe('#9f7bff'); + expect(dialog.dataset.fdHoverShadow).toBe('xl'); + // Lift is inverted so a positive value raises the dialog. + expect(dialog.style.getPropertyValue('--fd-dialog-hover-lift')).toBe('-6px'); + expect(dialog.style.getPropertyValue('--fd-dialog-hover-scale')).toBe('1.02'); + expect(dialog.style.getPropertyValue('--fd-dialog-hover-duration')).toBe('150ms'); + + applyAppearance(overlay, dialog); + expect(dialog.hasAttribute('data-fd-hover')).toBe(false); + expect(dialog.hasAttribute('data-fd-hover-shadow')).toBe(false); + expect(dialog.style.getPropertyValue('--fd-dialog-hover-lift')).toBe(''); + }); + it('accepts CSS lengths and custom shadow values', () => { const overlay = document.createElement('div'); const dialog = document.createElement('div'); diff --git a/tests/core/capabilities.test.ts b/tests/core/capabilities.test.ts index bb81187..d13c7cb 100644 --- a/tests/core/capabilities.test.ts +++ b/tests/core/capabilities.test.ts @@ -4,6 +4,12 @@ import { animateDialogIn, animateDialogOut, } from '../../src/core/animationRegistry'; +import { applyAppearance } from '../../src/core/appearance'; +import { applyBasicAppearance } from '../../src/core/appearance-basic'; +import { + applyDialogAppearance, + registerAppearanceApplier, +} from '../../src/core/appearanceRegistry'; import { makeBasicDraggable } from '../../src/core/draggable-basic'; import { createDraggable, registerDraggableFactory } from '../../src/core/interactionRegistry'; @@ -20,6 +26,36 @@ describe('optional capabilities', () => { expect(exit).toHaveBeenCalledWith(overlay, dialog, 'fade'); }); + it('applies basic appearance until the full applier is registered', () => { + const overlay = document.createElement('div'); + const dialog = document.createElement('div'); + + // The lightweight applier covers surface, backdrop, and border only, so + // focused entries stay small; richer options are simply ignored. + applyBasicAppearance(overlay, dialog, { + opacity: 0.5, + borderColor: '#123456', + shadow: 'lg', + titleColor: '#ff0000', + hover: { titleColor: '#00ff00' }, + }); + expect(dialog.style.getPropertyValue('--fd-dialog-opacity')).toBe('50%'); + expect(dialog.style.getPropertyValue('--fd-dialog-border-color')).toBe('#123456'); + expect(dialog.dataset.fdShadow).toBe('lg'); + expect(dialog.style.getPropertyValue('--fd-dialog-title-color')).toBe(''); + expect(dialog.hasAttribute('data-fd-hover')).toBe(false); + + registerAppearanceApplier(applyAppearance); + applyDialogAppearance(overlay, dialog, { + titleColor: '#ff0000', + hover: { titleColor: '#00ff00' }, + }); + expect(dialog.style.getPropertyValue('--fd-dialog-title-color')).toBe('#ff0000'); + expect(dialog.dataset.fdHover).toBe(''); + + registerAppearanceApplier(applyBasicAppearance); + }); + it('allows the draggable factory to be replaced', () => { const custom = vi.fn(makeBasicDraggable); registerDraggableFactory(custom); diff --git a/tsup.config.ts b/tsup.config.ts index 55e0b61..5712a32 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -6,6 +6,10 @@ const shared: Options = { dts: true, sourcemap: true, minify: true, + // Keep every entry self-contained. With ESM splitting on, the capability + // registrations land in shared chunks that `sideEffects` cannot name, so + // bundlers drop the bare import and the capability never registers. + splitting: false, define: { __FORGEDIALOG_VERSION__: JSON.stringify(packageJson.version) }, outExtension({ format }) { if (format === 'esm') return { js: '.mjs' }; @@ -32,6 +36,7 @@ export default defineConfig([ prompt: 'src/prompt-entry.ts', interactions: 'src/interactions-entry.ts', animations: 'src/animations-entry.ts', + appearance: 'src/appearance-entry.ts', presentation: 'src/presentation-entry.ts', workflows: 'src/workflows-entry.ts', react: 'src/adapters/react.ts',