From 9bfcf71a8460edfb96ddef9f45c19b61d7253543 Mon Sep 17 00:00:00 2001 From: Bernardo Gurgel Date: Tue, 31 Mar 2026 20:26:16 +0000 Subject: [PATCH 01/17] feat: add documentation website with Vite + ReScript + Xote Built with xote, rescript-signals, and basefn, following the same architecture as the zekr docs-website. Includes landing page, getting started guide, full API reference pages, examples, search modal, dark/light theme, and responsive design. https://claude.ai/code/session_01B8MgrWxixKEWaxDiHyy8Bx --- website/.gitignore | 5 + website/index.html | 19 + website/package-lock.json | 1288 +++++++++++++++++++ website/package.json | 25 + website/public/favicon.svg | 4 + website/rescript.json | 27 + website/src/App.res | 84 ++ website/src/DocsPage.res | 223 ++++ website/src/Layout.res | 368 ++++++ website/src/Main.res | 6 + website/src/SyntaxHighlight.res | 93 ++ website/src/components/CodeBlock.res | 44 + website/src/pages/Pages__ApiComponents.res | 98 ++ website/src/pages/Pages__ApiCore.res | 181 +++ website/src/pages/Pages__ApiEffects.res | 139 ++ website/src/pages/Pages__ApiInstruments.res | 131 ++ website/src/pages/Pages__ApiScheduling.res | 136 ++ website/src/pages/Pages__ApiSignals.res | 121 ++ website/src/pages/Pages__ApiSources.res | 109 ++ website/src/pages/Pages__Examples.res | 156 +++ website/src/pages/Pages__GettingStarted.res | 117 ++ website/src/pages/Pages__Home.res | 294 +++++ website/src/styles.css | 1194 +++++++++++++++++ website/vite.config.js | 12 + 24 files changed, 4874 insertions(+) create mode 100644 website/.gitignore create mode 100644 website/index.html create mode 100644 website/package-lock.json create mode 100644 website/package.json create mode 100644 website/public/favicon.svg create mode 100644 website/rescript.json create mode 100644 website/src/App.res create mode 100644 website/src/DocsPage.res create mode 100644 website/src/Layout.res create mode 100644 website/src/Main.res create mode 100644 website/src/SyntaxHighlight.res create mode 100644 website/src/components/CodeBlock.res create mode 100644 website/src/pages/Pages__ApiComponents.res create mode 100644 website/src/pages/Pages__ApiCore.res create mode 100644 website/src/pages/Pages__ApiEffects.res create mode 100644 website/src/pages/Pages__ApiInstruments.res create mode 100644 website/src/pages/Pages__ApiScheduling.res create mode 100644 website/src/pages/Pages__ApiSignals.res create mode 100644 website/src/pages/Pages__ApiSources.res create mode 100644 website/src/pages/Pages__Examples.res create mode 100644 website/src/pages/Pages__GettingStarted.res create mode 100644 website/src/pages/Pages__Home.res create mode 100644 website/src/styles.css create mode 100644 website/vite.config.js diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 0000000..b09cab4 --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +*.res.mjs +.vite +lib diff --git a/website/index.html b/website/index.html new file mode 100644 index 0000000..62c6cc2 --- /dev/null +++ b/website/index.html @@ -0,0 +1,19 @@ + + + + + + rescript-tone - ReScript Bindings for Tone.js + + + + + + + + + Skip to content +
+ + + diff --git a/website/package-lock.json b/website/package-lock.json new file mode 100644 index 0000000..44835a7 --- /dev/null +++ b/website/package-lock.json @@ -0,0 +1,1288 @@ +{ + "name": "rescript-tone-docs", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rescript-tone-docs", + "version": "1.0.0", + "dependencies": { + "@rescript/core": "^1.6.1", + "basefn": "^1.9.1", + "highlight.js": "^11.11.1", + "rescript-signals": "^1.3.3", + "xote": "^4.16.1" + }, + "devDependencies": { + "rescript": "^12.0.0", + "vite": "^7.1.12" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rescript/core": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@rescript/core/-/core-1.6.1.tgz", + "integrity": "sha512-vyb5k90ck+65Fgui+5vCja/mUfzKaK3kOPT4Z6aAJdHLH1eljEi1zKhXroCiCtpNLSWp8k4ulh1bdB5WS0hvqA==", + "license": "MIT", + "peerDependencies": { + "rescript": ">=11.1.0" + } + }, + "node_modules/@rescript/darwin-arm64": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/@rescript/darwin-arm64/-/darwin-arm64-12.2.0.tgz", + "integrity": "sha512-xc3K/J7Ujl1vPiFY2009mRf3kWRlUe/VZyJWprseKxlcEtUQv89ter7r6pY+YFbtYvA/fcaEncL9CVGEdattAg==", + "cpu": [ + "arm64" + ], + "license": "(LGPL-3.0-or-later AND MIT)", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.11.0" + } + }, + "node_modules/@rescript/darwin-x64": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/@rescript/darwin-x64/-/darwin-x64-12.2.0.tgz", + "integrity": "sha512-qqcTvnlSeoKkywLjG7cXfYvKZ1e4Gz2kUKcD6SiqDgCqm8TF+spwlFAiM6sloRUOFsc0bpC/0R0B3yr01FCB1A==", + "cpu": [ + "x64" + ], + "license": "(LGPL-3.0-or-later AND MIT)", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.11.0" + } + }, + "node_modules/@rescript/linux-arm64": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/@rescript/linux-arm64/-/linux-arm64-12.2.0.tgz", + "integrity": "sha512-ODmpG3ji+Nj/8d5yvXkeHlfKkmbw1Q4t1iIjVuNwtmFpz7TiEa7n/sQqoYdE+WzbDX3DoJfmJNbp3Ob7qCUoOg==", + "cpu": [ + "arm64" + ], + "license": "(LGPL-3.0-or-later AND MIT)", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.11.0" + } + }, + "node_modules/@rescript/linux-x64": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/@rescript/linux-x64/-/linux-x64-12.2.0.tgz", + "integrity": "sha512-2W9Y9/g19Y4F/subl8yV3T8QBG2oRaP+HciNRcBjptyEdw9LmCKH8+rhWO6sp3E+nZLwoE2IAkwH0WKV3wqlxQ==", + "cpu": [ + "x64" + ], + "license": "(LGPL-3.0-or-later AND MIT)", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.11.0" + } + }, + "node_modules/@rescript/runtime": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/@rescript/runtime/-/runtime-12.2.0.tgz", + "integrity": "sha512-NwfljDRq1rjFPHUaca1nzFz13xsa9ZGkBkLvMhvVgavJT5+A4rMcLu8XAaVTi/oAhO/tlHf9ZDoOTF1AfyAk9Q==", + "license": "MIT" + }, + "node_modules/@rescript/win32-x64": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/@rescript/win32-x64/-/win32-x64-12.2.0.tgz", + "integrity": "sha512-fhf8CBj3p1lkIXPeNko3mVTKQfXXm4BoxJtR1xAXxUn43wDpd8Lox4w8/EPBbbW6C/YFQW6H7rtpY+2AKuNaDA==", + "cpu": [ + "x64" + ], + "license": "(LGPL-3.0-or-later AND MIT)", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.11.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/basefn": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/basefn/-/basefn-1.10.0.tgz", + "integrity": "sha512-yT7L3H6If+riqzpZL4oOF0xTJ2R9CsbnnshVl2MrAVZ9Bw48l4KaUpuQRxHCLem5l8bHjDVSuWrCmEVoFjMFhQ==", + "license": "MIT", + "dependencies": { + "@rescript/core": "^1.6.1", + "highlight.js": "^11.11.1", + "lucide": "^0.562.0" + }, + "peerDependencies": { + "xote": "^4.12.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/lucide": { + "version": "0.562.0", + "resolved": "https://registry.npmjs.org/lucide/-/lucide-0.562.0.tgz", + "integrity": "sha512-k1Fb8ZMnRQovWRlea7Jr0b9UKA29IM7/cu79+mJrhVohvA2YC/Ti3Sk+G+h/SIu3IlrKT4RAbWMHUBBQd1O6XA==", + "license": "ISC" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rescript": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/rescript/-/rescript-12.2.0.tgz", + "integrity": "sha512-1Jf2cmNhyx5Mj2vwZ4XXPcXvNSjGj9D1jPBUcoqIOqRpLPo1ch2Ta/7eWh23xAHWHK5ow7BCDyYFjvZSjyjLzg==", + "license": "(LGPL-3.0-or-later AND MIT)", + "workspaces": [ + "packages/playground", + "packages/@rescript/*", + "tests/dependencies/**", + "tests/analysis_tests/**", + "tests/docstring_tests", + "tests/gentype_tests/**", + "tests/tools_tests", + "tests/commonjs_tests", + "scripts/res" + ], + "dependencies": { + "@rescript/runtime": "12.2.0" + }, + "bin": { + "bsc": "cli/bsc.js", + "bstracing": "cli/bstracing.js", + "rescript": "cli/rescript.js", + "rescript-legacy": "cli/rescript-legacy.js", + "rescript-tools": "cli/rescript-tools.js" + }, + "engines": { + "node": ">=20.11.0" + }, + "optionalDependencies": { + "@rescript/darwin-arm64": "12.2.0", + "@rescript/darwin-x64": "12.2.0", + "@rescript/linux-arm64": "12.2.0", + "@rescript/linux-x64": "12.2.0", + "@rescript/win32-x64": "12.2.0" + } + }, + "node_modules/rescript-signals": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rescript-signals/-/rescript-signals-1.3.4.tgz", + "integrity": "sha512-JvuPULX6c+4r7J+LOzH1zRepOHpiem6kK/gLtzuvfwXj6MTDjBQlWh0xM1VxR7GjGg3sitdR+Bo17QzH67Tx9g==", + "license": "SEE LICENSE IN LICENSE" + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/xote": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/xote/-/xote-4.16.1.tgz", + "integrity": "sha512-6DhZeMqjhKn6S/QQCO1zo83lebF1Wvalj0ejkM0fFDVfO6P86KyotIho7g2r3bg7QURmNlh3zk0rksvFHkG5Yg==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "rescript-signals": "^1.3.3" + } + } + } +} diff --git a/website/package.json b/website/package.json new file mode 100644 index 0000000..7477a42 --- /dev/null +++ b/website/package.json @@ -0,0 +1,25 @@ +{ + "name": "rescript-tone-docs", + "version": "1.0.0", + "type": "module", + "private": true, + "scripts": { + "dev": "rescript -w & vite", + "build": "rescript && vite build", + "preview": "vite preview", + "res:build": "rescript", + "res:watch": "rescript -w", + "res:clean": "rescript clean" + }, + "dependencies": { + "@rescript/core": "^1.6.1", + "basefn": "^1.9.1", + "highlight.js": "^11.11.1", + "rescript-signals": "^1.3.3", + "xote": "^4.16.1" + }, + "devDependencies": { + "rescript": "^12.0.0", + "vite": "^7.1.12" + } +} diff --git a/website/public/favicon.svg b/website/public/favicon.svg new file mode 100644 index 0000000..f98d8c9 --- /dev/null +++ b/website/public/favicon.svg @@ -0,0 +1,4 @@ + + + T + diff --git a/website/rescript.json b/website/rescript.json new file mode 100644 index 0000000..f69b497 --- /dev/null +++ b/website/rescript.json @@ -0,0 +1,27 @@ +{ + "name": "rescript-tone-docs", + "sources": [ + { + "dir": "src", + "subdirs": true + } + ], + "package-specs": { + "module": "esmodule", + "in-source": true + }, + "suffix": ".res.mjs", + "dependencies": [ + "@rescript/core", + "rescript-signals", + "xote", + "basefn" + ], + "compiler-flags": [ + "-open RescriptCore" + ], + "jsx": { + "version": 4, + "module": "Xote__JSX" + } +} diff --git a/website/src/App.res b/website/src/App.res new file mode 100644 index 0000000..c9fe9fe --- /dev/null +++ b/website/src/App.res @@ -0,0 +1,84 @@ +open Xote + +type props = {} + +let make = (_props: props) => { + Router.routes([ + { + pattern: "/", + render: _ => , + }, + { + pattern: "/getting-started", + render: _ => + } + />, + }, + { + pattern: "/api/core", + render: _ => + } + />, + }, + { + pattern: "/api/instruments", + render: _ => + } + />, + }, + { + pattern: "/api/effects", + render: _ => + } + />, + }, + { + pattern: "/api/sources", + render: _ => + } + />, + }, + { + pattern: "/api/components", + render: _ => + } + />, + }, + { + pattern: "/api/signals", + render: _ => + } + />, + }, + { + pattern: "/api/scheduling", + render: _ => + } + />, + }, + { + pattern: "/examples", + render: _ => + } + />, + }, + ]) +} diff --git a/website/src/DocsPage.res b/website/src/DocsPage.res new file mode 100644 index 0000000..aaf7aeb --- /dev/null +++ b/website/src/DocsPage.res @@ -0,0 +1,223 @@ +open Xote + +// ---- Navigation data ---- +type docItem = { + title: string, + path: string, +} + +type docCategory = { + label: string, + items: array, +} + +let docsNav: array = [ + { + label: "Getting Started", + items: [{title: "Installation & Setup", path: "/getting-started"}], + }, + { + label: "API Reference", + items: [ + {title: "Core & Transport", path: "/api/core"}, + {title: "Instruments", path: "/api/instruments"}, + {title: "Sources", path: "/api/sources"}, + {title: "Effects", path: "/api/effects"}, + {title: "Components", path: "/api/components"}, + {title: "Signal & Gain", path: "/api/signals"}, + {title: "Scheduling", path: "/api/scheduling"}, + ], + }, + { + label: "Resources", + items: [ + {title: "Examples", path: "/examples"}, + ], + }, +] + +// Flatten for prev/next +let flatItems = docsNav->Array.flatMap(cat => cat.items) + +// Find prev/next +let getPrevNext = (currentPath: string) => { + let idx = flatItems->Array.findIndex(item => item.path == currentPath) + let prev = if idx > 0 { + flatItems->Array.get(idx - 1) + } else { + None + } + let next = if idx >= 0 && idx < Array.length(flatItems) - 1 { + flatItems->Array.get(idx + 1) + } else { + None + } + (prev, next) +} + +// Find category + title for breadcrumb +let getCategoryAndTitle = (currentPath: string) => { + let result = ref(("", "")) + docsNav->Array.forEach(cat => { + cat.items->Array.forEach(item => { + if item.path == currentPath { + result := (cat.label, item.title) + } + }) + }) + result.contents +} + +// ---- Sidebar ---- +module Sidebar = { + type props = {currentPath: string} + + let make = (props: props) => { + let {currentPath} = props + + } +} + +// ---- Breadcrumb ---- +module DocsBreadcrumb = { + type props = {currentPath: string} + + let make = (props: props) => { + let (category, title) = getCategoryAndTitle(props.currentPath) + + } +} + +// ---- Prev/Next ---- +module PrevNextNav = { + type props = {currentPath: string} + + let make = (props: props) => { + let (prev, next) = getPrevNext(props.currentPath) +
+ {switch prev { + | Some(item) => + Router.link( + ~to=item.path, + ~attrs=[Component.attr("class", "docs-prev-next-link")], + ~children=[ + + {Component.text("\u2190 Previous")} + , + {Component.text(item.title)} , + ], + (), + ) + | None =>
+ }} + {switch next { + | Some(item) => + Router.link( + ~to=item.path, + ~attrs=[Component.attr("class", "docs-prev-next-link next")], + ~children=[ + + {Component.text("Next \u2192")} + , + {Component.text(item.title)} , + ], + (), + ) + | None =>
+ }} +
+ } +} + +// ---- Feedback Widget ---- +module FeedbackWidget = { + type props = {} + + let make = (_props: props) => { + let feedback = Signal.make("") + +
+ {Component.text("Was this page helpful?")} + {Component.element( + "button", + ~attrs=[ + Component.computedAttr("class", () => + "feedback-btn" ++ (Signal.get(feedback) == "yes" ? " selected" : "") + ), + Component.attr("title", "Yes"), + ], + ~events=[("click", _ => Signal.set(feedback, "yes"))], + ~children=[Component.text("\u{1F44D}")], + (), + )} + {Component.element( + "button", + ~attrs=[ + Component.computedAttr("class", () => + "feedback-btn" ++ (Signal.get(feedback) == "no" ? " selected" : "") + ), + Component.attr("title", "No"), + ], + ~events=[("click", _ => Signal.set(feedback, "no"))], + ~children=[Component.text("\u{1F44E}")], + (), + )} +
+ } +} + +// ---- Main docs page component ---- +type props = { + currentPath: string, + content: Component.node, +} + +let make = (props: props) => { + let {currentPath, content} = props + + + +
+ +
{content}
+ + +
+
+ } + /> +} diff --git a/website/src/Layout.res b/website/src/Layout.res new file mode 100644 index 0000000..9fe3569 --- /dev/null +++ b/website/src/Layout.res @@ -0,0 +1,368 @@ +open Xote + +// ---- External bindings ---- +@val external localStorage: {..} = "localStorage" +@val external document: {..} = "document" +@val external window: {..} = "window" + +// ---- Theme ---- +let getInitialTheme = () => { + try { + let stored: string = localStorage["getItem"]("theme") + if stored == "light" { + "light" + } else { + "dark" + } + } catch { + | _ => "dark" + } +} + +let theme = Signal.make(getInitialTheme()) + +let applyTheme = (t: string) => { + let _ = document["documentElement"]["setAttribute"]("data-theme", t) + let _ = localStorage["setItem"]("theme", t) +} + +let toggleTheme = () => { + let next = if Signal.get(theme) == "dark" { + "light" + } else { + "dark" + } + Signal.set(theme, next) + applyTheme(next) +} + +// ---- Search data ---- +type searchItem = { + title: string, + section: string, + path: string, +} + +let searchItems: array = [ + {title: "Installation", section: "Getting Started", path: "/getting-started"}, + {title: "Core & Transport", section: "API Reference", path: "/api/core"}, + {title: "Instruments", section: "API Reference", path: "/api/instruments"}, + {title: "Effects", section: "API Reference", path: "/api/effects"}, + {title: "Sources", section: "API Reference", path: "/api/sources"}, + {title: "Components", section: "API Reference", path: "/api/components"}, + {title: "Signal & Gain", section: "API Reference", path: "/api/signals"}, + {title: "Scheduling", section: "API Reference", path: "/api/scheduling"}, + {title: "Examples", section: "Resources", path: "/examples"}, +] + +// ---- Search modal ---- +let searchOpen = Signal.make(false) +let searchQuery = Signal.make("") +let searchIndex = Signal.make(0) + +let filteredItems = Computed.make(() => { + let q = Signal.get(searchQuery)->String.toLowerCase + if q == "" { + searchItems + } else { + searchItems->Array.filter(item => + item.title->String.toLowerCase->String.includes(q) || + item.section->String.toLowerCase->String.includes(q) + ) + } +}) + +let navigateToResult = () => { + let items = Signal.get(filteredItems) + let idx = Signal.get(searchIndex) + switch items->Array.get(idx) { + | Some(item) => { + Router.push(item.path, ()) + Signal.set(searchOpen, false) + Signal.set(searchQuery, "") + Signal.set(searchIndex, 0) + } + | None => () + } +} + +// ---- Search Modal Component ---- +module SearchModal = { + type props = {} + + let make = (_props: props) => { + let searchContent = Computed.make(() => { + if Signal.get(searchOpen) { + [ +
+
+
+ {Component.element( + "input", + ~attrs=[ + Component.attr("type", "text"), + Component.attr("class", "search-input"), + Component.attr("placeholder", "Search documentation..."), + Component.computedAttr("value", () => Signal.get(searchQuery)), + ], + ~events=[ + ("input", evt => { + let target: {..} = Obj.magic(evt)["target"] + Signal.set(searchQuery, target["value"]) + Signal.set(searchIndex, 0) + }), + ("keydown", evt => { + let key: string = Obj.magic(evt)["key"] + switch key { + | "ArrowDown" => { + let _ = Obj.magic(evt)["preventDefault"]() + let len = Signal.get(filteredItems)->Array.length + let cur = Signal.get(searchIndex) + if cur < len - 1 { + Signal.set(searchIndex, cur + 1) + } + } + | "ArrowUp" => { + let _ = Obj.magic(evt)["preventDefault"]() + let cur = Signal.get(searchIndex) + if cur > 0 { + Signal.set(searchIndex, cur - 1) + } + } + | "Enter" => navigateToResult() + | "Escape" => { + Signal.set(searchOpen, false) + Signal.set(searchQuery, "") + Signal.set(searchIndex, 0) + } + | _ => () + } + }), + ], + (), + )} + {Component.text("ESC")} +
+
+ {Component.signalFragment( + Computed.make(() => { + Signal.get(filteredItems)->Array.mapWithIndex((item, i) => { + Component.element( + "div", + ~attrs=[ + Component.computedAttr("class", () => + "search-result-item" ++ + (Signal.get(searchIndex) == i ? " active" : "") + ), + ], + ~events=[ + ("click", _ => { + Signal.set(searchIndex, i) + navigateToResult() + }), + ], + ~children=[ + + {Component.text(item.section)} + , + + {Component.text(item.title)} + , + ], + (), + ) + }) + }), + )} +
+
+ {Component.element( + "div", + ~attrs=[Component.attr("class", "search-backdrop")], + ~events=[ + ("click", _ => { + Signal.set(searchOpen, false) + Signal.set(searchQuery, "") + Signal.set(searchIndex, 0) + }), + ], + (), + )} +
, + ] + } else { + [] + } + }) + Component.signalFragment(searchContent) + } +} + +// ---- Header ---- +module Header = { + type props = {} + + let make = (_props: props) => { + let themeIcon = Computed.make(() => + if Signal.get(theme) == "dark" { + "Light" + } else { + "Dark" + } + ) + +
+
+
+ {Router.link( + ~to="/", + ~attrs=[Component.attr("class", "header-logo")], + ~children=[Component.text("rescript-tone")], + (), + )} + +
+
+ {Component.element( + "button", + ~attrs=[ + Component.attr("class", "search-trigger"), + Component.attr("title", "Search"), + ], + ~events=[("click", _ => Signal.set(searchOpen, true))], + ~children=[ + {Component.text("Search")} , + {Component.text("\u2318K")} , + ], + (), + )} + {Component.element( + "a", + ~attrs=[ + Component.attr("class", "header-github"), + Component.attr("href", "https://github.com/brnrdog/rescript-tone"), + Component.attr("target", "_blank"), + Component.attr("rel", "noopener noreferrer"), + Component.attr("title", "GitHub"), + ], + ~children=[Component.text("GitHub")], + (), + )} + {Component.element( + "button", + ~attrs=[ + Component.attr("class", "theme-toggle"), + Component.attr("title", "Toggle theme"), + ], + ~events=[("click", _ => toggleTheme())], + ~children=[ + Component.textSignal(() => Signal.get(themeIcon)), + ], + (), + )} +
+
+
+ } +} + +// ---- Footer ---- +module Footer = { + type props = {} + + let make = (_props: props) => { +
+ + +
+ } +} + +// ---- Global keyboard shortcut ---- +let _ = Effect.run(() => { + applyTheme(Signal.get(theme)) + + let handler = evt => { + let e: {..} = Obj.magic(evt) + let key: string = e["key"] + let meta: bool = e["metaKey"] + let ctrl: bool = e["ctrlKey"] + if key == "k" && (meta || ctrl) { + let _ = e["preventDefault"]() + let isOpen = Signal.get(searchOpen) + Signal.set(searchOpen, !isOpen) + if isOpen { + Signal.set(searchQuery, "") + Signal.set(searchIndex, 0) + } + } + } + let _ = document["addEventListener"]("keydown", handler) + None +}) + +// ---- Layout ---- +type props = {children: Component.node} + +let make = (props: props) => { + <> +
+ +
{props.children}
+
+ +} diff --git a/website/src/Main.res b/website/src/Main.res new file mode 100644 index 0000000..eb48d85 --- /dev/null +++ b/website/src/Main.res @@ -0,0 +1,6 @@ +open Xote + +%%raw(`import './styles.css'`) + +Router.init(~basePath="/rescript-tone", ()) +Component.mountById(, "app") diff --git a/website/src/SyntaxHighlight.res b/website/src/SyntaxHighlight.res new file mode 100644 index 0000000..47e4c3c --- /dev/null +++ b/website/src/SyntaxHighlight.res @@ -0,0 +1,93 @@ +open Xote + +let keywords = [ + "let", + "type", + "module", + "open", + "switch", + "if", + "else", + "true", + "false", + "and", + "or", + "rec", + "external", + "include", + "when", + "async", + "await", +] + +let types = ["int", "string", "bool", "float", "array", "option", "unit", "promise"] + +let highlightToken = (token: string) => { + if keywords->Array.includes(token) { + {Component.text(token)} + } else if types->Array.includes(token) { + {Component.text(token)} + } else if token == "=>" || token == "->" || token == "|>" || token == "==" || token == "!=" { + {Component.text(token)} + } else { + Component.text(token) + } +} + +let highlightLine = (line: string) => { + let trimmed = line->String.trim + if trimmed->String.startsWith("//") { + {Component.text(line)} + } else { + // Check for strings + let parts = line->String.split("\"") + if Array.length(parts) > 1 { + Component.fragment( + parts->Array.mapWithIndex((part, i) => { + if mod(i, 2) == 1 { + + {Component.text("\"" ++ part ++ "\"")} + + } else { + let words = part->String.split(" ") + Component.fragment( + words->Array.mapWithIndex((word, j) => { + let separator = if j > 0 { + Component.text(" ") + } else { + Component.text("") + } + Component.fragment([separator, highlightToken(word)]) + }), + ) + } + }), + ) + } else { + let words = line->String.split(" ") + Component.fragment( + words->Array.mapWithIndex((word, i) => { + let separator = if i > 0 { + Component.text(" ") + } else { + Component.text("") + } + Component.fragment([separator, highlightToken(word)]) + }), + ) + } + } +} + +let highlight = (code: string) => { + let lines = code->String.split("\n") + Component.fragment( + lines->Array.mapWithIndex((line, i) => { + let lineNum = Int.toString(i + 1) +
+ {Component.text(lineNum)} + {highlightLine(line)} +
+ }), + ) +} diff --git a/website/src/components/CodeBlock.res b/website/src/components/CodeBlock.res new file mode 100644 index 0000000..099e3b4 --- /dev/null +++ b/website/src/components/CodeBlock.res @@ -0,0 +1,44 @@ +open Xote + +@val external navigator: {..} = "navigator" +@val external setTimeout: (unit => unit, int) => unit = "setTimeout" + +type props = {code: string} + +let make = (props: props) => { + let copied = Signal.make(false) + + let copyCode = () => { + let _ = navigator["clipboard"]["writeText"](props.code) + Signal.set(copied, true) + setTimeout(() => Signal.set(copied, false), 2000) + } + +
+
+ {Component.element( + "button", + ~attrs=[ + Component.attr("class", "code-block-copy"), + Component.attr("title", "Copy to clipboard"), + ], + ~events=[("click", _ => copyCode())], + ~children=[ + Component.textSignal(() => + if Signal.get(copied) { + "Copied!" + } else { + "Copy" + } + ), + ], + (), + )} +
+
+      
+        {SyntaxHighlight.highlight(props.code)}
+      
+    
+
+} diff --git a/website/src/pages/Pages__ApiComponents.res b/website/src/pages/Pages__ApiComponents.res new file mode 100644 index 0000000..606e7fe --- /dev/null +++ b/website/src/pages/Pages__ApiComponents.res @@ -0,0 +1,98 @@ +open Xote + +type props = {} + +let make = (_props: props) => { +
+

{Component.text("Components")}

+

+ {Component.text( + "Components are audio-processing nodes for dynamics, filtering, and routing.", + )} +

+ +

{Component.text("Compressor")}

+ Compressor.threshold // Param.t +comp->Compressor.ratio // Param.t +comp->Compressor.attack // Param.t +comp->Compressor.release // Param.t +comp->Compressor.knee // Param.t +comp->Compressor.reduction // decibels`} + /> + +

{Component.text("Limiter")}

+ Limiter.threshold // Param.t +limiter->Limiter.reduction // decibels`} + /> + +

{Component.text("Gate")}

+ Gate.threshold // decibels +gate->Gate.smoothing // float`} + /> + +

{Component.text("Filter")}

+ Filter.frequency // Param.t +filter->Filter.detune // Param.t +filter->Filter.gain // Param.t +filter->Filter.q // Param.t +filter->Filter.getType() // filterType +filter->Filter.setType("highpass") +filter->Filter.rolloff // int`} + /> + +

{Component.text("EQ3")}

+ EQ3.low // Param.t +eq->EQ3.mid // Param.t +eq->EQ3.high // Param.t`} + /> + +

{Component.text("Panner")}

+ Panner.pan // Param.t`} + /> +
+} diff --git a/website/src/pages/Pages__ApiCore.res b/website/src/pages/Pages__ApiCore.res new file mode 100644 index 0000000..62c6381 --- /dev/null +++ b/website/src/pages/Pages__ApiCore.res @@ -0,0 +1,181 @@ +open Xote + +type props = {} + +let make = (_props: props) => { +
+

{Component.text("Core & Transport")}

+ +

{Component.text("Core")}

+

+ {Component.text( + "The Core module provides top-level Tone.js functions for managing the audio context.", + )} +

+ +

{Component.text("Functions")}

+ + +// Get the current audio time +Core.now() // seconds + +// Get the most recent audio time +Core.immediate() // seconds + +// Get references to global singletons +Core.getContext() // Context.t +Core.getTransport() // Transport.t +Core.getDestination() // Destination.t + +// Wait for all audio buffers to load +Core.loaded() // promise + +// Check Web Audio API support +Core.supported() // bool + +// Get the Tone.js version string +Core.version // string`} + /> + +

{Component.text("Context")}

+

+ {Component.text( + "The Context wraps the native Web Audio AudioContext and provides additional features.", + )} +

+ Context.currentTime // seconds +ctx->Context.state // audioContextState +ctx->Context.sampleRate // float +ctx->Context.lookAhead // seconds +ctx->Context.setLookAhead(0.1) +ctx->Context.latencyHint // string + +// Lifecycle +ctx->Context.resume() // promise +ctx->Context.close() // promise +ctx->Context.dispose()`} + /> + +

{Component.text("Transport")}

+

+ {Component.text( + "The Transport is Tone.js's main timekeeper. It provides scheduling, tempo, time signature, and looping.", + )} +

+ +

{Component.text("Playback")}

+ Transport.start() +transport->Transport.startAt(~time="0") +transport->Transport.stop() +transport->Transport.pause() +transport->Transport.toggle() +transport->Transport.cancel()`} + /> + +

{Component.text("Scheduling")}

+ Transport.schedule(time => { + Console.log2("event at", time) +}, "0:0:0") + +// Schedule a repeating event +let id2 = transport->Transport.scheduleRepeat(time => { + Console.log2("repeat at", time) +}, "4n") + +// Schedule a one-time event that auto-clears +let _ = transport->Transport.scheduleOnce(_time => { + Console.log("once!") +}, "1m") + +// Clear a scheduled event +transport->Transport.clear(id)`} + /> + +

{Component.text("Tempo & Time")}

+ Transport.bpm + +// Position +transport->Transport.position // "0:0:0" +transport->Transport.setPosition("1:0:0") +transport->Transport.seconds // float +transport->Transport.ticks // int +transport->Transport.state // "started" | "stopped" | "paused" + +// Time signature +transport->Transport.timeSignature // int +transport->Transport.setTimeSignature(4) + +// Swing +transport->Transport.swing // normalRange +transport->Transport.setSwing(0.5)`} + /> + +

{Component.text("Looping")}

+ Transport.setLoop(true) +transport->Transport.setLoopStart("0:0:0") +transport->Transport.setLoopEnd("4:0:0") + +// Or set both at once +transport->Transport.setLoopPoints("0:0:0", "4:0:0")`} + /> + +

{Component.text("Destination")}

+

+ {Component.text( + "Destination represents the master output. All audio is routed here by default.", + )} +

+ Destination.volume // Param.t +dest->Destination.mute // bool +dest->Destination.setMute(true)`} + /> + +

{Component.text("AudioNode")}

+

+ {Component.text( + "AudioNode is the base type for all audio-producing and audio-processing nodes.", + )} +

+ AudioNode.connect(nodeB) +nodeA->AudioNode.disconnect() +nodeA->AudioNode.disconnectFrom(nodeB) + +// Connect to the speakers +node->AudioNode.toDestination() + +// Chain multiple nodes: A -> B -> C -> ... +nodeA->AudioNode.chain([nodeB, nodeC]) + +// Fan out: A -> B, A -> C +nodeA->AudioNode.fan([nodeB, nodeC]) + +// Properties +node->AudioNode.numberOfInputs // int +node->AudioNode.numberOfOutputs // int +node->AudioNode.channelCount // int + +// Cleanup +node->AudioNode.dispose()`} + /> +
+} diff --git a/website/src/pages/Pages__ApiEffects.res b/website/src/pages/Pages__ApiEffects.res new file mode 100644 index 0000000..65a7324 --- /dev/null +++ b/website/src/pages/Pages__ApiEffects.res @@ -0,0 +1,139 @@ +open Xote + +type props = {} + +let make = (_props: props) => { +
+

{Component.text("Effects")}

+

+ {Component.text( + "Effects process audio signals. Connect them between a source and the destination to modify the sound. All effects have a wet property to control the mix.", + )} +

+ +

{Component.text("Reverb")}

+ Reverb.decay // time +reverb->Reverb.setDecay(4.0) +reverb->Reverb.preDelay // time +reverb->Reverb.wet // Param.t +reverb->Reverb.ready // promise +reverb->Reverb.generate() // promise`} + /> + +

{Component.text("Freeverb")}

+ Freeverb.roomSize // Param.t +verb->Freeverb.dampening // Param.t`} + /> + +

{Component.text("FeedbackDelay")}

+ FeedbackDelay.delayTime // Param.t +delay->FeedbackDelay.feedback // Param.t`} + /> + +

{Component.text("PingPongDelay")}

+ + +

{Component.text("Chorus")}

+ Chorus.frequency // Param.t +chorus->Chorus.delayTime // float +chorus->Chorus.depth // float +chorus->Chorus.start()`} + /> + +

{Component.text("Distortion")}

+ Distortion.distortion // float +dist->Distortion.setDistortion(0.5)`} + /> + +

{Component.text("Phaser")}

+ + +

{Component.text("Tremolo & Vibrato")}

+ Tremolo.start() +trem->Tremolo.frequency // Param.t +trem->Tremolo.depth // Param.t + +let vib = Vibrato.make() +vib->Vibrato.frequency // Param.t +vib->Vibrato.depth // Param.t`} + /> + +

{Component.text("Other Effects")}

+

+ {Component.text( + "Additional effects include AutoFilter, AutoPanner, AutoWah, BitCrusher, Chebyshev, FrequencyShifter, JCReverb, PitchShift, and StereoWidener. They all follow the same pattern:", + )} +

+ Synth.asAudioNode +->AudioNode.connect(effect->SomeEffect.asAudioNode) +->AudioNode.toDestination + +// Control wet/dry mix +effect->SomeEffect.wet // Param.t + +// Cleanup +effect->SomeEffect.dispose()`} + /> +
+} diff --git a/website/src/pages/Pages__ApiInstruments.res b/website/src/pages/Pages__ApiInstruments.res new file mode 100644 index 0000000..3dd8973 --- /dev/null +++ b/website/src/pages/Pages__ApiInstruments.res @@ -0,0 +1,131 @@ +open Xote + +type props = {} + +let make = (_props: props) => { +
+

{Component.text("Instruments")}

+

+ {Component.text( + "Instruments are sound sources that can be triggered with notes. All instruments share a common interface for triggering attacks and releases.", + )} +

+ +

{Component.text("Synth")}

+

+ {Component.text( + "A basic synthesizer with an oscillator and an ADSR envelope.", + )} +

+ Synth.asAudioNode->AudioNode.toDestination + +// Trigger a note +synth->Synth.triggerAttack("C4") +synth->Synth.triggerRelease() + +// Trigger attack and release in one call +synth->Synth.triggerAttackRelease("C4", "8n") + +// With time and velocity +synth->Synth.triggerAttackReleaseAtVel("E4", "4n", ~velocity=0.8) + +// Properties +synth->Synth.volume // Param.t +synth->Synth.frequency // Param.t +synth->Synth.detune // Param.t + +// Lifecycle +synth->Synth.dispose() +synth->Synth.sync() +synth->Synth.unsync()`} + /> + +

{Component.text("FMSynth")}

+

+ {Component.text( + "A frequency modulation synthesizer. Same trigger interface as Synth, plus modulation controls.", + )} +

+ FMSynth.asAudioNode->AudioNode.toDestination +fm->FMSynth.triggerAttackRelease("A3", "2n") + +// FM-specific properties +fm->FMSynth.harmonicity // Param.t +fm->FMSynth.modulationIndex // Param.t`} + /> + +

{Component.text("AMSynth")}

+

+ {Component.text( + "An amplitude modulation synthesizer.", + )} +

+ AMSynth.asAudioNode->AudioNode.toDestination +am->AMSynth.triggerAttackRelease("G3", "4n") + +am->AMSynth.harmonicity // Param.t`} + /> + +

{Component.text("MonoSynth")}

+

+ {Component.text( + "A monophonic synthesizer with one oscillator, a filter, and two envelopes.", + )} +

+ MonoSynth.asAudioNode->AudioNode.toDestination +mono->MonoSynth.triggerAttackRelease("D3", "8n")`} + /> + +

{Component.text("PolySynth")}

+

+ {Component.text( + "A polyphonic synthesizer that can play multiple notes simultaneously.", + )} +

+ PolySynth.asAudioNode->AudioNode.toDestination + +// Play a chord +poly->PolySynth.triggerAttack(["C4", "E4", "G4"]) +poly->PolySynth.triggerRelease(["C4", "E4", "G4"]) + +poly->PolySynth.triggerAttackRelease(["C4", "E4", "G4"], "2n") + +// Set max polyphony +poly->PolySynth.maxPolyphony // int +poly->PolySynth.releaseAll()`} + /> +
+} diff --git a/website/src/pages/Pages__ApiScheduling.res b/website/src/pages/Pages__ApiScheduling.res new file mode 100644 index 0000000..67a1d55 --- /dev/null +++ b/website/src/pages/Pages__ApiScheduling.res @@ -0,0 +1,136 @@ +open Xote + +type props = {} + +let make = (_props: props) => { +
+

{Component.text("Scheduling")}

+

+ {Component.text( + "Scheduling modules let you trigger events over time, synced to the Transport.", + )} +

+ +

{Component.text("Loop")}

+

+ {Component.text( + "A Loop calls a callback at a regular interval.", + )} +

+ { + synth->Synth.triggerAttackRelease("C4", "8n") +}, "4n") + +let loop2 = Loop.makeWithOptions({ + callback: time => Console.log(time), + interval: "4n", + iterations: 8, // stop after 8 repeats + probability: 0.9, // 90% chance each iteration fires + humanize: true, +}) + +loop->Loop.start() +loop->Loop.stop() +loop->Loop.cancel() + +// Properties +loop->Loop.state // "started" | "stopped" +loop->Loop.progress // normalRange +loop->Loop.interval // time +loop->Loop.setInterval("8n") +loop->Loop.playbackRate // positive +loop->Loop.setPlaybackRate(2.0) +loop->Loop.iterations // int +loop->Loop.setIterations(16) +loop->Loop.mute // bool +loop->Loop.setMute(true) +loop->Loop.humanize // bool +loop->Loop.probability // normalRange`} + /> + +

{Component.text("Event")}

+

+ {Component.text( + "An Event triggers a callback on a schedule. Similar to Loop but for single occurrences.", + )} +

+ { + synth->Synth.triggerAttackRelease("E4", "8n") +}) + +let event2 = Event.makeWithOptions({ + callback: _time => Console.log("event!"), + loop: true, + loopStart: "0", + loopEnd: "2m", + playbackRate: 1.0, +}) + +event->Event.start("0") +event->Event.stop("4m") +event->Event.cancel() + +event->Event.state // playbackState +event->Event.progress // normalRange +event->Event.loop // bool +event->Event.setLoop(true) +event->Event.mute // bool +event->Event.probability // normalRange`} + /> + +

{Component.text("Part")}

+

+ {Component.text( + "A Part schedules an array of events along a timeline.", + )} +

+ { + synth->Synth.triggerAttackRelease(note, "8n") +}, [ + {"time": "0:0:0", "note": "C4"}, + {"time": "0:1:0", "note": "E4"}, + {"time": "0:2:0", "note": "G4"}, +]) + +part->Part.start("0") +part->Part.stop() + +part->Part.loop // bool +part->Part.setLoop(true) +part->Part.loopStart // time +part->Part.loopEnd // time +part->Part.playbackRate // positive +part->Part.length // int + +// Add/remove events +part->Part.add("0:3:0", "B4") +part->Part.remove("0:1:0", "E4") +part->Part.clear()`} + /> + +

{Component.text("Sequence")}

+

+ {Component.text( + "A Sequence plays an array of values at a given subdivision.", + )} +

+ { + synth->Synth.triggerAttackRelease(note, "8n") +}, ["C4", "E4", "G4", "B4"], "4n") + +seq->Sequence.start("0") +seq->Sequence.stop() + +seq->Sequence.loop // bool +seq->Sequence.setLoop(true) +seq->Sequence.subdivision // time +seq->Sequence.events // array +seq->Sequence.playbackRate // positive`} + /> +
+} diff --git a/website/src/pages/Pages__ApiSignals.res b/website/src/pages/Pages__ApiSignals.res new file mode 100644 index 0000000..31df82c --- /dev/null +++ b/website/src/pages/Pages__ApiSignals.res @@ -0,0 +1,121 @@ +open Xote + +type props = {} + +let make = (_props: props) => { +
+

{Component.text("Signal & Gain")}

+

+ {Component.text( + "Signal, Param, Gain, Volume, and Channel provide low-level control over audio signal flow and parameter automation.", + )} +

+ +

{Component.text("Param")}

+

+ {Component.text( + "Param represents an automatable audio parameter. Many properties on instruments, effects, and components return Param.t values.", + )} +

+ Synth.volume // Param.t + +// Get/set value +vol->Param.getValue() // float +vol->Param.setValue(-12.0) + +// Automation +vol->Param.linearRampTo(-6.0, "1m") +vol->Param.exponentialRampTo(0.0, "2m") +vol->Param.rampTo(-3.0, "500ms") +vol->Param.setValueAtTime(-12.0, "0:0:0") + +// Advanced scheduling +vol->Param.linearRampToValueAtTime(0.0, "4m") +vol->Param.exponentialRampToValueAtTime(-6.0, "2m") +vol->Param.setTargetAtTime(-12.0, "0", 0.5) +vol->Param.cancelScheduledValues("0") +vol->Param.cancelAndHoldAtTime("1m") +vol->Param.setRampPoint("0")`} + /> + +

{Component.text("Signal")}

+

+ {Component.text( + "A schedulable signal value. Like Param but can be connected to other nodes.", + )} +

+ Signal.getValue() // float +sig->Signal.setValue(0.75) +sig->Signal.maxValue // float +sig->Signal.minValue // float + +// Same scheduling as Param +sig->Signal.linearRampTo(1.0, "1m") +sig->Signal.rampTo(0.5, "2m") + +// Connect to other nodes +sig->Signal.connect(node->AudioNode.t) + +// Cast to Param or AudioNode +sig->Signal.asParam // Param.t +sig->Signal.asAudioNode // AudioNode.t`} + /> + +

{Component.text("Gain")}

+ Gain.gain // Param.t`} + /> + +

{Component.text("Volume")}

+ Volume.volume // Param.t +vol->Volume.mute // bool +vol->Volume.setMute(true)`} + /> + +

{Component.text("Channel")}

+ Channel.volume // Param.t +ch->Channel.pan // Param.t +ch->Channel.mute // bool +ch->Channel.solo // bool +ch->Channel.send("bus-name", -12.0) // returns Gain.t +ch->Channel.receive("bus-name")`} + /> + +

{Component.text("CrossFade")}

+ CrossFade.fade // Param.t +xfade->CrossFade.a // Gain.t +xfade->CrossFade.b // Gain.t`} + /> +
+} diff --git a/website/src/pages/Pages__ApiSources.res b/website/src/pages/Pages__ApiSources.res new file mode 100644 index 0000000..ed62401 --- /dev/null +++ b/website/src/pages/Pages__ApiSources.res @@ -0,0 +1,109 @@ +open Xote + +type props = {} + +let make = (_props: props) => { +
+

{Component.text("Sources")}

+

+ {Component.text( + "Sources are audio-producing nodes that generate or play back sound. Unlike instruments, they don't respond to note triggers.", + )} +

+ +

{Component.text("Oscillator")}

+

+ {Component.text( + "Generates a periodic waveform. Supports sine, square, sawtooth, and triangle types.", + )} +

+ Oscillator.asAudioNode->AudioNode.toDestination + +// Start/stop +osc->Oscillator.start() +osc->Oscillator.stop() +osc->Oscillator.restart() + +// Properties +osc->Oscillator.frequency // Param.t +osc->Oscillator.detune // Param.t +osc->Oscillator.volume // Param.t +osc->Oscillator.getType() // oscillatorType +osc->Oscillator.setType("square") +osc->Oscillator.phase // degrees +osc->Oscillator.partialCount // int +osc->Oscillator.partials // array + +// Frequency sync +osc->Oscillator.syncFrequency() +osc->Oscillator.unsyncFrequency()`} + /> + +

{Component.text("Player")}

+

+ {Component.text( + "Plays an audio file. Supports loading from URL and various playback controls.", + )} +

+ Player.asAudioNode->AudioNode.toDestination + +// Playback +player->Player.start() +player->Player.stop() +player->Player.restart() +player->Player.seek("0:2:0") + +// Properties +player->Player.loop // bool +player->Player.setLoop(true) +player->Player.playbackRate // Param.t +player->Player.reverse // bool +player->Player.setReverse(true) +player->Player.loaded // bool +player->Player.buffer // ToneAudioBuffer.t`} + /> + +

{Component.text("Noise")}

+

+ {Component.text( + "Generates noise. Supports white, pink, and brown noise types.", + )} +

+ Noise.asAudioNode->AudioNode.toDestination +noise->Noise.start() +noise->Noise.stop() + +noise->Noise.getType() // "white" | "pink" | "brown" +noise->Noise.setType("pink")`} + /> +
+} diff --git a/website/src/pages/Pages__Examples.res b/website/src/pages/Pages__Examples.res new file mode 100644 index 0000000..0eb5ac1 --- /dev/null +++ b/website/src/pages/Pages__Examples.res @@ -0,0 +1,156 @@ +open Xote + +type props = {} + +let make = (_props: props) => { +
+

{Component.text("Examples")}

+ +

{Component.text("Simple Melody")}

+

+ {Component.text( + "Play a sequence of notes using Sequence and the Transport.", + )} +

+ Synth.asAudioNode->AudioNode.toDestination + +let notes = ["C4", "D4", "E4", "F4", "G4", "A4", "B4", "C5"] + +let _seq = Sequence.make((_time, note) => { + synth->Synth.triggerAttackRelease(note, "8n") +}, notes, "4n") + +let startButton = async () => { + await Core.start() + let transport = Core.getTransport() + transport->Transport.start() +}`} + /> + +

{Component.text("Effects Chain")}

+

+ {Component.text( + "Build a signal chain with multiple effects.", + )} +

+ Chorus -> Delay -> Reverb -> Speakers +synth->FMSynth.asAudioNode->AudioNode.chain([ + chorus->Chorus.asAudioNode, + delay->FeedbackDelay.asAudioNode, + reverb->Reverb.asAudioNode, +]) +reverb->Reverb.asAudioNode->AudioNode.toDestination + +chorus->Chorus.start()`} + /> + +

{Component.text("Drum Pattern")}

+

+ {Component.text( + "Use Loop and Transport to create a repeating drum pattern.", + )} +

+ Synth.asAudioNode->AudioNode.toDestination + +let hihat = Noise.makeWithOptions({ + \"type": "white", + volume: -20.0, +}) +let hihatEnv = Volume.makeWithVolume(-20.0) +hihat->Noise.asAudioNode +->AudioNode.connect(hihatEnv->Volume.asAudioNode) +->AudioNode.toDestination + +let transport = Core.getTransport() + +// Kick on every beat +let _kickLoop = Loop.make(_time => { + kick->Synth.triggerAttackRelease("C1", "8n") +}, "4n") + +// Hi-hat on every eighth note +let _hatLoop = Loop.make(_time => { + hihat->Noise.start() + hihat->Noise.stop(~time="+32n") +}, "8n") + +transport->Transport.start()`} + /> + +

{Component.text("Parameter Automation")}

+

+ {Component.text( + "Automate parameters over time for expressive control.", + )} +

+ Oscillator.asAudioNode +->AudioNode.connect(filter->Filter.asAudioNode) +->AudioNode.toDestination + +// Automate filter cutoff +let cutoff = filter->Filter.frequency +cutoff->Param.rampTo(2000.0, "2m") + +// Automate volume +let vol = osc->Oscillator.volume +vol->Param.setValueAtTime(-20.0, "0") +vol->Param.linearRampToValueAtTime(-6.0, "1m") +vol->Param.exponentialRampToValueAtTime(-20.0, "2m") + +osc->Oscillator.start()`} + /> +
+} diff --git a/website/src/pages/Pages__GettingStarted.res b/website/src/pages/Pages__GettingStarted.res new file mode 100644 index 0000000..38a60e6 --- /dev/null +++ b/website/src/pages/Pages__GettingStarted.res @@ -0,0 +1,117 @@ +open Xote + +type props = {} + +let make = (_props: props) => { +
+

{Component.text("Getting Started")}

+ +

{Component.text("Installation")}

+

+ {Component.text( + "Install rescript-tone and its peer dependency tone via your package manager:", + )} +

+ +

{Component.text("Or with yarn:")}

+ + +

{Component.text("ReScript Configuration")}

+

+ {Component.text( + "Add rescript-tone to your bs-dependencies in rescript.json:", + )} +

+ + +

{Component.text("Your First Sound")}

+

+ {Component.text( + "All Tone.js modules are available under the Tone namespace. Here's how to play a simple note:", + )} +

+ Synth.asAudioNode->AudioNode.toDestination + +// Tone.js requires a user gesture to start audio +let startAudio = async () => { + await Core.start() + synth->Synth.triggerAttackRelease("C4", "8n") +}`} + /> + +
+
{Component.text("Note")}
+

+ {Component.text( + "Browsers require a user interaction (click, tap) before audio can play. Always call Core.start() inside an event handler.", + )} +

+
+ +

{Component.text("Core Concepts")}

+ +

{Component.text("Audio Nodes")}

+

+ {Component.text( + "Every sound-producing or sound-modifying module in Tone.js is an AudioNode. Nodes can be connected together to form a signal chain:", + )} +

+ Reverb -> Destination +let synth = Synth.make() +let reverb = Reverb.make() + +synth->Synth.asAudioNode +->AudioNode.connect(reverb->Reverb.asAudioNode) +->AudioNode.toDestination`} + /> + +

{Component.text("The asAudioNode Pattern")}

+

+ {Component.text( + "Each module provides an asAudioNode function to cast its type to AudioNode.t. This is the common interface for connecting, disconnecting, and routing audio:", + )} +

+ Synth.asAudioNode->AudioNode.connect(...) +reverb->Reverb.asAudioNode->AudioNode.connect(...) +delay->FeedbackDelay.asAudioNode->AudioNode.connect(...)`} + /> + +

{Component.text("Transport")}

+

+ {Component.text( + "The Transport is Tone.js's main timekeeper. Use it to schedule events, set tempo, and control playback:", + )} +

+ { + synth->Synth.triggerAttackRelease("C4", "8n") +}, ~time="0")`} + /> + +

{Component.text("Requirements")}

+
    +
  • {Component.text("ReScript >= 12.0.0")}
  • +
  • {Component.text("Tone.js >= 15.0.0")}
  • +
  • {Component.text("A browser with Web Audio API support")}
  • +
+
+} diff --git a/website/src/pages/Pages__Home.res b/website/src/pages/Pages__Home.res new file mode 100644 index 0000000..b587d1b --- /dev/null +++ b/website/src/pages/Pages__Home.res @@ -0,0 +1,294 @@ +open Xote + +// ---- Clipboard binding ---- +@val external navigator: {..} = "navigator" +@val external setTimeout: (unit => unit, int) => unit = "setTimeout" + +// ---- Feature data ---- +type feature = { + icon: string, + title: string, + description: string, +} + +let features: array = [ + { + icon: "\u{1F3B9}", + title: "Full Tone.js Coverage", + description: "Bindings for synths, effects, sources, scheduling, and more.", + }, + { + icon: "\u{1F512}", + title: "Type-Safe by Default", + description: "Catch errors at compile time with ReScript's powerful type system.", + }, + { + icon: "\u{1F3A7}", + title: "Web Audio Made Easy", + description: "Create interactive music in the browser with a simple, expressive API.", + }, + { + icon: "\u26A1", + title: "Zero Runtime Overhead", + description: "Direct bindings to Tone.js with no wrapper layer or performance cost.", + }, + { + icon: "\u{1F4E6}", + title: "Modular Design", + description: "Import only what you need. Each module maps directly to Tone.js classes.", + }, + { + icon: "\u{1F680}", + title: "Easy to Get Started", + description: "Add to your ReScript project in minutes. Works with existing Tone.js knowledge.", + }, +] + +// ---- Feature Card ---- +module FeatureCard = { + type props = {feature: feature} + + let make = (props: props) => { + let {feature} = props +
+
{Component.text(feature.icon)}
+

{Component.text(feature.title)}

+

{Component.text(feature.description)}

+
+ } +} + +// ---- Hero ---- +module Hero = { + type props = {} + + let make = (_props: props) => { +
+
+
+

+ {Component.text("rescript-tone")} +

+

+ {Component.text( + "Type-safe ReScript bindings for Tone.js. Build interactive music and audio applications in the browser.", + )} +

+
+ {Router.link( + ~to="/getting-started", + ~attrs=[Component.attr("class", "btn btn-primary")], + ~children=[Component.text("Get Started")], + (), + )} + {Router.link( + ~to="/api/core", + ~attrs=[Component.attr("class", "btn btn-secondary")], + ~children=[Component.text("API Reference")], + (), + )} +
+
+ {Component.text("npm install rescript-tone tone")} +
+
+
+ } +} + +// ---- Features Grid ---- +module Features = { + type props = {} + + let make = (_props: props) => { +
+

{Component.text("Why rescript-tone?")}

+
+ {Component.fragment( + features->Array.map(f => ), + )} +
+
+ } +} + +// ---- Code Demo ---- +module CodeDemo = { + type props = {} + + let make = (_props: props) => { + let activeTab = Signal.make("synth") + let copied = Signal.make(false) + + let synthCode = `open Tone + +let synth = Synth.make() +synth->Synth.asAudioNode->AudioNode.toDestination + +// Play a note +synth->Synth.triggerAttackRelease("C4", "8n")` + + let effectsCode = `open Tone + +let synth = FMSynth.make() +let reverb = Reverb.makeWithOptions({decay: 2.5}) +let delay = FeedbackDelay.makeWithOptions({ + delayTime: "8n", + feedback: 0.3, +}) + +synth->FMSynth.asAudioNode +->AudioNode.connect(reverb->Reverb.asAudioNode) +->AudioNode.connect(delay->FeedbackDelay.asAudioNode) +->AudioNode.toDestination` + + let schedulingCode = `open Tone + +let synth = Synth.make() +synth->Synth.asAudioNode->AudioNode.toDestination + +// Schedule a repeating pattern +let _loop = Loop.makeWithCallback(~callback=_time => { + synth->Synth.triggerAttackRelease("C4", "8n") +}, ~interval="4n") + +Transport.start()` + + let getCode = () => { + switch Signal.get(activeTab) { + | "effects" => effectsCode + | "scheduling" => schedulingCode + | _ => synthCode + } + } + + let copyToClipboard = () => { + let code = getCode() + let _ = navigator["clipboard"]["writeText"](code) + Signal.set(copied, true) + setTimeout(() => Signal.set(copied, false), 2000) + } + +
+

{Component.text("Expressive & Type-Safe")}

+
+
+ {Component.element( + "button", + ~attrs=[ + Component.computedAttr("class", () => + "code-tab" ++ (Signal.get(activeTab) == "synth" ? " active" : "") + ), + ], + ~events=[("click", _ => Signal.set(activeTab, "synth"))], + ~children=[Component.text("Synth")], + (), + )} + {Component.element( + "button", + ~attrs=[ + Component.computedAttr("class", () => + "code-tab" ++ (Signal.get(activeTab) == "effects" ? " active" : "") + ), + ], + ~events=[("click", _ => Signal.set(activeTab, "effects"))], + ~children=[Component.text("Effects Chain")], + (), + )} + {Component.element( + "button", + ~attrs=[ + Component.computedAttr("class", () => + "code-tab" ++ (Signal.get(activeTab) == "scheduling" ? " active" : "") + ), + ], + ~events=[("click", _ => Signal.set(activeTab, "scheduling"))], + ~children=[Component.text("Scheduling")], + (), + )} + {Component.element( + "button", + ~attrs=[ + Component.attr("class", "code-copy-btn"), + Component.attr("title", "Copy code"), + ], + ~events=[("click", _ => copyToClipboard())], + ~children=[ + Component.textSignal(() => + if Signal.get(copied) { + "Copied!" + } else { + "Copy" + } + ), + ], + (), + )} +
+
+ {Component.signalFragment( + Computed.make(() => []) + )} +
+
+
+ } +} + +// ---- Community CTA ---- +module Community = { + type props = {} + + let make = (_props: props) => { +
+

{Component.text("Start Building")}

+

+ {Component.text( + "Add rescript-tone to your project and start creating audio experiences with the safety of ReScript.", + )} +

+ +
+ } +} + +// ---- Page ---- +type props = {} + +let make = (_props: props) => { + + + + + + +} diff --git a/website/src/styles.css b/website/src/styles.css new file mode 100644 index 0000000..429abf6 --- /dev/null +++ b/website/src/styles.css @@ -0,0 +1,1194 @@ +/* ======================================== + Design Tokens + ======================================== */ + +:root { + /* Orange color ramp */ + --orange-50: #fef6f0; + --orange-100: #fde8d4; + --orange-200: #fad0a8; + --orange-300: #f5b070; + --orange-400: #e8893d; + --orange-500: #c47535; + --orange-600: #a8612b; + --orange-700: #8a4f24; + --orange-800: #6b3d1d; + --orange-900: #4a2b14; + --orange-950: #2e1a0b; + + /* Layout */ + --max-width: 1400px; + --sidebar-width: 260px; + --content-max-width: 720px; + --header-height: 56px; + + /* Spacing */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-10: 40px; + --space-12: 48px; + --space-16: 64px; + --space-20: 80px; + + /* Typography */ + --font-display: "Instrument Serif", serif; + --font-body: "DM Sans", system-ui, -apple-system, sans-serif; + --font-mono: "Geist Mono", "JetBrains Mono", "Fira Code", monospace; + + /* Border radius */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + + /* Transitions */ + --transition-fast: 150ms ease; + --transition-base: 250ms ease; +} + +/* ======================================== + Dark Theme (default) + ======================================== */ + +[data-theme="dark"] { + --bg-primary: #121110; + --bg-secondary: #1a1918; + --bg-tertiary: #222120; + --bg-elevated: #2a2928; + --bg-code: #1a1918; + + --text-primary: #f5f2ef; + --text-secondary: #b8b3ad; + --text-muted: #807a73; + --text-accent: var(--orange-400); + + --border-primary: #2e2d2b; + --border-secondary: #3a3836; + --border-accent: var(--orange-700); + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5); +} + +/* ======================================== + Light Theme + ======================================== */ + +[data-theme="light"] { + --bg-primary: #fafaf9; + --bg-secondary: #f5f4f2; + --bg-tertiary: #eeedeb; + --bg-elevated: #ffffff; + --bg-code: #f5f4f2; + + --text-primary: #1a1918; + --text-secondary: #555350; + --text-muted: #8a8784; + --text-accent: var(--orange-600); + + --border-primary: #e5e3e0; + --border-secondary: #d5d3d0; + --border-accent: var(--orange-300); + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.1); +} + +/* ======================================== + Reset & Base + ======================================== */ + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + scroll-behavior: smooth; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: var(--font-body); + font-size: 15px; + line-height: 1.65; + color: var(--text-primary); + background-color: var(--bg-primary); + transition: background-color var(--transition-base), color var(--transition-base); +} + +a { + color: var(--text-accent); + text-decoration: none; + transition: color var(--transition-fast); +} + +a:hover { + color: var(--orange-300); +} + +/* Skip to content */ +.skip-to-content { + position: absolute; + top: -100px; + left: var(--space-4); + padding: var(--space-2) var(--space-4); + background: var(--orange-500); + color: white; + border-radius: var(--radius-sm); + z-index: 1000; + transition: top var(--transition-fast); +} + +.skip-to-content:focus { + top: var(--space-4); +} + +/* ======================================== + Header + ======================================== */ + +.header { + position: sticky; + top: 0; + z-index: 100; + height: var(--header-height); + background-color: var(--bg-primary); + border-bottom: 1px solid var(--border-primary); + backdrop-filter: blur(8px); +} + +.header-inner { + max-width: var(--max-width); + margin: 0 auto; + height: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--space-6); +} + +.header-left { + display: flex; + align-items: center; + gap: var(--space-8); +} + +.header-logo { + font-family: var(--font-mono); + font-size: 16px; + font-weight: 600; + color: var(--text-primary) !important; + letter-spacing: -0.02em; +} + +.header-nav { + display: flex; + gap: var(--space-6); +} + +.header-nav a { + color: var(--text-secondary); + font-size: 14px; + font-weight: 500; + transition: color var(--transition-fast); +} + +.header-nav a:hover { + color: var(--text-primary); +} + +.header-right { + display: flex; + align-items: center; + gap: var(--space-4); +} + +.search-trigger { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-1) var(--space-3); + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + color: var(--text-muted); + font-size: 13px; + cursor: pointer; + transition: border-color var(--transition-fast); + font-family: var(--font-body); +} + +.search-trigger:hover { + border-color: var(--border-secondary); +} + +.search-trigger kbd { + font-family: var(--font-mono); + font-size: 11px; + padding: 1px 5px; + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); +} + +.header-github { + font-size: 13px; + color: var(--text-secondary) !important; + font-weight: 500; +} + +.header-github:hover { + color: var(--text-primary) !important; +} + +.theme-toggle { + background: none; + border: none; + cursor: pointer; + font-size: 18px; + padding: var(--space-1); + line-height: 1; +} + +/* ======================================== + Search Modal + ======================================== */ + +.search-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 200; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 120px; +} + +.search-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + z-index: -1; +} + +.search-modal { + width: 100%; + max-width: 560px; + background: var(--bg-elevated); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + overflow: hidden; +} + +.search-input-wrapper { + display: flex; + align-items: center; + padding: var(--space-4); + border-bottom: 1px solid var(--border-primary); +} + +.search-input { + flex: 1; + background: none; + border: none; + outline: none; + font-size: 16px; + color: var(--text-primary); + font-family: var(--font-body); +} + +.search-input::placeholder { + color: var(--text-muted); +} + +.search-shortcut { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-muted); + padding: 2px 6px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); +} + +.search-results { + max-height: 400px; + overflow-y: auto; + padding: var(--space-2); +} + +.search-result-item { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + cursor: pointer; + transition: background var(--transition-fast); +} + +.search-result-item:hover, +.search-result-item.active { + background: var(--bg-tertiary); +} + +.search-result-section { + font-size: 12px; + color: var(--text-muted); + min-width: 100px; +} + +.search-result-title { + font-size: 14px; + color: var(--text-primary); + font-weight: 500; +} + +/* ======================================== + Hero + ======================================== */ + +.hero { + position: relative; + padding: var(--space-20) var(--space-6); + text-align: center; + overflow: hidden; +} + +.hero-backdrop { + position: absolute; + inset: 0; + background: radial-gradient( + ellipse at 50% 0%, + rgba(196, 117, 53, 0.12) 0%, + transparent 70% + ); + pointer-events: none; +} + +.hero-content { + position: relative; + max-width: 680px; + margin: 0 auto; +} + +.hero-title { + font-family: var(--font-display); + font-size: 56px; + font-weight: 400; + line-height: 1.1; + margin-bottom: var(--space-6); + letter-spacing: -0.02em; +} + +.hero-title-accent { + color: var(--text-accent); +} + +.hero-subtitle { + font-size: 18px; + line-height: 1.6; + color: var(--text-secondary); + margin-bottom: var(--space-10); + max-width: 520px; + margin-left: auto; + margin-right: auto; +} + +.hero-actions { + display: flex; + gap: var(--space-4); + justify-content: center; + margin-bottom: var(--space-8); +} + +.hero-install { + display: inline-block; + padding: var(--space-3) var(--space-6); + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); +} + +.hero-install code { + font-family: var(--font-mono); + font-size: 14px; + color: var(--text-secondary); +} + +/* ======================================== + Buttons + ======================================== */ + +.btn { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-6); + border-radius: var(--radius-md); + font-size: 14px; + font-weight: 500; + font-family: var(--font-body); + cursor: pointer; + transition: all var(--transition-fast); + border: 1px solid transparent; + text-decoration: none; +} + +.btn-primary { + background: var(--orange-500); + color: white; + border-color: var(--orange-500); +} + +.btn-primary:hover { + background: var(--orange-400); + border-color: var(--orange-400); + color: white; +} + +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); + border-color: var(--border-primary); +} + +.btn-secondary:hover { + background: var(--bg-elevated); + border-color: var(--border-secondary); + color: var(--text-primary); +} + +/* ======================================== + Features + ======================================== */ + +.features-section { + padding: var(--space-16) var(--space-6); + max-width: var(--max-width); + margin: 0 auto; +} + +.section-title { + font-family: var(--font-display); + font-size: 36px; + font-weight: 400; + text-align: center; + margin-bottom: var(--space-12); + letter-spacing: -0.01em; +} + +.features-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--space-6); + max-width: 960px; + margin: 0 auto; +} + +.feature-card { + padding: var(--space-6); + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +.feature-card:hover { + border-color: var(--border-secondary); + box-shadow: var(--shadow-sm); +} + +.feature-icon { + font-size: 28px; + margin-bottom: var(--space-4); +} + +.feature-title { + font-size: 16px; + font-weight: 600; + margin-bottom: var(--space-2); + color: var(--text-primary); +} + +.feature-desc { + font-size: 14px; + color: var(--text-secondary); + line-height: 1.6; +} + +/* ======================================== + Code Demo + ======================================== */ + +.code-demo-section { + padding: var(--space-16) var(--space-6); + max-width: var(--max-width); + margin: 0 auto; +} + +.code-demo { + max-width: 720px; + margin: 0 auto; + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.code-demo-tabs { + display: flex; + align-items: center; + padding: var(--space-2) var(--space-4); + border-bottom: 1px solid var(--border-primary); + gap: var(--space-1); +} + +.code-tab { + padding: var(--space-2) var(--space-3); + background: none; + border: none; + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 13px; + font-weight: 500; + cursor: pointer; + font-family: var(--font-body); + transition: all var(--transition-fast); +} + +.code-tab:hover { + color: var(--text-secondary); +} + +.code-tab.active { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.code-copy-btn { + margin-left: auto; + padding: var(--space-1) var(--space-3); + background: none; + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 12px; + cursor: pointer; + font-family: var(--font-body); + transition: all var(--transition-fast); +} + +.code-copy-btn:hover { + border-color: var(--border-secondary); + color: var(--text-secondary); +} + +.code-demo-content { + padding: var(--space-4); +} + +/* ======================================== + Community + ======================================== */ + +.community-section { + padding: var(--space-20) var(--space-6); + text-align: center; + border-top: 1px solid var(--border-primary); +} + +.community-desc { + font-size: 16px; + color: var(--text-secondary); + max-width: 480px; + margin: 0 auto var(--space-8); + line-height: 1.6; +} + +.community-links { + display: flex; + gap: var(--space-4); + justify-content: center; + flex-wrap: wrap; +} + +/* ======================================== + Docs Layout + ======================================== */ + +.docs-layout { + display: flex; + max-width: var(--max-width); + margin: 0 auto; + min-height: calc(100vh - var(--header-height)); +} + +.docs-sidebar { + width: var(--sidebar-width); + flex-shrink: 0; + padding: var(--space-8) var(--space-6); + border-right: 1px solid var(--border-primary); + position: sticky; + top: var(--header-height); + height: calc(100vh - var(--header-height)); + overflow-y: auto; +} + +.sidebar-section { + margin-bottom: var(--space-6); +} + +.sidebar-section-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + margin-bottom: var(--space-3); + padding: 0 var(--space-3); +} + +.sidebar-link { + display: block; + padding: var(--space-2) var(--space-3); + font-size: 14px; + color: var(--text-secondary); + border-radius: var(--radius-sm); + transition: all var(--transition-fast); +} + +.sidebar-link:hover { + color: var(--text-primary); + background: var(--bg-tertiary); +} + +.sidebar-link.active { + color: var(--text-accent); + background: rgba(196, 117, 53, 0.1); + font-weight: 500; +} + +/* ======================================== + Docs Main + ======================================== */ + +.docs-main { + flex: 1; + min-width: 0; + padding: var(--space-8) var(--space-10); + max-width: calc(var(--content-max-width) + var(--space-10) * 2); +} + +.docs-content { + margin-bottom: var(--space-12); +} + +/* ======================================== + Breadcrumbs + ======================================== */ + +.docs-breadcrumb { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: 13px; + color: var(--text-muted); + margin-bottom: var(--space-6); +} + +.docs-breadcrumb a { + color: var(--text-muted); +} + +.docs-breadcrumb a:hover { + color: var(--text-accent); +} + +.docs-breadcrumb-sep { + color: var(--text-muted); + opacity: 0.5; +} + +.docs-breadcrumb-current { + color: var(--text-secondary); +} + +/* ======================================== + Docs Article + ======================================== */ + +.docs-article h1 { + font-family: var(--font-display); + font-size: 36px; + font-weight: 400; + margin-bottom: var(--space-6); + letter-spacing: -0.01em; + line-height: 1.2; +} + +.docs-article h2 { + font-size: 22px; + font-weight: 600; + margin-top: var(--space-12); + margin-bottom: var(--space-4); + padding-bottom: var(--space-2); + border-bottom: 1px solid var(--border-primary); +} + +.docs-article h3 { + font-size: 17px; + font-weight: 600; + margin-top: var(--space-8); + margin-bottom: var(--space-3); +} + +.docs-article p { + color: var(--text-secondary); + margin-bottom: var(--space-4); + line-height: 1.7; +} + +.docs-article ul, +.docs-article ol { + color: var(--text-secondary); + margin-bottom: var(--space-4); + padding-left: var(--space-6); +} + +.docs-article li { + margin-bottom: var(--space-2); +} + +.docs-article code { + font-family: var(--font-mono); + font-size: 13px; + padding: 2px 6px; + background: var(--bg-code); + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); +} + +/* ======================================== + Code Block + ======================================== */ + +.code-block { + margin-bottom: var(--space-6); + background: var(--bg-code); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + overflow: hidden; +} + +.code-block-header { + display: flex; + justify-content: flex-end; + padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--border-primary); +} + +.code-block-copy { + padding: var(--space-1) var(--space-3); + background: none; + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 12px; + cursor: pointer; + font-family: var(--font-body); + transition: all var(--transition-fast); +} + +.code-block-copy:hover { + border-color: var(--border-secondary); + color: var(--text-secondary); +} + +.code-block-pre { + padding: var(--space-4); + overflow-x: auto; + margin: 0; +} + +.code-block-code { + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.6; + background: none !important; + border: none !important; + padding: 0 !important; +} + +/* ======================================== + Syntax Highlighting + ======================================== */ + +.syntax-line { + display: flex; + min-height: 1.6em; +} + +.syntax-line-number { + width: 36px; + flex-shrink: 0; + color: var(--text-muted); + opacity: 0.4; + text-align: right; + padding-right: var(--space-4); + user-select: none; + font-size: 12px; +} + +.syntax-line-content { + flex: 1; + white-space: pre; +} + +.syntax-keyword { + color: #c678dd; +} + +.syntax-type { + color: #e5c07b; +} + +.syntax-string { + color: #98c379; +} + +.syntax-comment { + color: var(--text-muted); + font-style: italic; +} + +.syntax-operator { + color: #56b6c2; +} + +.syntax-number { + color: #d19a66; +} + +[data-theme="light"] .syntax-keyword { + color: #a626a4; +} + +[data-theme="light"] .syntax-type { + color: #c18401; +} + +[data-theme="light"] .syntax-string { + color: #50a14f; +} + +[data-theme="light"] .syntax-comment { + color: #a0a1a7; +} + +[data-theme="light"] .syntax-operator { + color: #0184bc; +} + +[data-theme="light"] .syntax-number { + color: #986801; +} + +/* ======================================== + Callouts + ======================================== */ + +.callout { + padding: var(--space-4) var(--space-5); + border-radius: var(--radius-md); + margin-bottom: var(--space-6); + border-left: 3px solid; +} + +.callout-title { + font-weight: 600; + font-size: 14px; + margin-bottom: var(--space-2); +} + +.callout p { + font-size: 14px; + margin-bottom: 0; +} + +.callout-note { + background: rgba(59, 130, 246, 0.08); + border-color: #3b82f6; +} + +.callout-note .callout-title { + color: #3b82f6; +} + +.callout-tip { + background: rgba(34, 197, 94, 0.08); + border-color: #22c55e; +} + +.callout-tip .callout-title { + color: #22c55e; +} + +.callout-warning { + background: rgba(234, 179, 8, 0.08); + border-color: #eab308; +} + +.callout-warning .callout-title { + color: #eab308; +} + +.callout-danger { + background: rgba(239, 68, 68, 0.08); + border-color: #ef4444; +} + +.callout-danger .callout-title { + color: #ef4444; +} + +/* ======================================== + Prev/Next Navigation + ======================================== */ + +.docs-prev-next { + display: flex; + justify-content: space-between; + gap: var(--space-4); + padding-top: var(--space-8); + border-top: 1px solid var(--border-primary); + margin-bottom: var(--space-8); +} + +.docs-prev-next-link { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: var(--space-4) var(--space-5); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + transition: border-color var(--transition-fast); + max-width: 50%; +} + +.docs-prev-next-link:hover { + border-color: var(--text-accent); +} + +.docs-prev-next-link.next { + text-align: right; + margin-left: auto; +} + +.docs-prev-next-label { + font-size: 12px; + color: var(--text-muted); +} + +.docs-prev-next-title { + font-size: 14px; + font-weight: 500; + color: var(--text-accent); +} + +/* ======================================== + Feedback Widget + ======================================== */ + +.docs-feedback { + display: flex; + align-items: center; + gap: var(--space-3); + font-size: 14px; + color: var(--text-muted); +} + +.feedback-btn { + background: none; + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); + padding: var(--space-1) var(--space-2); + cursor: pointer; + font-size: 16px; + transition: all var(--transition-fast); +} + +.feedback-btn:hover { + border-color: var(--border-secondary); + background: var(--bg-tertiary); +} + +.feedback-btn.selected { + border-color: var(--text-accent); + background: rgba(196, 117, 53, 0.1); +} + +/* ======================================== + Footer + ======================================== */ + +.footer { + border-top: 1px solid var(--border-primary); + padding: var(--space-12) var(--space-6) var(--space-8); + background: var(--bg-secondary); +} + +.footer-inner { + display: flex; + gap: var(--space-16); + max-width: var(--max-width); + margin: 0 auto; + margin-bottom: var(--space-8); +} + +.footer-brand { + font-family: var(--font-mono); + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: var(--space-2); +} + +.footer-tagline { + font-size: 14px; + color: var(--text-muted); +} + +.footer-col { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.footer-col:first-child { + flex: 1; +} + +.footer-col-title { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + margin-bottom: var(--space-2); +} + +.footer-col a { + font-size: 14px; + color: var(--text-secondary); +} + +.footer-col a:hover { + color: var(--text-accent); +} + +.footer-bottom { + max-width: var(--max-width); + margin: 0 auto; + padding-top: var(--space-6); + border-top: 1px solid var(--border-primary); + font-size: 13px; + color: var(--text-muted); +} + +/* ======================================== + Responsive + ======================================== */ + +@media (max-width: 768px) { + .hero-title { + font-size: 36px; + } + + .hero-subtitle { + font-size: 16px; + } + + .features-grid { + grid-template-columns: 1fr; + } + + .docs-sidebar { + display: none; + } + + .docs-main { + padding: var(--space-6) var(--space-4); + } + + .docs-prev-next { + flex-direction: column; + } + + .docs-prev-next-link { + max-width: 100%; + } + + .docs-prev-next-link.next { + text-align: left; + } + + .header-nav { + display: none; + } + + .search-trigger span { + display: none; + } + + .footer-inner { + flex-direction: column; + gap: var(--space-8); + } + + .hero-actions { + flex-direction: column; + align-items: center; + } +} + +@media (max-width: 480px) { + .hero { + padding: var(--space-12) var(--space-4); + } + + .hero-title { + font-size: 28px; + } + + .docs-article h1 { + font-size: 28px; + } + + .docs-article h2 { + font-size: 20px; + } + + .section-title { + font-size: 28px; + } +} diff --git a/website/vite.config.js b/website/vite.config.js new file mode 100644 index 0000000..710e8bb --- /dev/null +++ b/website/vite.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from "vite" + +export default defineConfig({ + base: "/rescript-tone/", + build: { + outDir: "dist", + emptyOutDir: true, + }, + server: { + port: 3000, + }, +}) From c3b308750442a1a6f949be7fd39ba565dcacb665 Mon Sep 17 00:00:00 2001 From: Bernardo Gurgel Date: Wed, 1 Apr 2026 08:29:44 +0000 Subject: [PATCH 02/17] feat(website): add Tone.js pink/green theme, basefn icons, and interactive demos - Replace orange color palette with Tone.js-inspired pink (#e535ab) and green (#2dd4a0) colors throughout the design system - Integrate Basefn.Icon components (Search, GitHub, Sun/Moon, Menu, etc.) in header, hero, feature cards, and code demo sections - Use Basefn.Theme for proper dark/light theme management - Add three interactive audio demos to the Examples page: - Synth Keyboard: play notes with selectable waveforms - Effects Chain: FM synth with adjustable reverb/delay wet mix - Step Sequencer: togglable 8-step pattern with Loop/Transport - Add rescript-tone and tone as website dependencies for live demos - Add demo-specific CSS (piano keys, sequencer grid, sliders, etc.) - Update .gitignore to exclude *.res.mjs compiled output https://claude.ai/code/session_01B8MgrWxixKEWaxDiHyy8Bx --- .gitignore | 1 + website/package-lock.json | 72 +++ website/package.json | 2 + website/rescript.json | 1 + website/src/Layout.res | 152 +++--- website/src/pages/Pages__Examples.res | 655 +++++++++++++++++++++++--- website/src/pages/Pages__Home.res | 83 ++-- website/src/styles.css | 422 +++++++++++++++-- 8 files changed, 1181 insertions(+), 207 deletions(-) diff --git a/.gitignore b/.gitignore index 0c2a81f..5a05163 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ lib/ .bsb.lock *.res.js +*.res.mjs .vite/ dist/ .merlin diff --git a/website/package-lock.json b/website/package-lock.json index 44835a7..0718e76 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -12,6 +12,8 @@ "basefn": "^1.9.1", "highlight.js": "^11.11.1", "rescript-signals": "^1.3.3", + "rescript-tone": "file:..", + "tone": "^15.1.22", "xote": "^4.16.1" }, "devDependencies": { @@ -19,6 +21,32 @@ "vite": "^7.1.12" } }, + "..": { + "version": "1.1.0", + "license": "MIT", + "devDependencies": { + "@semantic-release/changelog": "^6.0.3", + "@semantic-release/git": "^10.0.1", + "rescript": "^12.2.0", + "semantic-release": "^25.0.3", + "tone": "^15.1.22", + "vite": "^6.0.0", + "zekr": "^1.8.0" + }, + "peerDependencies": { + "rescript": "^12.0.0", + "tone": "^15.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", @@ -913,6 +941,19 @@ "dev": true, "license": "MIT" }, + "node_modules/automation-events": { + "version": "7.1.17", + "resolved": "https://registry.npmjs.org/automation-events/-/automation-events-7.1.17.tgz", + "integrity": "sha512-L9MTEpShvoOrFETgB9PMQ4cHq1Foe3p53QFKrefGsgSzlRBORzRi0cC81tMYAgN71W30yYEt5cDZnWZvJKjLoQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.2.0" + } + }, "node_modules/basefn": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/basefn/-/basefn-1.10.0.tgz", @@ -1128,6 +1169,10 @@ "integrity": "sha512-JvuPULX6c+4r7J+LOzH1zRepOHpiem6kK/gLtzuvfwXj6MTDjBQlWh0xM1VxR7GjGg3sitdR+Bo17QzH67Tx9g==", "license": "SEE LICENSE IN LICENSE" }, + "node_modules/rescript-tone": { + "resolved": "..", + "link": true + }, "node_modules/rollup": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", @@ -1183,6 +1228,17 @@ "node": ">=0.10.0" } }, + "node_modules/standardized-audio-context": { + "version": "25.3.77", + "resolved": "https://registry.npmjs.org/standardized-audio-context/-/standardized-audio-context-25.3.77.tgz", + "integrity": "sha512-Ki9zNz6pKcC5Pi+QPjPyVsD9GwJIJWgryji0XL9cAJXMGyn+dPOf6Qik1AHei0+UNVcc4BOCa0hWLBzlwqsW/A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.6", + "automation-events": "^7.0.9", + "tslib": "^2.7.0" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1200,6 +1256,22 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tone": { + "version": "15.1.22", + "resolved": "https://registry.npmjs.org/tone/-/tone-15.1.22.tgz", + "integrity": "sha512-TCScAGD4sLsama5DjvTUXlLDXSqPealhL64nsdV1hhr6frPWve0DeSo63AKnSJwgfg55fhvxj0iPPRwPN5o0ag==", + "license": "MIT", + "dependencies": { + "standardized-audio-context": "^25.3.70", + "tslib": "^2.3.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/website/package.json b/website/package.json index 7477a42..6bc865d 100644 --- a/website/package.json +++ b/website/package.json @@ -16,6 +16,8 @@ "basefn": "^1.9.1", "highlight.js": "^11.11.1", "rescript-signals": "^1.3.3", + "rescript-tone": "file:..", + "tone": "^15.1.22", "xote": "^4.16.1" }, "devDependencies": { diff --git a/website/rescript.json b/website/rescript.json index f69b497..f519278 100644 --- a/website/rescript.json +++ b/website/rescript.json @@ -14,6 +14,7 @@ "dependencies": [ "@rescript/core", "rescript-signals", + "rescript-tone", "xote", "basefn" ], diff --git a/website/src/Layout.res b/website/src/Layout.res index 9fe3569..41738ae 100644 --- a/website/src/Layout.res +++ b/website/src/Layout.res @@ -1,40 +1,19 @@ open Xote -// ---- External bindings ---- -@val external localStorage: {..} = "localStorage" -@val external document: {..} = "document" -@val external window: {..} = "window" - -// ---- Theme ---- -let getInitialTheme = () => { - try { - let stored: string = localStorage["getItem"]("theme") - if stored == "light" { - "light" - } else { - "dark" +// ---- Theme integration via Basefn ---- +let _ = { + // Initialize basefn theme with dark default + let stored: option = %raw(`localStorage.getItem('basefn-theme')`) + switch stored { + | Some(_) => Basefn.Theme.init() + | None => { + Basefn.Theme.applyTheme(Dark) + Signal.set(Basefn.Theme.currentTheme, Dark) } - } catch { - | _ => "dark" } } -let theme = Signal.make(getInitialTheme()) - -let applyTheme = (t: string) => { - let _ = document["documentElement"]["setAttribute"]("data-theme", t) - let _ = localStorage["setItem"]("theme", t) -} - -let toggleTheme = () => { - let next = if Signal.get(theme) == "dark" { - "light" - } else { - "dark" - } - Signal.set(theme, next) - applyTheme(next) -} +let toggleTheme = () => Basefn.Theme.toggleTheme() // ---- Search data ---- type searchItem = { @@ -97,13 +76,14 @@ module SearchModal = {
+ {Component.element( "input", ~attrs=[ Component.attr("type", "text"), Component.attr("class", "search-input"), Component.attr("placeholder", "Search documentation..."), - Component.computedAttr("value", () => Signal.get(searchQuery)), + Component.attr("autofocus", "true"), ], ~events=[ ("input", evt => { @@ -146,35 +126,47 @@ module SearchModal = {
{Component.signalFragment( Computed.make(() => { - Signal.get(filteredItems)->Array.mapWithIndex((item, i) => { - Component.element( - "div", - ~attrs=[ - Component.computedAttr("class", () => - "search-result-item" ++ - (Signal.get(searchIndex) == i ? " active" : "") - ), - ], - ~events=[ - ("click", _ => { - Signal.set(searchIndex, i) - navigateToResult() - }), - ], - ~children=[ - - {Component.text(item.section)} - , - - {Component.text(item.title)} - , - ], - (), - ) - }) + let items = Signal.get(filteredItems) + if Array.length(items) == 0 { + [ +
+ {Component.text("No results found.")} +
, + ] + } else { + items->Array.mapWithIndex((item, i) => { + Component.element( + "div", + ~attrs=[ + Component.computedAttr("class", () => + "search-result-item" ++ + (Signal.get(searchIndex) == i ? " active" : "") + ), + ], + ~events=[ + ("click", _ => { + Signal.set(searchIndex, i) + navigateToResult() + }), + ], + ~children=[ + + {Component.text(item.section)} + , + + {Component.text(item.title)} + , + ], + (), + ) + }) + } }), )}
+
{Component.element( "div", @@ -203,14 +195,6 @@ module Header = { type props = {} let make = (_props: props) => { - let themeIcon = Computed.make(() => - if Signal.get(theme) == "dark" { - "Light" - } else { - "Dark" - } - ) -
@@ -247,33 +231,53 @@ module Header = { ], ~events=[("click", _ => Signal.set(searchOpen, true))], ~children=[ - {Component.text("Search")} , - {Component.text("\u2318K")} , + Basefn.Icon.make({name: Search, size: Sm}), + {Component.text("Search docs...")} , +
+ {Component.text("\u2318")} + {Component.text("K")} +
, ], (), )} {Component.element( "a", ~attrs=[ - Component.attr("class", "header-github"), + Component.attr("class", "header-icon-btn"), Component.attr("href", "https://github.com/brnrdog/rescript-tone"), Component.attr("target", "_blank"), Component.attr("rel", "noopener noreferrer"), Component.attr("title", "GitHub"), ], - ~children=[Component.text("GitHub")], + ~children=[Basefn.Icon.make({name: GitHub, size: Sm})], (), )} {Component.element( "button", ~attrs=[ - Component.attr("class", "theme-toggle"), + Component.attr("class", "header-icon-btn"), Component.attr("title", "Toggle theme"), ], ~events=[("click", _ => toggleTheme())], ~children=[ - Component.textSignal(() => Signal.get(themeIcon)), + Component.signalFragment( + Computed.make(() => + Signal.get(Basefn.Theme.currentTheme) == Dark + ? [Basefn.Icon.make({name: Sun, size: Sm})] + : [Basefn.Icon.make({name: Moon, size: Sm})] + ), + ), + ], + (), + )} + {Component.element( + "button", + ~attrs=[ + Component.attr("class", "header-icon-btn mobile-menu-btn"), + Component.attr("title", "Menu"), ], + ~events=[("click", _ => Signal.set(searchOpen, true))], + ~children=[Basefn.Icon.make({name: Menu, size: Sm})], (), )}
@@ -333,9 +337,9 @@ module Footer = { } // ---- Global keyboard shortcut ---- -let _ = Effect.run(() => { - applyTheme(Signal.get(theme)) +@val external document: {..} = "document" +let _ = Effect.run(() => { let handler = evt => { let e: {..} = Obj.magic(evt) let key: string = e["key"] diff --git a/website/src/pages/Pages__Examples.res b/website/src/pages/Pages__Examples.res index 0eb5ac1..f9ab766 100644 --- a/website/src/pages/Pages__Examples.res +++ b/website/src/pages/Pages__Examples.res @@ -1,12 +1,596 @@ open Xote +// ---- JS bindings ---- +@val external setTimeout: (unit => unit, int) => unit = "setTimeout" + +// Tone.js accepts strings for frequency ("C4") and time ("8n") at runtime +// but the bindings type them as float/abstract. These helpers cast safely. +// The Tone bindings use abstract types, but Tone.js accepts strings at runtime. +// We use Obj.magic for the type casts in these interactive demos. +let toFreq: string => 'a = Obj.magic +let toTime: string => 'a = Obj.magic +let floatToTime: float => 'a = Obj.magic + +let toOsc = (s: string): Tone.Types.oscillatorType => { + switch s { + | "sine" => Sine + | "square" => Square + | "sawtooth" => Sawtooth + | _ => Triangle + } +} + +let makeEnvelope = ( + ~attack: float, + ~decay: float, + ~sustain: float, + ~release: float, +): Tone.Types.envelopeOptions => { + attack: floatToTime(attack), + decay: floatToTime(decay), + sustain, + release: floatToTime(release), +} + +// ---- Interactive Synth Keyboard Demo ---- +module SynthKeyboard = { + type props = {} + + let notes = ["C4", "D4", "E4", "F4", "G4", "A4", "B4", "C5"] + let noteLabels = ["C", "D", "E", "F", "G", "A", "B", "C"] + + let make = (_props: props) => { + let isReady = Signal.make(false) + let activeNote = Signal.make("") + let waveform = Signal.make("triangle") + + // Store synth in a ref-like signal + let synthRef: ref> = ref(None) + + let initAudio = _ => { + let _ = Tone.Core.start() + let synth = Tone.Synth.makeWithOptions({ + oscillator: {\"type": toOsc(Signal.get(waveform))}, + envelope: makeEnvelope(~attack=0.01, ~decay=0.2, ~sustain=0.3, ~release=0.8), + }) + synth->Tone.Synth.asAudioNode->Tone.AudioNode.toDestination->ignore + synthRef := Some(synth) + Signal.set(isReady, true) + } + + let playNote = (note: string) => _ => { + switch synthRef.contents { + | Some(synth) => { + synth->Tone.Synth.triggerAttackRelease(toFreq(note), toTime("8n"))->ignore + Signal.set(activeNote, note) + setTimeout(() => Signal.set(activeNote, ""), 200) + } + | None => () + } + } + + let setWave = (wave: string) => _ => { + Signal.set(waveform, wave) + // Recreate synth with new waveform + switch synthRef.contents { + | Some(_) => { + let synth = Tone.Synth.makeWithOptions({ + oscillator: {\"type": toOsc(wave)}, + envelope: makeEnvelope(~attack=0.01, ~decay=0.2, ~sustain=0.3, ~release=0.8), + }) + synth->Tone.Synth.asAudioNode->Tone.AudioNode.toDestination->ignore + synthRef := Some(synth) + } + | None => () + } + } + +
+
+

{Component.text("Synth Keyboard")}

+

{Component.text("Click a key to play a note. Choose different waveforms to change the timbre.")}

+
+
+ {Component.signalFragment( + Computed.make(() => { + if Signal.get(isReady) { + [ +
+ {Component.text("Waveform:")} + {Component.element( + "button", + ~attrs=[ + Component.computedAttr("class", () => + "demo-wave-btn" ++ (Signal.get(waveform) == "sine" ? " active" : "") + ), + ], + ~events=[("click", setWave("sine"))], + ~children=[Component.text("Sine")], + (), + )} + {Component.element( + "button", + ~attrs=[ + Component.computedAttr("class", () => + "demo-wave-btn" ++ (Signal.get(waveform) == "triangle" ? " active" : "") + ), + ], + ~events=[("click", setWave("triangle"))], + ~children=[Component.text("Triangle")], + (), + )} + {Component.element( + "button", + ~attrs=[ + Component.computedAttr("class", () => + "demo-wave-btn" ++ (Signal.get(waveform) == "sawtooth" ? " active" : "") + ), + ], + ~events=[("click", setWave("sawtooth"))], + ~children=[Component.text("Sawtooth")], + (), + )} + {Component.element( + "button", + ~attrs=[ + Component.computedAttr("class", () => + "demo-wave-btn" ++ (Signal.get(waveform) == "square" ? " active" : "") + ), + ], + ~events=[("click", setWave("square"))], + ~children=[Component.text("Square")], + (), + )} +
, +
+ {Component.fragment( + notes->Array.mapWithIndex((note, i) => { + Component.element( + "button", + ~attrs=[ + Component.computedAttr("class", () => + "piano-key" ++ (Signal.get(activeNote) == note ? " active" : "") + ), + ], + ~events=[("click", playNote(note))], + ~children=[ + Component.text( + switch noteLabels->Array.get(i) { + | Some(l) => l + | None => "" + }, + ), + ], + (), + ) + }), + )} +
, + ] + } else { + [ + Component.element( + "button", + ~attrs=[Component.attr("class", "btn btn-primary demo-start-btn")], + ~events=[("click", initAudio)], + ~children=[Component.text("Start Audio")], + (), + ), + ] + } + }), + )} +
+
+ Synth.asAudioNode->AudioNode.toDestination + +synth->Synth.triggerAttackRelease("C4", "8n")`} + /> +
+
+ } +} + +// ---- Effects Chain Demo ---- +module EffectsChain = { + type props = {} + + let make = (_props: props) => { + let isReady = Signal.make(false) + let reverbWet = Signal.make(50) + let delayWet = Signal.make(30) + let activeNote = Signal.make("") + + let synthRef: ref> = ref(None) + let reverbRef: ref> = ref(None) + let delayRef: ref> = ref(None) + + let initAudio = _ => { + let _ = Tone.Core.start() + let synth = Tone.FMSynth.make() + let reverb = Tone.Reverb.makeWithOptions({decay: 3.0, wet: 0.5}) + let delay = Tone.FeedbackDelay.makeWithOptions({ + delayTime: toTime("8n."), + feedback: 0.3, + wet: 0.3, + }) + + synth->Tone.FMSynth.asAudioNode + ->Tone.AudioNode.connect(delay->Tone.FeedbackDelay.asAudioNode) + ->ignore + delay->Tone.FeedbackDelay.asAudioNode + ->Tone.AudioNode.connect(reverb->Tone.Reverb.asAudioNode) + ->ignore + reverb->Tone.Reverb.asAudioNode->Tone.AudioNode.toDestination->ignore + + synthRef := Some(synth) + reverbRef := Some(reverb) + delayRef := Some(delay) + Signal.set(isReady, true) + } + + let playNote = (note: string) => _ => { + switch synthRef.contents { + | Some(synth) => { + synth->Tone.FMSynth.triggerAttackRelease(toFreq(note), toTime("4n"))->ignore + Signal.set(activeNote, note) + setTimeout(() => Signal.set(activeNote, ""), 300) + } + | None => () + } + } + + let notes = ["C3", "E3", "G3", "C4", "E4", "G4"] + +
+
+

{Component.text("Effects Chain")}

+

{Component.text("FM Synth routed through Delay and Reverb. Adjust the wet mix of each effect.")}

+
+
+ {Component.signalFragment( + Computed.make(() => { + if Signal.get(isReady) { + [ +
+
+ + {Component.textSignal(() => + "Reverb: " ++ Int.toString(Signal.get(reverbWet)) ++ "%" + )} + + {Component.element( + "input", + ~attrs=[ + Component.attr("type", "range"), + Component.attr("min", "0"), + Component.attr("max", "100"), + Component.computedAttr("value", () => + Int.toString(Signal.get(reverbWet)) + ), + Component.attr("class", "demo-slider"), + ], + ~events=[ + ("input", evt => { + let val: string = Obj.magic(evt)["target"]["value"] + let intVal = Int.fromString(val)->Option.getOr(50) + Signal.set(reverbWet, intVal) + switch reverbRef.contents { + | Some(reverb) => + Tone.Reverb.wet(reverb)->Tone.Param.setValue( + Int.toFloat(intVal) /. 100.0, + ) + | None => () + } + }), + ], + (), + )} +
+
+ + {Component.textSignal(() => + "Delay: " ++ Int.toString(Signal.get(delayWet)) ++ "%" + )} + + {Component.element( + "input", + ~attrs=[ + Component.attr("type", "range"), + Component.attr("min", "0"), + Component.attr("max", "100"), + Component.computedAttr("value", () => + Int.toString(Signal.get(delayWet)) + ), + Component.attr("class", "demo-slider"), + ], + ~events=[ + ("input", evt => { + let val: string = Obj.magic(evt)["target"]["value"] + let intVal = Int.fromString(val)->Option.getOr(30) + Signal.set(delayWet, intVal) + switch delayRef.contents { + | Some(delay) => + Tone.FeedbackDelay.wet(delay)->Tone.Param.setValue( + Int.toFloat(intVal) /. 100.0, + ) + | None => () + } + }), + ], + (), + )} +
+
, +
+ {Component.fragment( + notes->Array.map(note => { + Component.element( + "button", + ~attrs=[ + Component.computedAttr("class", () => + "demo-note-btn" ++ + (Signal.get(activeNote) == note ? " active" : "") + ), + ], + ~events=[("click", playNote(note))], + ~children=[Component.text(note)], + (), + ) + }), + )} +
, + ] + } else { + [ + Component.element( + "button", + ~attrs=[Component.attr("class", "btn btn-primary demo-start-btn")], + ~events=[("click", initAudio)], + ~children=[Component.text("Start Audio")], + (), + ), + ] + } + }), + )} +
+
+ FMSynth.asAudioNode +->AudioNode.connect(delay->FeedbackDelay.asAudioNode) +->AudioNode.connect(reverb->Reverb.asAudioNode) +->AudioNode.toDestination`} + /> +
+
+ } +} + +// ---- Sequencer Demo ---- +module SequencerDemo = { + type props = {} + + let make = (_props: props) => { + let isReady = Signal.make(false) + let isPlaying = Signal.make(false) + let currentStep = Signal.make(-1) + + let steps = [ + ("C4", true), + ("D4", false), + ("E4", true), + ("F4", false), + ("G4", true), + ("A4", false), + ("B4", true), + ("C5", false), + ] + let stepActive = steps->Array.map(((_, active)) => Signal.make(active)) + + let synthRef: ref> = ref(None) + let loopRef: ref> = ref(None) + + let initAudio = _ => { + let _ = Tone.Core.start() + let synth = Tone.Synth.makeWithOptions({ + oscillator: {\"type": Square}, + envelope: makeEnvelope(~attack=0.01, ~decay=0.1, ~sustain=0.1, ~release=0.3), + }) + synth->Tone.Synth.asAudioNode->Tone.AudioNode.toDestination->ignore + + let stepIdx = ref(0) + let loop = Tone.Loop.make(_time => { + let idx = mod(stepIdx.contents, 8) + Signal.set(currentStep, idx) + + let isActive = switch stepActive->Array.get(idx) { + | Some(s) => Signal.get(s) + | None => false + } + + if isActive { + let (note, _) = switch steps->Array.get(idx) { + | Some(s) => s + | None => ("C4", false) + } + synth->Tone.Synth.triggerAttackRelease(toFreq(note), toTime("16n"))->ignore + } + + stepIdx := stepIdx.contents + 1 + }, toTime("8n")) + + synthRef := Some(synth) + loopRef := Some(loop) + Signal.set(isReady, true) + } + + let togglePlay = _ => { + let playing = Signal.get(isPlaying) + if playing { + switch loopRef.contents { + | Some(loop) => loop->Tone.Loop.stop->ignore + | None => () + } + let transport = Tone.Core.getTransport() + transport->Tone.Transport.stop->ignore + Signal.set(isPlaying, false) + Signal.set(currentStep, -1) + } else { + switch loopRef.contents { + | Some(loop) => loop->Tone.Loop.start->ignore + | None => () + } + let transport = Tone.Core.getTransport() + transport->Tone.Transport.start->ignore + Signal.set(isPlaying, true) + } + } + +
+
+

{Component.text("Step Sequencer")}

+

{Component.text("Toggle steps on/off to create a pattern. Uses Loop and Transport for timing.")}

+
+
+ {Component.signalFragment( + Computed.make(() => { + if Signal.get(isReady) { + let _ = Signal.get(isPlaying) + [ +
+ {Component.element( + "button", + ~attrs=[ + Component.computedAttr("class", () => + "btn " ++ + (Signal.get(isPlaying) ? "btn-danger" : "btn-primary") + ), + ], + ~events=[("click", togglePlay)], + ~children=[ + Component.textSignal(() => + Signal.get(isPlaying) ? "Stop" : "Play" + ), + ], + (), + )} +
, +
+ {Component.fragment( + steps->Array.mapWithIndex(((note, _), i) => { + let stepSignal = switch stepActive->Array.get(i) { + | Some(s) => s + | None => Signal.make(false) + } + Component.element( + "button", + ~attrs=[ + Component.computedAttr("class", () => { + let isCurrent = Signal.get(currentStep) == i + let active = Signal.get(stepSignal) + "seq-step" ++ + (active ? " on" : "") ++ + (isCurrent ? " current" : "") + }), + ], + ~events=[ + ("click", _ => { + Signal.update(stepSignal, v => !v) + }), + ], + ~children=[ + {Component.text(note)} , + ], + (), + ) + }), + )} +
, + ] + } else { + [ + Component.element( + "button", + ~attrs=[Component.attr("class", "btn btn-primary demo-start-btn")], + ~events=[("click", initAudio)], + ~children=[Component.text("Start Audio")], + (), + ), + ] + } + }), + )} +
+
+ Synth.asAudioNode->AudioNode.toDestination + +let _loop = Loop.make(_time => { + synth->Synth.triggerAttackRelease("C4", "16n") +}, "8n") + +let transport = Core.getTransport() +transport->Transport.start()`} + /> +
+
+ } +} + +// ---- Page ---- type props = {} let make = (_props: props) => {
-

{Component.text("Examples")}

+

{Component.text("Interactive Examples")}

+

+ {Component.text( + "Try these live demos built with rescript-tone and xote. Click \"Start Audio\" to initialize the Web Audio context, then interact with each demo.", + )} +

+ +

{Component.text("Synth Keyboard")}

+ -

{Component.text("Simple Melody")}

+

{Component.text("Effects Chain")}

+ + +

{Component.text("Step Sequencer")}

+ + +

{Component.text("More Code Examples")}

+ +

{Component.text("Simple Melody")}

{Component.text( "Play a sequence of notes using Sequence and the Transport.", @@ -16,7 +600,7 @@ let make = (_props: props) => { code={`open Tone let synth = Synth.makeWithOptions({ - oscillator: {\"type": "triangle"}, + oscillator: {"type": "triangle"}, envelope: {attack: 0.01, decay: 0.1, sustain: 0.3, release: 0.5}, }) synth->Synth.asAudioNode->AudioNode.toDestination @@ -34,49 +618,7 @@ let startButton = async () => { }`} /> -

{Component.text("Effects Chain")}

-

- {Component.text( - "Build a signal chain with multiple effects.", - )} -

- Chorus -> Delay -> Reverb -> Speakers -synth->FMSynth.asAudioNode->AudioNode.chain([ - chorus->Chorus.asAudioNode, - delay->FeedbackDelay.asAudioNode, - reverb->Reverb.asAudioNode, -]) -reverb->Reverb.asAudioNode->AudioNode.toDestination - -chorus->Chorus.start()`} - /> - -

{Component.text("Drum Pattern")}

+

{Component.text("Drum Pattern")}

{Component.text( "Use Loop and Transport to create a repeating drum pattern.", @@ -86,19 +628,16 @@ chorus->Chorus.start()`} code={`open Tone let kick = Synth.makeWithOptions({ - oscillator: {\"type": "sine"}, + oscillator: {"type": "sine"}, envelope: {attack: 0.001, decay: 0.2, sustain: 0.0, release: 0.2}, }) kick->Synth.asAudioNode->AudioNode.toDestination let hihat = Noise.makeWithOptions({ - \"type": "white", + "type": "white", volume: -20.0, }) -let hihatEnv = Volume.makeWithVolume(-20.0) -hihat->Noise.asAudioNode -->AudioNode.connect(hihatEnv->Volume.asAudioNode) -->AudioNode.toDestination +hihat->Noise.asAudioNode->AudioNode.toDestination let transport = Core.getTransport() @@ -116,7 +655,7 @@ let _hatLoop = Loop.make(_time => { transport->Transport.start()`} /> -

{Component.text("Parameter Automation")}

+

{Component.text("Parameter Automation")}

{Component.text( "Automate parameters over time for expressive control.", @@ -127,12 +666,12 @@ transport->Transport.start()`} let osc = Oscillator.makeWithOptions({ frequency: 200.0, - \"type": "sawtooth", + "type": "sawtooth", }) let filter = Filter.makeWithOptions({ frequency: 500.0, - \"type": "lowpass", + "type": "lowpass", rolloff: -24, }) @@ -144,12 +683,6 @@ osc->Oscillator.asAudioNode let cutoff = filter->Filter.frequency cutoff->Param.rampTo(2000.0, "2m") -// Automate volume -let vol = osc->Oscillator.volume -vol->Param.setValueAtTime(-20.0, "0") -vol->Param.linearRampToValueAtTime(-6.0, "1m") -vol->Param.exponentialRampToValueAtTime(-20.0, "2m") - osc->Oscillator.start()`} />

diff --git a/website/src/pages/Pages__Home.res b/website/src/pages/Pages__Home.res index b587d1b..d8afa40 100644 --- a/website/src/pages/Pages__Home.res +++ b/website/src/pages/Pages__Home.res @@ -6,39 +6,39 @@ open Xote // ---- Feature data ---- type feature = { - icon: string, + iconName: Basefn.iconName, title: string, description: string, } let features: array = [ { - icon: "\u{1F3B9}", + iconName: Basefn.Icon.Star, title: "Full Tone.js Coverage", description: "Bindings for synths, effects, sources, scheduling, and more.", }, { - icon: "\u{1F512}", + iconName: Basefn.Icon.Check, title: "Type-Safe by Default", description: "Catch errors at compile time with ReScript's powerful type system.", }, { - icon: "\u{1F3A7}", + iconName: Basefn.Icon.Heart, title: "Web Audio Made Easy", description: "Create interactive music in the browser with a simple, expressive API.", }, { - icon: "\u26A1", + iconName: Basefn.Icon.Loader, title: "Zero Runtime Overhead", description: "Direct bindings to Tone.js with no wrapper layer or performance cost.", }, { - icon: "\u{1F4E6}", + iconName: Basefn.Icon.Download, title: "Modular Design", description: "Import only what you need. Each module maps directly to Tone.js classes.", }, { - icon: "\u{1F680}", + iconName: Basefn.Icon.ChevronRight, title: "Easy to Get Started", description: "Add to your ReScript project in minutes. Works with existing Tone.js knowledge.", }, @@ -51,7 +51,9 @@ module FeatureCard = { let make = (props: props) => { let {feature} = props
-
{Component.text(feature.icon)}
+
+ {Basefn.Icon.make({name: feature.iconName, size: Md})} +

{Component.text(feature.title)}

{Component.text(feature.description)}

@@ -78,13 +80,24 @@ module Hero = { {Router.link( ~to="/getting-started", ~attrs=[Component.attr("class", "btn btn-primary")], - ~children=[Component.text("Get Started")], + ~children=[ + Component.text("Get Started "), + Basefn.Icon.make({name: ChevronRight, size: Sm}), + ], (), )} - {Router.link( - ~to="/api/core", - ~attrs=[Component.attr("class", "btn btn-secondary")], - ~children=[Component.text("API Reference")], + {Component.element( + "a", + ~attrs=[ + Component.attr("class", "btn btn-secondary"), + Component.attr("href", "https://github.com/brnrdog/rescript-tone"), + Component.attr("target", "_blank"), + Component.attr("rel", "noopener noreferrer"), + ], + ~children=[ + Basefn.Icon.make({name: GitHub, size: Sm}), + Component.text(" View on GitHub"), + ], (), )}
@@ -181,7 +194,7 @@ Transport.start()` ), ], ~events=[("click", _ => Signal.set(activeTab, "synth"))], - ~children=[Component.text("Synth")], + ~children=[Component.text("Synth.res")], (), )} {Component.element( @@ -192,7 +205,7 @@ Transport.start()` ), ], ~events=[("click", _ => Signal.set(activeTab, "effects"))], - ~children=[Component.text("Effects Chain")], + ~children=[Component.text("Effects.res")], (), )} {Component.element( @@ -203,32 +216,36 @@ Transport.start()` ), ], ~events=[("click", _ => Signal.set(activeTab, "scheduling"))], - ~children=[Component.text("Scheduling")], + ~children=[Component.text("Scheduling.res")], (), )} +
+
{Component.element( "button", ~attrs=[ - Component.attr("class", "code-copy-btn"), + Component.computedAttr("class", () => + "code-copy-btn" ++ (Signal.get(copied) ? " copied" : "") + ), Component.attr("title", "Copy code"), ], ~events=[("click", _ => copyToClipboard())], ~children=[ - Component.textSignal(() => - if Signal.get(copied) { - "Copied!" - } else { - "Copy" - } + Component.signalFragment( + Computed.make(() => + Signal.get(copied) + ? [Basefn.Icon.make({name: Check, size: Sm}), Component.text(" Copied")] + : [Basefn.Icon.make({name: Copy, size: Sm}), Component.text(" Copy")] + ), ), ], (), )} -
-
- {Component.signalFragment( - Computed.make(() => []) - )} +
+ {Component.signalFragment( + Computed.make(() => []) + )} +
@@ -262,7 +279,10 @@ module Community = { Component.attr("target", "_blank"), Component.attr("rel", "noopener noreferrer"), ], - ~children=[Component.text("View on GitHub")], + ~children=[ + Basefn.Icon.make({name: GitHub, size: Sm}), + Component.text(" GitHub"), + ], (), )} {Component.element( @@ -273,7 +293,10 @@ module Community = { Component.attr("target", "_blank"), Component.attr("rel", "noopener noreferrer"), ], - ~children=[Component.text("npm")], + ~children=[ + Basefn.Icon.make({name: Download, size: Sm}), + Component.text(" npm"), + ], (), )}
diff --git a/website/src/styles.css b/website/src/styles.css index 429abf6..0077abe 100644 --- a/website/src/styles.css +++ b/website/src/styles.css @@ -3,18 +3,31 @@ ======================================== */ :root { - /* Orange color ramp */ - --orange-50: #fef6f0; - --orange-100: #fde8d4; - --orange-200: #fad0a8; - --orange-300: #f5b070; - --orange-400: #e8893d; - --orange-500: #c47535; - --orange-600: #a8612b; - --orange-700: #8a4f24; - --orange-800: #6b3d1d; - --orange-900: #4a2b14; - --orange-950: #2e1a0b; + /* Pink (Tone.js magenta) */ + --pink-50: #fef0f8; + --pink-100: #fdd5ee; + --pink-200: #fcade0; + --pink-300: #f472c8; + --pink-400: #e535ab; + --pink-500: #d41d91; + --pink-600: #b01577; + --pink-700: #8c1160; + --pink-800: #6b0e4a; + --pink-900: #4a0a34; + --pink-950: #2e061f; + + /* Green (Tone.js mint) */ + --green-50: #edfcf6; + --green-100: #d0f7e8; + --green-200: #a3efd2; + --green-300: #5ee4b3; + --green-400: #2dd4a0; + --green-500: #18b886; + --green-600: #0e9a6e; + --green-700: #0b7a58; + --green-800: #096147; + --green-900: #074f3a; + --green-950: #042e22; /* Layout */ --max-width: 1400px; @@ -65,11 +78,11 @@ --text-primary: #f5f2ef; --text-secondary: #b8b3ad; --text-muted: #807a73; - --text-accent: var(--orange-400); + --text-accent: var(--pink-400); --border-primary: #2e2d2b; --border-secondary: #3a3836; - --border-accent: var(--orange-700); + --border-accent: var(--pink-700); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); @@ -90,11 +103,11 @@ --text-primary: #1a1918; --text-secondary: #555350; --text-muted: #8a8784; - --text-accent: var(--orange-600); + --text-accent: var(--pink-600); --border-primary: #e5e3e0; --border-secondary: #d5d3d0; - --border-accent: var(--orange-300); + --border-accent: var(--pink-300); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06); --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); @@ -135,7 +148,7 @@ a { } a:hover { - color: var(--orange-300); + color: var(--pink-300); } /* Skip to content */ @@ -144,7 +157,7 @@ a:hover { top: -100px; left: var(--space-4); padding: var(--space-2) var(--space-4); - background: var(--orange-500); + background: var(--pink-500); color: white; border-radius: var(--radius-sm); z-index: 1000; @@ -243,23 +256,57 @@ a:hover { border-radius: var(--radius-sm); } -.header-github { - font-size: 13px; - color: var(--text-secondary) !important; - font-weight: 500; +.header-icon-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: var(--radius-md); + border: none; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition-fast); + text-decoration: none; } -.header-github:hover { - color: var(--text-primary) !important; +.header-icon-btn:hover { + background: var(--bg-tertiary); + color: var(--text-primary); } -.theme-toggle { - background: none; - border: none; - cursor: pointer; - font-size: 18px; - padding: var(--space-1); - line-height: 1; +.search-trigger-text { + color: var(--text-muted); +} + +.search-trigger-keys { + margin-left: auto; + display: flex; + gap: 2px; +} + +.search-footer { + padding: var(--space-2) var(--space-4); + border-top: 1px solid var(--border-primary); + font-size: 12px; + color: var(--text-muted); +} + +.search-empty { + padding: var(--space-8) var(--space-4); + text-align: center; + color: var(--text-muted); +} + +.mobile-menu-btn { + display: none; +} + +@media (max-width: 768px) { + .mobile-menu-btn { display: flex; } + .search-trigger-text { display: none; } + .search-trigger-keys { display: none; } } /* ======================================== @@ -377,11 +424,9 @@ a:hover { .hero-backdrop { position: absolute; inset: 0; - background: radial-gradient( - ellipse at 50% 0%, - rgba(196, 117, 53, 0.12) 0%, - transparent 70% - ); + background: + radial-gradient(ellipse at 30% 0%, rgba(229, 53, 171, 0.12) 0%, transparent 60%), + radial-gradient(ellipse at 70% 20%, rgba(45, 212, 160, 0.08) 0%, transparent 50%); pointer-events: none; } @@ -455,14 +500,14 @@ a:hover { } .btn-primary { - background: var(--orange-500); + background: var(--pink-500); color: white; - border-color: var(--orange-500); + border-color: var(--pink-500); } .btn-primary:hover { - background: var(--orange-400); - border-color: var(--orange-400); + background: var(--pink-400); + border-color: var(--pink-400); color: white; } @@ -684,7 +729,7 @@ a:hover { .sidebar-link.active { color: var(--text-accent); - background: rgba(196, 117, 53, 0.1); + background: rgba(229, 53, 171, 0.1); font-weight: 500; } @@ -1044,7 +1089,7 @@ a:hover { .feedback-btn.selected { border-color: var(--text-accent); - background: rgba(196, 117, 53, 0.1); + background: rgba(229, 53, 171, 0.1); } /* ======================================== @@ -1192,3 +1237,296 @@ a:hover { font-size: 28px; } } + +/* ======================================== + Code Demo (Home page) - body/copy + ======================================== */ + +.code-demo-body { + position: relative; +} + +.code-demo-body .code-copy-btn { + position: absolute; + top: var(--space-3); + right: var(--space-3); + z-index: 5; + display: flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-3); + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 12px; + cursor: pointer; + font-family: var(--font-body); + transition: all var(--transition-fast); +} + +.code-demo-body .code-copy-btn:hover { + border-color: var(--border-secondary); + color: var(--text-secondary); +} + +.code-demo-body .code-copy-btn.copied { + color: var(--green-400); + border-color: var(--green-400); +} + +/* ======================================== + Interactive Demos + ======================================== */ + +.demo-card { + margin-bottom: var(--space-8); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + overflow: hidden; + background: var(--bg-secondary); +} + +.demo-header { + padding: var(--space-5) var(--space-6); + border-bottom: 1px solid var(--border-primary); +} + +.demo-header h3 { + margin: 0 0 var(--space-1) 0; + font-size: 16px; +} + +.demo-header p { + margin: 0; + font-size: 14px; + color: var(--text-secondary); +} + +.demo-body { + padding: var(--space-6); +} + +.demo-start-btn { + width: 100%; + justify-content: center; + padding: var(--space-4); +} + +.demo-controls { + display: flex; + align-items: center; + gap: var(--space-4); + margin-bottom: var(--space-5); + flex-wrap: wrap; +} + +.demo-label { + font-size: 13px; + color: var(--text-secondary); + font-weight: 500; +} + +.demo-wave-btn { + padding: var(--space-1) var(--space-3); + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + font-family: var(--font-body); + transition: all var(--transition-fast); +} + +.demo-wave-btn:hover { + border-color: var(--border-secondary); + color: var(--text-primary); +} + +.demo-wave-btn.active { + background: var(--pink-500); + border-color: var(--pink-500); + color: white; +} + +.demo-slider-group { + display: flex; + align-items: center; + gap: var(--space-3); + flex: 1; + min-width: 200px; +} + +.demo-slider { + flex: 1; + accent-color: var(--pink-500); + height: 4px; +} + +/* Piano keys */ +.piano-keys { + display: flex; + gap: var(--space-2); +} + +.piano-key { + flex: 1; + height: 100px; + background: var(--bg-elevated); + border: 1px solid var(--border-primary); + border-radius: 0 0 var(--radius-md) var(--radius-md); + color: var(--text-secondary); + font-size: 14px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: flex-end; + justify-content: center; + padding-bottom: var(--space-3); + transition: all 100ms ease; + font-family: var(--font-mono); +} + +.piano-key:hover { + background: var(--bg-tertiary); + border-color: var(--pink-400); +} + +.piano-key.active { + background: var(--pink-500); + border-color: var(--pink-400); + color: white; + transform: translateY(2px); +} + +/* Note grid (effects demo) */ +.demo-note-grid { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: var(--space-2); +} + +.demo-note-btn { + padding: var(--space-4) var(--space-2); + background: var(--bg-elevated); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + color: var(--text-secondary); + font-size: 14px; + font-weight: 500; + font-family: var(--font-mono); + cursor: pointer; + transition: all 100ms ease; +} + +.demo-note-btn:hover { + border-color: var(--green-400); + color: var(--text-primary); +} + +.demo-note-btn.active { + background: var(--green-500); + border-color: var(--green-400); + color: white; +} + +/* Sequencer */ +.sequencer-controls { + display: flex; + gap: var(--space-3); + margin-bottom: var(--space-5); +} + +.sequencer-grid { + display: grid; + grid-template-columns: repeat(8, 1fr); + gap: var(--space-2); +} + +.seq-step { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-4) var(--space-1); + background: var(--bg-elevated); + border: 2px solid var(--border-primary); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 100ms ease; +} + +.seq-step .seq-step-note { + font-size: 11px; + font-family: var(--font-mono); + color: var(--text-muted); +} + +.seq-step.on { + background: rgba(229, 53, 171, 0.15); + border-color: var(--pink-500); +} + +.seq-step.on .seq-step-note { + color: var(--pink-400); +} + +.seq-step.current { + box-shadow: 0 0 0 2px var(--green-400); +} + +.seq-step.on.current { + background: rgba(229, 53, 171, 0.3); + box-shadow: 0 0 0 2px var(--green-400); +} + +.seq-step:hover { + border-color: var(--text-muted); +} + +.btn-danger { + background: #ef4444; + color: white; + border-color: #ef4444; +} + +.btn-danger:hover { + background: #dc2626; + border-color: #dc2626; + color: white; +} + +/* Demo source code toggle */ +.demo-source { + border-top: 1px solid var(--border-primary); + padding: var(--space-4) var(--space-6); + background: var(--bg-primary); +} + +.demo-source .code-block { + margin-bottom: 0; +} + +@media (max-width: 768px) { + .piano-keys { + gap: var(--space-1); + } + + .piano-key { + height: 80px; + font-size: 12px; + } + + .demo-note-grid { + grid-template-columns: repeat(3, 1fr); + } + + .sequencer-grid { + grid-template-columns: repeat(4, 1fr); + } + + .demo-slider-group { + min-width: 100%; + } +} From 2a2380402fe6eca4a2caeb89b47a76e226bf9079 Mon Sep 17 00:00:00 2001 From: Bernardo Gurgel Date: Wed, 1 Apr 2026 19:12:14 +0000 Subject: [PATCH 03/17] feat(website): add floating table of contents to docs pages Adds a sticky TOC on the right side of documentation pages that: - Extracts h2/h3 headings from page content after render - Highlights the active section based on scroll position - Shows indented sub-entries for h3 headings - Hides on screens narrower than 1100px https://claude.ai/code/session_01B8MgrWxixKEWaxDiHyy8Bx --- website/src/DocsPage.res | 126 +++++++++++++++++++++++++++++++++++++++ website/src/styles.css | 65 ++++++++++++++++++++ 2 files changed, 191 insertions(+) diff --git a/website/src/DocsPage.res b/website/src/DocsPage.res index aaf7aeb..6a21c87 100644 --- a/website/src/DocsPage.res +++ b/website/src/DocsPage.res @@ -120,6 +120,131 @@ module DocsBreadcrumb = { } } +// ---- Table of Contents ---- +module TableOfContents = { + type props = {} + + type tocEntry = { + id: string, + text: string, + level: int, + } + + let make = (_props: props) => { + let entries: Signal.t> = Signal.make([]) + let activeId = Signal.make("") + + // Extract headings from the .docs-content container after render + let _ = Effect.run(() => { + let extract: unit => unit = %raw(`function() { + setTimeout(function() { + var container = document.querySelector('.docs-content'); + if (!container) return; + var headings = container.querySelectorAll('h2[id], h3[id]'); + var items = []; + headings.forEach(function(h) { + items.push({ + id: h.id, + text: h.textContent || '', + level: h.tagName === 'H2' ? 2 : 3 + }); + }); + if (items.length > 0) { + window.__tocSetEntries(items); + } + }, 100); + }`) + + let setEntries: array => unit = %raw(`function(items) { + window.__tocSetEntries = function() {}; + }`) + ignore(setEntries) + + // Register the setter globally so the raw JS can call it + let _: unit = %raw(`window.__tocSetEntries = function(items) {}`) + let registerSetter: (array => unit) => unit = %raw(`function(fn) { + window.__tocSetEntries = fn; + }`) + registerSetter(items => Signal.set(entries, items)) + + extract() + + // Scroll spy: track which heading is active + let onScroll: unit => unit = %raw(`function() { + var container = document.querySelector('.docs-content'); + if (!container) return; + var headings = container.querySelectorAll('h2[id], h3[id]'); + var current = ''; + var offset = 100; + headings.forEach(function(h) { + var rect = h.getBoundingClientRect(); + if (rect.top <= offset) { + current = h.id; + } + }); + if (window.__tocSetActive) { + window.__tocSetActive(current); + } + }`) + + let registerActive: (string => unit) => unit = %raw(`function(fn) { + window.__tocSetActive = fn; + }`) + registerActive(id => Signal.set(activeId, id)) + + let addScroll: unit => unit = %raw(`function() { + window.addEventListener('scroll', function() { + var container = document.querySelector('.docs-content'); + if (!container) return; + var headings = container.querySelectorAll('h2[id], h3[id]'); + var current = ''; + var offset = 100; + headings.forEach(function(h) { + var rect = h.getBoundingClientRect(); + if (rect.top <= offset) { + current = h.id; + } + }); + if (window.__tocSetActive) { + window.__tocSetActive(current); + } + }, { passive: true }); + }`) + ignore(onScroll) + addScroll() + + None + }) + + + } +} + // ---- Prev/Next ---- module PrevNextNav = { type props = {currentPath: string} @@ -217,6 +342,7 @@ let make = (props: props) => { + } /> diff --git a/website/src/styles.css b/website/src/styles.css index 0077abe..b1e550a 100644 --- a/website/src/styles.css +++ b/website/src/styles.css @@ -748,6 +748,71 @@ a:hover { margin-bottom: var(--space-12); } +/* ======================================== + Table of Contents (floating right) + ======================================== */ + +.docs-toc { + width: 200px; + flex-shrink: 0; + position: sticky; + top: calc(var(--header-height) + var(--space-8)); + height: fit-content; + max-height: calc(100vh - var(--header-height) - var(--space-16)); + overflow-y: auto; + padding: 0 var(--space-4) 0 var(--space-5); + border-left: 1px solid var(--border-primary); + scrollbar-width: thin; + scrollbar-color: var(--border-primary) transparent; +} + +.toc-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + margin-bottom: var(--space-3); +} + +.toc-nav { + display: flex; + flex-direction: column; +} + +.toc-link { + display: block; + font-size: 13px; + line-height: 1.4; + color: var(--text-muted); + padding: var(--space-1) 0; + border-left: 2px solid transparent; + padding-left: var(--space-3); + margin-left: calc(-1 * var(--space-5)); + transition: all var(--transition-fast); + text-decoration: none; +} + +.toc-link:hover { + color: var(--text-secondary); +} + +.toc-link.active { + color: var(--text-accent); + border-left-color: var(--pink-500); +} + +.toc-link-sub { + padding-left: calc(var(--space-3) + var(--space-3)); + font-size: 12px; +} + +@media (max-width: 1100px) { + .docs-toc { + display: none; + } +} + /* ======================================== Breadcrumbs ======================================== */ From 90b6f9b9385f233979619a4de999028357d516c5 Mon Sep 17 00:00:00 2001 From: Bernardo Gurgel Date: Wed, 1 Apr 2026 19:24:29 +0000 Subject: [PATCH 04/17] Fix DOM flickering by replacing signalFragment with computedAttr - Layout.res: Theme toggle renders both Sun/Moon icons, toggles visibility via computedAttr("style") instead of signalFragment icon swap - DocsPage.res: TOC separates entries (structural) from activeId (class-only) so scroll spy only updates CSS classes, not full DOM rebuild - Pages__Home.res: Copy button renders both Check/Copy icons with visibility toggle instead of signalFragment icon swap https://claude.ai/code/session_01B8MgrWxixKEWaxDiHyy8Bx --- website/src/DocsPage.res | 10 ++++----- website/src/Layout.res | 36 +++++++++++++++++++++++++------ website/src/pages/Pages__Home.res | 25 +++++++++++++++------ 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/website/src/DocsPage.res b/website/src/DocsPage.res index 6a21c87..da0f8af 100644 --- a/website/src/DocsPage.res +++ b/website/src/DocsPage.res @@ -222,17 +222,17 @@ module TableOfContents = { {Component.signalFragment( Computed.make(() => { let items = Signal.get(entries) - let active = Signal.get(activeId) items->Array.map(entry => { - let className = + let baseClass = "toc-link" ++ - (entry.level == 3 ? " toc-link-sub" : "") ++ - (active == entry.id ? " active" : "") + (entry.level == 3 ? " toc-link-sub" : "") Component.element( "a", ~attrs=[ Component.attr("href", "#" ++ entry.id), - Component.attr("class", className), + Component.computedAttr("class", () => + baseClass ++ (Signal.get(activeId) == entry.id ? " active" : "") + ), ], ~children=[Component.text(entry.text)], (), diff --git a/website/src/Layout.res b/website/src/Layout.res index 41738ae..0ff0ef6 100644 --- a/website/src/Layout.res +++ b/website/src/Layout.res @@ -70,6 +70,9 @@ module SearchModal = { type props = {} let make = (_props: props) => { + // The modal show/hide is fine with signalFragment since the whole modal + // is either present or absent. But the inner results list must NOT + // use signalFragment - use computedAttr for the active class instead. let searchContent = Computed.make(() => { if Signal.get(searchOpen) { [ @@ -124,6 +127,8 @@ module SearchModal = { {Component.text("ESC")}
+ // Use signalFragment only for the filtered list (which changes structurally), + // but use computedAttr for the active highlight (no DOM recreation). {Component.signalFragment( Computed.make(() => { let items = Signal.get(filteredItems) @@ -252,6 +257,8 @@ module Header = { ~children=[Basefn.Icon.make({name: GitHub, size: Sm})], (), )} + // Theme toggle: render BOTH icons, toggle visibility via style + // to avoid signalFragment destroying/recreating DOM nodes {Component.element( "button", ~attrs=[ @@ -260,12 +267,29 @@ module Header = { ], ~events=[("click", _ => toggleTheme())], ~children=[ - Component.signalFragment( - Computed.make(() => - Signal.get(Basefn.Theme.currentTheme) == Dark - ? [Basefn.Icon.make({name: Sun, size: Sm})] - : [Basefn.Icon.make({name: Moon, size: Sm})] - ), + Component.element( + "span", + ~attrs=[ + Component.computedAttr("style", () => + Signal.get(Basefn.Theme.currentTheme) == Dark + ? "display: inline-flex" + : "display: none" + ), + ], + ~children=[Basefn.Icon.make({name: Sun, size: Sm})], + (), + ), + Component.element( + "span", + ~attrs=[ + Component.computedAttr("style", () => + Signal.get(Basefn.Theme.currentTheme) == Dark + ? "display: none" + : "display: inline-flex" + ), + ], + ~children=[Basefn.Icon.make({name: Moon, size: Sm})], + (), ), ], (), diff --git a/website/src/pages/Pages__Home.res b/website/src/pages/Pages__Home.res index d8afa40..3a7f7fa 100644 --- a/website/src/pages/Pages__Home.res +++ b/website/src/pages/Pages__Home.res @@ -231,12 +231,25 @@ Transport.start()` ], ~events=[("click", _ => copyToClipboard())], ~children=[ - Component.signalFragment( - Computed.make(() => - Signal.get(copied) - ? [Basefn.Icon.make({name: Check, size: Sm}), Component.text(" Copied")] - : [Basefn.Icon.make({name: Copy, size: Sm}), Component.text(" Copy")] - ), + Component.element( + "span", + ~attrs=[ + Component.computedAttr("style", () => + Signal.get(copied) ? "display: inline-flex; align-items: center" : "display: none" + ), + ], + ~children=[Basefn.Icon.make({name: Check, size: Sm}), Component.text(" Copied")], + (), + ), + Component.element( + "span", + ~attrs=[ + Component.computedAttr("style", () => + Signal.get(copied) ? "display: none" : "display: inline-flex; align-items: center" + ), + ], + ~children=[Basefn.Icon.make({name: Copy, size: Sm}), Component.text(" Copy")], + (), ), ], (), From b878141063c524feb2d60a152eed7936809dfdcf Mon Sep 17 00:00:00 2001 From: Bernardo Gurgel Date: Wed, 1 Apr 2026 19:36:14 +0000 Subject: [PATCH 05/17] Lift Layout to App.res to persist shell across route changes Layout (Header, Footer, SearchModal) was rendered inside each page component, causing full DOM recreation on every route navigation. Moved Layout to wrap Router.routes in App.res so the shell persists and only page content swaps on navigation. https://claude.ai/code/session_01B8MgrWxixKEWaxDiHyy8Bx --- website/src/App.res | 158 +++++++++++++++--------------- website/src/DocsPage.res | 24 ++--- website/src/pages/Pages__Home.res | 4 +- 3 files changed, 92 insertions(+), 94 deletions(-) diff --git a/website/src/App.res b/website/src/App.res index c9fe9fe..e70447d 100644 --- a/website/src/App.res +++ b/website/src/App.res @@ -3,82 +3,84 @@ open Xote type props = {} let make = (_props: props) => { - Router.routes([ - { - pattern: "/", - render: _ => , - }, - { - pattern: "/getting-started", - render: _ => - } - />, - }, - { - pattern: "/api/core", - render: _ => - } - />, - }, - { - pattern: "/api/instruments", - render: _ => - } - />, - }, - { - pattern: "/api/effects", - render: _ => - } - />, - }, - { - pattern: "/api/sources", - render: _ => - } - />, - }, - { - pattern: "/api/components", - render: _ => - } - />, - }, - { - pattern: "/api/signals", - render: _ => - } - />, - }, - { - pattern: "/api/scheduling", - render: _ => - } - />, - }, - { - pattern: "/examples", - render: _ => - } - />, - }, - ]) + + {Router.routes([ + { + pattern: "/", + render: _ => , + }, + { + pattern: "/getting-started", + render: _ => + } + />, + }, + { + pattern: "/api/core", + render: _ => + } + />, + }, + { + pattern: "/api/instruments", + render: _ => + } + />, + }, + { + pattern: "/api/effects", + render: _ => + } + />, + }, + { + pattern: "/api/sources", + render: _ => + } + />, + }, + { + pattern: "/api/components", + render: _ => + } + />, + }, + { + pattern: "/api/signals", + render: _ => + } + />, + }, + { + pattern: "/api/scheduling", + render: _ => + } + />, + }, + { + pattern: "/examples", + render: _ => + } + />, + }, + ])} + } diff --git a/website/src/DocsPage.res b/website/src/DocsPage.res index da0f8af..6438feb 100644 --- a/website/src/DocsPage.res +++ b/website/src/DocsPage.res @@ -332,18 +332,14 @@ type props = { let make = (props: props) => { let {currentPath, content} = props - - -
- -
{content}
- - -
- -
- } - /> +
+ +
+ +
{content}
+ + +
+ +
} diff --git a/website/src/pages/Pages__Home.res b/website/src/pages/Pages__Home.res index 3a7f7fa..1ceafe4 100644 --- a/website/src/pages/Pages__Home.res +++ b/website/src/pages/Pages__Home.res @@ -321,10 +321,10 @@ module Community = { type props = {} let make = (_props: props) => { - + <> - + } From 7815d3eedbf30da77df6d99ba62fd7acd92897ff Mon Sep 17 00:00:00 2001 From: Bernardo Gurgel Date: Wed, 1 Apr 2026 19:49:21 +0000 Subject: [PATCH 06/17] Add interactive demos for melody, drums, filter, chords, and AM synth Replace code-only examples with fully interactive demos: - Melody Sequencer: Sequence + Transport with BPM control - Drum Machine: Kick/snare/hihat grid sequencer with toggleable steps - Filter Sweep: Sawtooth oscillator through resonant lowpass filter - Polyphonic Chords: PolySynth playing major/minor chords - AM Synth Pad: AMSynth with adjustable harmonicity and reverb https://claude.ai/code/session_01B8MgrWxixKEWaxDiHyy8Bx --- website/src/pages/Pages__Examples.res | 1005 ++++++++++++++++++++++--- website/src/styles.css | 144 ++++ 2 files changed, 1059 insertions(+), 90 deletions(-) diff --git a/website/src/pages/Pages__Examples.res b/website/src/pages/Pages__Examples.res index f9ab766..6c7d149 100644 --- a/website/src/pages/Pages__Examples.res +++ b/website/src/pages/Pages__Examples.res @@ -567,123 +567,948 @@ transport->Transport.start()`} } } -// ---- Page ---- -type props = {} +// ---- Melody Sequencer Demo ---- +module MelodyDemo = { + type props = {} + + let melodyNotes = ["C4", "D4", "E4", "G4", "A4", "G4", "E4", "D4"] + let melodyLabels = ["C4", "D4", "E4", "G4", "A4", "G4", "E4", "D4"] + + let make = (_props: props) => { + let isReady = Signal.make(false) + let isPlaying = Signal.make(false) + let currentStep = Signal.make(-1) + let bpm = Signal.make(120) + + let synthRef: ref> = ref(None) + let seqRef: ref> = ref(None) + + let initAudio = _ => { + let _ = Tone.Core.start() + let synth = Tone.Synth.makeWithOptions({ + oscillator: {\"type": Triangle}, + envelope: makeEnvelope(~attack=0.01, ~decay=0.1, ~sustain=0.3, ~release=0.5), + }) + synth->Tone.Synth.asAudioNode->Tone.AudioNode.toDestination->ignore + + let stepIdx = ref(0) + let seq = Tone.Sequence.makeWithSubdivision( + (_time, note) => { + let noteStr: string = Obj.magic(note) + synth->Tone.Synth.triggerAttackRelease(toFreq(noteStr), toTime("8n"))->ignore + Signal.set(currentStep, mod(stepIdx.contents, 8)) + stepIdx := stepIdx.contents + 1 + }, + melodyNotes->Array.map(n => Obj.magic(n)), + toTime("4n"), + ) + + let transport = Tone.Core.getTransport() + Tone.Transport.bpm(transport)->Tone.Param.setValue(120.0) + + synthRef := Some(synth) + seqRef := Some(seq) + Signal.set(isReady, true) + } + + let togglePlay = _ => { + if Signal.get(isPlaying) { + switch seqRef.contents { + | Some(seq) => seq->Tone.Sequence.stop->ignore + | None => () + } + Tone.Core.getTransport()->Tone.Transport.stop->ignore + Signal.set(isPlaying, false) + Signal.set(currentStep, -1) + } else { + switch seqRef.contents { + | Some(seq) => seq->Tone.Sequence.start->ignore + | None => () + } + Tone.Core.getTransport()->Tone.Transport.start->ignore + Signal.set(isPlaying, true) + } + } + + let changeBpm = evt => { + let val: string = Obj.magic(evt)["target"]["value"] + let intVal = Int.fromString(val)->Option.getOr(120) + Signal.set(bpm, intVal) + let transport = Tone.Core.getTransport() + Tone.Transport.bpm(transport)->Tone.Param.setValue(Int.toFloat(intVal)) + } + +
+
+

{Component.text("Melody Sequencer")}

+

{Component.text("Play a looping melody using Sequence and Transport. Adjust the BPM to change the tempo.")}

+
+
+ {Component.signalFragment( + Computed.make(() => { + if Signal.get(isReady) { + [ +
+ {Component.element( + "button", + ~attrs=[ + Component.computedAttr("class", () => + "btn " ++ (Signal.get(isPlaying) ? "btn-danger" : "btn-primary") + ), + ], + ~events=[("click", togglePlay)], + ~children=[ + Component.textSignal(() => + Signal.get(isPlaying) ? "Stop" : "Play" + ), + ], + (), + )} +
+ + {Component.textSignal(() => + "BPM: " ++ Int.toString(Signal.get(bpm)) + )} + + {Component.element( + "input", + ~attrs=[ + Component.attr("type", "range"), + Component.attr("min", "60"), + Component.attr("max", "200"), + Component.computedAttr("value", () => Int.toString(Signal.get(bpm))), + Component.attr("class", "demo-slider"), + ], + ~events=[("input", changeBpm)], + (), + )} +
+
, +
+ {Component.fragment( + melodyLabels->Array.mapWithIndex((label, i) => { + Component.element( + "button", + ~attrs=[ + Component.computedAttr("class", () => + "seq-step melody-step" ++ + " on" ++ + (Signal.get(currentStep) == i ? " current" : "") + ), + ], + ~events=[("click", _ => ())], + ~children=[ + {Component.text(label)} , + ], + (), + ) + }), + )} +
, + ] + } else { + [ + Component.element( + "button", + ~attrs=[Component.attr("class", "btn btn-primary demo-start-btn")], + ~events=[("click", initAudio)], + ~children=[Component.text("Start Audio")], + (), + ), + ] + } + }), + )} +
+
+ Synth.asAudioNode->AudioNode.toDestination + +let notes = ["C4", "D4", "E4", "G4", "A4", "G4", "E4", "D4"] + +let _seq = Sequence.makeWithSubdivision( + (_time, note) => { + synth->Synth.triggerAttackRelease(note, "8n") + }, notes, "4n" +) + +let transport = Core.getTransport() +transport->Transport.start()`} + /> +
+
+ } +} + +// ---- Drum Machine Demo ---- +module DrumMachine = { + type props = {} + + let make = (_props: props) => { + let isReady = Signal.make(false) + let isPlaying = Signal.make(false) + let currentBeat = Signal.make(-1) + let bpm = Signal.make(120) + + // 8 steps for each of 3 drum sounds + let kickPattern = Array.make(~length=8, false)->Array.map(v => Signal.make(v)) + let snarePattern = Array.make(~length=8, false)->Array.map(v => Signal.make(v)) + let hihatPattern = Array.make(~length=8, false)->Array.map(v => Signal.make(v)) + + // Preset a classic pattern + let _ = { + // Kick on 1, 5 + switch kickPattern->Array.get(0) { | Some(s) => Signal.set(s, true) | None => () } + switch kickPattern->Array.get(4) { | Some(s) => Signal.set(s, true) | None => () } + // Snare on 3, 7 + switch snarePattern->Array.get(2) { | Some(s) => Signal.set(s, true) | None => () } + switch snarePattern->Array.get(6) { | Some(s) => Signal.set(s, true) | None => () } + // Hihat on all even + [0, 2, 4, 6]->Array.forEach(i => + switch hihatPattern->Array.get(i) { | Some(s) => Signal.set(s, true) | None => () } + ) + } + + let kickRef: ref> = ref(None) + let snareRef: ref> = ref(None) + let hihatRef: ref> = ref(None) + let loopRef: ref> = ref(None) + + let initAudio = _ => { + let _ = Tone.Core.start() + + // Kick: low sine + let kick = Tone.Synth.makeWithOptions({ + oscillator: {\"type": Sine}, + envelope: makeEnvelope(~attack=0.001, ~decay=0.2, ~sustain=0.0, ~release=0.2), + }) + kick->Tone.Synth.asAudioNode->Tone.AudioNode.toDestination->ignore + + // Snare: white noise burst + let snare = Tone.Noise.makeWithOptions({ + \"type": White, + volume: -10.0, + }) + let snareEnv = Tone.Gain.make() + snare->Tone.Noise.asAudioNode + ->Tone.AudioNode.connect(snareEnv->Tone.Gain.asAudioNode) + ->ignore + snareEnv->Tone.Gain.asAudioNode->Tone.AudioNode.toDestination->ignore + snare->Tone.Noise.start->ignore + + // Hihat: filtered noise + let hihat = Tone.Noise.makeWithOptions({ + \"type": White, + volume: -18.0, + }) + let hihatFilter = Tone.Filter.makeWithOptions({ + frequency: 8000.0, + \"type": Highpass, + }) + hihat->Tone.Noise.asAudioNode + ->Tone.AudioNode.connect(hihatFilter->Tone.Filter.asAudioNode) + ->ignore + hihatFilter->Tone.Filter.asAudioNode->Tone.AudioNode.toDestination->ignore + hihat->Tone.Noise.start->ignore + + let transport = Tone.Core.getTransport() + Tone.Transport.bpm(transport)->Tone.Param.setValue(120.0) + + let stepIdx = ref(0) + let loop = Tone.Loop.make(_time => { + let idx = mod(stepIdx.contents, 8) + Signal.set(currentBeat, idx) + + // Kick + let kickOn = switch kickPattern->Array.get(idx) { + | Some(s) => Signal.get(s) + | None => false + } + if kickOn { + kick->Tone.Synth.triggerAttackRelease(toFreq("C1"), toTime("16n"))->ignore + } + + // Snare + let snareOn = switch snarePattern->Array.get(idx) { + | Some(s) => Signal.get(s) + | None => false + } + if snareOn { + Tone.Gain.gain(snareEnv)->Tone.Param.setValue(1.0) + Tone.Gain.gain(snareEnv)->Tone.Param.linearRampTo(0.0, toTime("32n"))->ignore + } + + // Hihat + let hihatOn = switch hihatPattern->Array.get(idx) { + | Some(s) => Signal.get(s) + | None => false + } + if hihatOn { + hihat->Tone.Noise.restart->ignore + } + + stepIdx := stepIdx.contents + 1 + }, toTime("8n")) + + kickRef := Some(kick) + snareRef := Some(snare) + hihatRef := Some(hihat) + loopRef := Some(loop) + Signal.set(isReady, true) + } + + let togglePlay = _ => { + if Signal.get(isPlaying) { + switch loopRef.contents { + | Some(loop) => loop->Tone.Loop.stop->ignore + | None => () + } + Tone.Core.getTransport()->Tone.Transport.stop->ignore + Signal.set(isPlaying, false) + Signal.set(currentBeat, -1) + } else { + switch loopRef.contents { + | Some(loop) => loop->Tone.Loop.start->ignore + | None => () + } + Tone.Core.getTransport()->Tone.Transport.start->ignore + Signal.set(isPlaying, true) + } + } + + let changeBpm = evt => { + let val: string = Obj.magic(evt)["target"]["value"] + let intVal = Int.fromString(val)->Option.getOr(120) + Signal.set(bpm, intVal) + Tone.Transport.bpm(Tone.Core.getTransport())->Tone.Param.setValue(Int.toFloat(intVal)) + } + + let drumLabels = ["Kick", "Snare", "Hi-hat"] + let patterns = [kickPattern, snarePattern, hihatPattern] + +
+
+

{Component.text("Drum Machine")}

+

{Component.text("A drum sequencer with kick, snare, and hi-hat. Toggle steps on/off to create your own beat.")}

+
+
+ {Component.signalFragment( + Computed.make(() => { + if Signal.get(isReady) { + [ +
+ {Component.element( + "button", + ~attrs=[ + Component.computedAttr("class", () => + "btn " ++ (Signal.get(isPlaying) ? "btn-danger" : "btn-primary") + ), + ], + ~events=[("click", togglePlay)], + ~children=[ + Component.textSignal(() => + Signal.get(isPlaying) ? "Stop" : "Play" + ), + ], + (), + )} +
+ + {Component.textSignal(() => + "BPM: " ++ Int.toString(Signal.get(bpm)) + )} + + {Component.element( + "input", + ~attrs=[ + Component.attr("type", "range"), + Component.attr("min", "60"), + Component.attr("max", "200"), + Component.computedAttr("value", () => Int.toString(Signal.get(bpm))), + Component.attr("class", "demo-slider"), + ], + ~events=[("input", changeBpm)], + (), + )} +
+
, +
+ {Component.fragment( + drumLabels->Array.mapWithIndex((label, rowIdx) => { + let pattern = switch patterns->Array.get(rowIdx) { + | Some(p) => p + | None => [] + } +
+ {Component.text(label)} +
+ {Component.fragment( + pattern->Array.mapWithIndex((stepSignal, colIdx) => { + Component.element( + "button", + ~attrs=[ + Component.computedAttr("class", () => { + let active = Signal.get(stepSignal) + let isCurrent = Signal.get(currentBeat) == colIdx + "drum-step" ++ + (active ? " on" : "") ++ + (isCurrent ? " current" : "") + }), + ], + ~events=[("click", _ => Signal.update(stepSignal, v => !v))], + (), + ) + }), + )} +
+
+ }), + )} +
, + ] + } else { + [ + Component.element( + "button", + ~attrs=[Component.attr("class", "btn btn-primary demo-start-btn")], + ~events=[("click", initAudio)], + ~children=[Component.text("Start Audio")], + (), + ), + ] + } + }), + )} +
+
+ Synth.asAudioNode->AudioNode.toDestination + +let snare = Noise.makeWithOptions({"type": "white", volume: -10.0}) +snare->Noise.asAudioNode->AudioNode.toDestination + +let hihat = Noise.makeWithOptions({"type": "white", volume: -18.0}) +let hpf = Filter.makeWithOptions({frequency: 8000.0, "type": "highpass"}) +hihat->Noise.asAudioNode->AudioNode.connect(hpf->Filter.asAudioNode) +hpf->Filter.asAudioNode->AudioNode.toDestination + +let _loop = Loop.make(_time => { + kick->Synth.triggerAttackRelease("C1", "16n") +}, "8n") + +let transport = Core.getTransport() +transport->Transport.start()`} + /> +
+
+ } +} + +// ---- Filter Sweep Demo ---- +module FilterSweep = { + type props = {} + + let make = (_props: props) => { + let isReady = Signal.make(false) + let isPlaying = Signal.make(false) + let cutoff = Signal.make(500) + let resonance = Signal.make(1) + + let oscRef: ref> = ref(None) + let filterRef: ref> = ref(None) + + let initAudio = _ => { + let _ = Tone.Core.start() + let osc = Tone.Oscillator.makeWithOptions({ + frequency: 110.0, + \"type": Sawtooth, + volume: -12.0, + }) + let filter = Tone.Filter.makeWithOptions({ + frequency: 500.0, + \"type": Lowpass, + rolloff: R24, + q: 1.0, + }) + osc->Tone.Oscillator.asAudioNode + ->Tone.AudioNode.connect(filter->Tone.Filter.asAudioNode) + ->ignore + filter->Tone.Filter.asAudioNode->Tone.AudioNode.toDestination->ignore + + oscRef := Some(osc) + filterRef := Some(filter) + Signal.set(isReady, true) + } + + let togglePlay = _ => { + if Signal.get(isPlaying) { + switch oscRef.contents { + | Some(osc) => osc->Tone.Oscillator.stop->ignore + | None => () + } + Signal.set(isPlaying, false) + } else { + switch oscRef.contents { + | Some(osc) => osc->Tone.Oscillator.start->ignore + | None => () + } + Signal.set(isPlaying, true) + } + } + + let changeCutoff = evt => { + let val: string = Obj.magic(evt)["target"]["value"] + let intVal = Int.fromString(val)->Option.getOr(500) + Signal.set(cutoff, intVal) + switch filterRef.contents { + | Some(filter) => + Tone.Filter.frequency(filter)->Tone.Param.setValue(Int.toFloat(intVal)) + | None => () + } + } + + let changeResonance = evt => { + let val: string = Obj.magic(evt)["target"]["value"] + let intVal = Int.fromString(val)->Option.getOr(1) + Signal.set(resonance, intVal) + switch filterRef.contents { + | Some(filter) => + Tone.Filter.q(filter)->Tone.Param.setValue(Int.toFloat(intVal)) + | None => () + } + } + +
+
+

{Component.text("Filter Sweep")}

+

{Component.text("A sawtooth oscillator through a resonant low-pass filter. Sweep the cutoff frequency and resonance.")}

+
+
+ {Component.signalFragment( + Computed.make(() => { + if Signal.get(isReady) { + [ +
+ {Component.element( + "button", + ~attrs=[ + Component.computedAttr("class", () => + "btn " ++ (Signal.get(isPlaying) ? "btn-danger" : "btn-primary") + ), + ], + ~events=[("click", togglePlay)], + ~children=[ + Component.textSignal(() => + Signal.get(isPlaying) ? "Stop" : "Play" + ), + ], + (), + )} +
, +
+
+ + {Component.textSignal(() => + "Cutoff: " ++ Int.toString(Signal.get(cutoff)) ++ " Hz" + )} + + {Component.element( + "input", + ~attrs=[ + Component.attr("type", "range"), + Component.attr("min", "50"), + Component.attr("max", "8000"), + Component.computedAttr("value", () => Int.toString(Signal.get(cutoff))), + Component.attr("class", "demo-slider"), + ], + ~events=[("input", changeCutoff)], + (), + )} +
+
+ + {Component.textSignal(() => + "Resonance (Q): " ++ Int.toString(Signal.get(resonance)) + )} + + {Component.element( + "input", + ~attrs=[ + Component.attr("type", "range"), + Component.attr("min", "0"), + Component.attr("max", "20"), + Component.computedAttr("value", () => Int.toString(Signal.get(resonance))), + Component.attr("class", "demo-slider"), + ], + ~events=[("input", changeResonance)], + (), + )} +
+
, + ] + } else { + [ + Component.element( + "button", + ~attrs=[Component.attr("class", "btn btn-primary demo-start-btn")], + ~events=[("click", initAudio)], + ~children=[Component.text("Start Audio")], + (), + ), + ] + } + }), + )} +
+
+ { -
-

{Component.text("Interactive Examples")}

-

- {Component.text( - "Try these live demos built with rescript-tone and xote. Click \"Start Audio\" to initialize the Web Audio context, then interact with each demo.", - )} -

+let osc = Oscillator.makeWithOptions({ + frequency: 110.0, + "type": "sawtooth", +}) -

{Component.text("Synth Keyboard")}

- +let filter = Filter.makeWithOptions({ + frequency: 500.0, + "type": "lowpass", + rolloff: -24, + q: 1.0, +}) -

{Component.text("Effects Chain")}

- +osc->Oscillator.asAudioNode +->AudioNode.connect(filter->Filter.asAudioNode) +->AudioNode.toDestination -

{Component.text("Step Sequencer")}

- +// Sweep the filter cutoff +filter->Filter.frequency->Param.rampTo(2000.0, "2m") -

{Component.text("More Code Examples")}

+osc->Oscillator.start()`} + /> +
+
+ } +} -

{Component.text("Simple Melody")}

-

- {Component.text( - "Play a sequence of notes using Sequence and the Transport.", - )} -

- Synth.asAudioNode->AudioNode.toDestination + type chord = { + name: string, + notes: array, + } -let notes = ["C4", "D4", "E4", "F4", "G4", "A4", "B4", "C5"] + let chords = [ + {name: "Cmaj", notes: ["C4", "E4", "G4"]}, + {name: "Fmaj", notes: ["F3", "A3", "C4"]}, + {name: "Am", notes: ["A3", "C4", "E4"]}, + {name: "G", notes: ["G3", "B3", "D4"]}, + {name: "Dm", notes: ["D3", "F3", "A3"]}, + {name: "Em", notes: ["E3", "G3", "B3"]}, + ] -let _seq = Sequence.make((_time, note) => { - synth->Synth.triggerAttackRelease(note, "8n") -}, notes, "4n") + let make = (_props: props) => { + let isReady = Signal.make(false) + let activeChord = Signal.make("") -let startButton = async () => { - await Core.start() - let transport = Core.getTransport() - transport->Transport.start() -}`} - /> + let synthRef: ref> = ref(None) -

{Component.text("Drum Pattern")}

-

- {Component.text( - "Use Loop and Transport to create a repeating drum pattern.", - )} -

- { + let _ = Tone.Core.start() + let synth = Tone.PolySynth.makeWithOptions({ + maxPolyphony: 6, + volume: -8.0, + }) + synth->Tone.PolySynth.asAudioNode->Tone.AudioNode.toDestination->ignore + synthRef := Some(synth) + Signal.set(isReady, true) + } -let kick = Synth.makeWithOptions({ - oscillator: {"type": "sine"}, - envelope: {attack: 0.001, decay: 0.2, sustain: 0.0, release: 0.2}, -}) -kick->Synth.asAudioNode->AudioNode.toDestination + let playChord = (chord: chord) => _ => { + switch synthRef.contents { + | Some(synth) => { + let freqs = chord.notes->Array.map(n => toFreq(n)) + synth->Tone.PolySynth.triggerAttackRelease(freqs, toTime("2n"))->ignore + Signal.set(activeChord, chord.name) + setTimeout(() => Signal.set(activeChord, ""), 500) + } + | None => () + } + } + +
+
+

{Component.text("Polyphonic Chords")}

+

{Component.text("Play chords using PolySynth. Each button triggers multiple notes simultaneously.")}

+
+
+ {Component.signalFragment( + Computed.make(() => { + if Signal.get(isReady) { + [ +
+ {Component.fragment( + chords->Array.map(chord => { + Component.element( + "button", + ~attrs=[ + Component.computedAttr("class", () => + "chord-btn" ++ + (Signal.get(activeChord) == chord.name ? " active" : "") + ), + ], + ~events=[("click", playChord(chord))], + ~children=[ + {Component.text(chord.name)} , + + {Component.text(chord.notes->Array.join(", "))} + , + ], + (), + ) + }), + )} +
, + ] + } else { + [ + Component.element( + "button", + ~attrs=[Component.attr("class", "btn btn-primary demo-start-btn")], + ~events=[("click", initAudio)], + ~children=[Component.text("Start Audio")], + (), + ), + ] + } + }), + )} +
+
+ Noise.asAudioNode->AudioNode.toDestination +synth->PolySynth.asAudioNode->AudioNode.toDestination -let transport = Core.getTransport() +// Play a C major chord +synth->PolySynth.triggerAttackRelease( + ["C4", "E4", "G4"], "2n" +)`} + /> +
+
+ } +} -// Kick on every beat -let _kickLoop = Loop.make(_time => { - kick->Synth.triggerAttackRelease("C1", "8n") -}, "4n") +// ---- AM Synth Pad Demo ---- +module AMSynthPad = { + type props = {} -// Hi-hat on every eighth note -let _hatLoop = Loop.make(_time => { - hihat->Noise.start() - hihat->Noise.stop(~time="+32n") -}, "8n") + let make = (_props: props) => { + let isReady = Signal.make(false) + let activeNote = Signal.make("") + let harmonicity = Signal.make(3) -transport->Transport.start()`} - /> + let synthRef: ref> = ref(None) + let reverbRef: ref> = ref(None) + + let initAudio = _ => { + let _ = Tone.Core.start() + let reverb = Tone.Reverb.makeWithOptions({decay: 4.0, wet: 0.6}) + let synth = Tone.AMSynth.makeWithOptions({ + harmonicity: 3.0, + oscillator: {\"type": Sine}, + modulation: {\"type": Square}, + envelope: makeEnvelope(~attack=0.5, ~decay=0.3, ~sustain=0.8, ~release=1.5), + modulationEnvelope: makeEnvelope(~attack=0.2, ~decay=0.1, ~sustain=0.5, ~release=0.8), + }) + synth->Tone.AMSynth.asAudioNode + ->Tone.AudioNode.connect(reverb->Tone.Reverb.asAudioNode) + ->ignore + reverb->Tone.Reverb.asAudioNode->Tone.AudioNode.toDestination->ignore + synthRef := Some(synth) + reverbRef := Some(reverb) + Signal.set(isReady, true) + } + + let notes = ["C3", "E3", "G3", "A3", "C4", "E4"] + + let playNote = (note: string) => _ => { + switch synthRef.contents { + | Some(synth) => { + synth->Tone.AMSynth.triggerAttackRelease(toFreq(note), toTime("1n"))->ignore + Signal.set(activeNote, note) + setTimeout(() => Signal.set(activeNote, ""), 600) + } + | None => () + } + } + + let changeHarmonicity = evt => { + let val: string = Obj.magic(evt)["target"]["value"] + let intVal = Int.fromString(val)->Option.getOr(3) + Signal.set(harmonicity, intVal) + // Recreate synth with new harmonicity + switch (synthRef.contents, reverbRef.contents) { + | (Some(_), Some(reverb)) => { + let synth = Tone.AMSynth.makeWithOptions({ + harmonicity: Int.toFloat(intVal), + oscillator: {\"type": Sine}, + modulation: {\"type": Square}, + envelope: makeEnvelope(~attack=0.5, ~decay=0.3, ~sustain=0.8, ~release=1.5), + modulationEnvelope: makeEnvelope(~attack=0.2, ~decay=0.1, ~sustain=0.5, ~release=0.8), + }) + synth->Tone.AMSynth.asAudioNode + ->Tone.AudioNode.connect(reverb->Tone.Reverb.asAudioNode) + ->ignore + synthRef := Some(synth) + } + | _ => () + } + } + +
+
+

{Component.text("AM Synth Pad")}

+

{Component.text("Amplitude Modulation synthesis with reverb. Adjust harmonicity to change the modulation character.")}

+
+
+ {Component.signalFragment( + Computed.make(() => { + if Signal.get(isReady) { + [ +
+
+ + {Component.textSignal(() => + "Harmonicity: " ++ Int.toString(Signal.get(harmonicity)) + )} + + {Component.element( + "input", + ~attrs=[ + Component.attr("type", "range"), + Component.attr("min", "1"), + Component.attr("max", "12"), + Component.computedAttr("value", () => Int.toString(Signal.get(harmonicity))), + Component.attr("class", "demo-slider"), + ], + ~events=[("input", changeHarmonicity)], + (), + )} +
+
, +
+ {Component.fragment( + notes->Array.map(note => { + Component.element( + "button", + ~attrs=[ + Component.computedAttr("class", () => + "demo-note-btn pad-btn" ++ + (Signal.get(activeNote) == note ? " active" : "") + ), + ], + ~events=[("click", playNote(note))], + ~children=[Component.text(note)], + (), + ) + }), + )} +
, + ] + } else { + [ + Component.element( + "button", + ~attrs=[Component.attr("class", "btn btn-primary demo-start-btn")], + ~events=[("click", initAudio)], + ~children=[Component.text("Start Audio")], + (), + ), + ] + } + }), + )} +
+
+ AMSynth.asAudioNode +->AudioNode.connect(reverb->Reverb.asAudioNode) +->AudioNode.toDestination + +synth->AMSynth.triggerAttackRelease("C3", "1n")`} + /> +
+
+ } +} -

{Component.text("Parameter Automation")}

+// ---- Page ---- +type props = {} + +let make = (_props: props) => { +
+

{Component.text("Interactive Examples")}

{Component.text( - "Automate parameters over time for expressive control.", + "Try these live demos built with rescript-tone and xote. Click \"Start Audio\" to initialize the Web Audio context, then interact with each demo.", )}

- {Component.text("Synth Keyboard")} + -let filter = Filter.makeWithOptions({ - frequency: 500.0, - "type": "lowpass", - rolloff: -24, -}) +

{Component.text("Effects Chain")}

+ -osc->Oscillator.asAudioNode -->AudioNode.connect(filter->Filter.asAudioNode) -->AudioNode.toDestination +

{Component.text("Step Sequencer")}

+ -// Automate filter cutoff -let cutoff = filter->Filter.frequency -cutoff->Param.rampTo(2000.0, "2m") +

{Component.text("Melody Sequencer")}

+ -osc->Oscillator.start()`} - /> +

{Component.text("Drum Machine")}

+ + +

{Component.text("Filter Sweep")}

+ + +

{Component.text("Polyphonic Chords")}

+ + +

{Component.text("AM Synth Pad")}

+
} diff --git a/website/src/styles.css b/website/src/styles.css index b1e550a..46d539b 100644 --- a/website/src/styles.css +++ b/website/src/styles.css @@ -1594,4 +1594,148 @@ a:hover { .demo-slider-group { min-width: 100%; } + + .drum-grid { + gap: var(--space-2); + } + + .drum-steps { + grid-template-columns: repeat(4, 1fr); + } + + .chord-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +/* ---- Drum Machine ---- */ +.drum-grid { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.drum-row { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.drum-label { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + min-width: 52px; + text-align: right; +} + +.drum-steps { + display: grid; + grid-template-columns: repeat(8, 1fr); + gap: var(--space-1); + flex: 1; +} + +.drum-step { + aspect-ratio: 1; + border: 2px solid var(--border-primary); + border-radius: var(--radius-sm); + background: var(--bg-elevated); + cursor: pointer; + transition: all 0.1s ease; + min-height: 32px; +} + +.drum-step:hover { + border-color: var(--text-muted); +} + +.drum-step.on { + background: rgba(229, 53, 171, 0.2); + border-color: var(--pink-500); +} + +.drum-step.current { + box-shadow: 0 0 0 2px var(--green-400); +} + +.drum-step.on.current { + background: rgba(229, 53, 171, 0.4); + box-shadow: 0 0 0 2px var(--green-400); +} + +/* ---- Chord Grid ---- */ +.chord-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--space-3); +} + +.chord-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-1); + padding: var(--space-4) var(--space-3); + background: var(--bg-elevated); + border: 2px solid var(--border-primary); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.15s ease; +} + +.chord-btn:hover { + border-color: var(--pink-400); + transform: translateY(-1px); +} + +.chord-btn.active { + background: var(--pink-500); + border-color: var(--pink-400); + color: white; + transform: scale(0.96); +} + +.chord-name { + font-size: 18px; + font-weight: 700; + color: var(--text-primary); +} + +.chord-btn.active .chord-name { + color: white; +} + +.chord-notes { + font-size: 12px; + font-family: var(--font-mono); + color: var(--text-muted); +} + +.chord-btn.active .chord-notes { + color: rgba(255, 255, 255, 0.8); +} + +/* ---- AM Synth Pad Buttons ---- */ +.demo-note-btn.pad-btn { + border-radius: var(--radius-lg); + padding: var(--space-5) var(--space-3); + font-size: 14px; + font-weight: 600; +} + +.demo-note-btn.pad-btn.active { + background: linear-gradient(135deg, var(--pink-500), var(--green-500)); + border-color: transparent; +} + +/* ---- Melody step override ---- */ +.seq-step.melody-step { + background: rgba(34, 197, 94, 0.1); + border-color: var(--green-500); +} + +.seq-step.melody-step.current { + background: rgba(34, 197, 94, 0.3); + box-shadow: 0 0 0 2px var(--pink-400); } From cb11e7acc2fb55e7975db546f908e61ec4f45b10 Mon Sep 17 00:00:00 2001 From: Bernardo Gurgel Date: Wed, 1 Apr 2026 19:52:11 +0000 Subject: [PATCH 07/17] Fix drum machine white noise by replacing Noise sources with Synths Noise generators were started immediately on init, producing continuous white noise. Replaced snare and hihat with short Synth bursts that only sound on trigger (triangle for snare, square for hihat). https://claude.ai/code/session_01B8MgrWxixKEWaxDiHyy8Bx --- website/src/pages/Pages__Examples.res | 49 +++++++++++---------------- 1 file changed, 20 insertions(+), 29 deletions(-) diff --git a/website/src/pages/Pages__Examples.res b/website/src/pages/Pages__Examples.res index 6c7d149..8aedb2f 100644 --- a/website/src/pages/Pages__Examples.res +++ b/website/src/pages/Pages__Examples.res @@ -776,46 +776,35 @@ module DrumMachine = { } let kickRef: ref> = ref(None) - let snareRef: ref> = ref(None) - let hihatRef: ref> = ref(None) + let snareRef: ref> = ref(None) + let hihatRef: ref> = ref(None) let loopRef: ref> = ref(None) let initAudio = _ => { let _ = Tone.Core.start() - // Kick: low sine + // Kick: low sine with fast decay let kick = Tone.Synth.makeWithOptions({ oscillator: {\"type": Sine}, envelope: makeEnvelope(~attack=0.001, ~decay=0.2, ~sustain=0.0, ~release=0.2), }) kick->Tone.Synth.asAudioNode->Tone.AudioNode.toDestination->ignore - // Snare: white noise burst - let snare = Tone.Noise.makeWithOptions({ - \"type": White, - volume: -10.0, + // Snare: triangle burst at mid frequency for a snappy sound + let snare = Tone.Synth.makeWithOptions({ + oscillator: {\"type": Triangle}, + envelope: makeEnvelope(~attack=0.001, ~decay=0.12, ~sustain=0.0, ~release=0.1), + volume: -6.0, }) - let snareEnv = Tone.Gain.make() - snare->Tone.Noise.asAudioNode - ->Tone.AudioNode.connect(snareEnv->Tone.Gain.asAudioNode) - ->ignore - snareEnv->Tone.Gain.asAudioNode->Tone.AudioNode.toDestination->ignore - snare->Tone.Noise.start->ignore + snare->Tone.Synth.asAudioNode->Tone.AudioNode.toDestination->ignore - // Hihat: filtered noise - let hihat = Tone.Noise.makeWithOptions({ - \"type": White, - volume: -18.0, - }) - let hihatFilter = Tone.Filter.makeWithOptions({ - frequency: 8000.0, - \"type": Highpass, + // Hihat: square burst at high frequency for a metallic tick + let hihat = Tone.Synth.makeWithOptions({ + oscillator: {\"type": Square}, + envelope: makeEnvelope(~attack=0.001, ~decay=0.05, ~sustain=0.0, ~release=0.05), + volume: -12.0, }) - hihat->Tone.Noise.asAudioNode - ->Tone.AudioNode.connect(hihatFilter->Tone.Filter.asAudioNode) - ->ignore - hihatFilter->Tone.Filter.asAudioNode->Tone.AudioNode.toDestination->ignore - hihat->Tone.Noise.start->ignore + hihat->Tone.Synth.asAudioNode->Tone.AudioNode.toDestination->ignore let transport = Tone.Core.getTransport() Tone.Transport.bpm(transport)->Tone.Param.setValue(120.0) @@ -840,8 +829,7 @@ module DrumMachine = { | None => false } if snareOn { - Tone.Gain.gain(snareEnv)->Tone.Param.setValue(1.0) - Tone.Gain.gain(snareEnv)->Tone.Param.linearRampTo(0.0, toTime("32n"))->ignore + snare->Tone.Synth.triggerAttackRelease(toFreq("G3"), toTime("32n"))->ignore } // Hihat @@ -850,7 +838,7 @@ module DrumMachine = { | None => false } if hihatOn { - hihat->Tone.Noise.restart->ignore + hihat->Tone.Synth.triggerAttackRelease(toFreq("C6"), toTime("32n"))->ignore } stepIdx := stepIdx.contents + 1 @@ -863,6 +851,9 @@ module DrumMachine = { Signal.set(isReady, true) } + // Suppress unused variable warnings + let _ = (kickRef, snareRef, hihatRef) + let togglePlay = _ => { if Signal.get(isPlaying) { switch loopRef.contents { From 599f50ec6b5bf0a2754d7ddf1891280b1319336b Mon Sep 17 00:00:00 2001 From: Bernardo Gurgel Date: Wed, 1 Apr 2026 19:54:35 +0000 Subject: [PATCH 08/17] Add GitHub Pages deployment workflow - deploy.yml: builds root rescript bindings + website, deploys via actions/deploy-pages - 404.html copy in build script for SPA client-side routing support - Vite base path already set to /rescript-tone/ https://claude.ai/code/session_01B8MgrWxixKEWaxDiHyy8Bx --- .github/workflows/deploy.yml | 53 ++++++++++++++++++++++++++++++++++++ website/package.json | 2 +- 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..2ff9195 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,53 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + # Install root deps (rescript-tone itself) + - run: npm ci + + # Install website deps + - run: npm ci + working-directory: website + + # Build ReScript bindings (root), then website + - run: npx rescript + + - run: npm run build + working-directory: website + + - uses: actions/upload-pages-artifact@v3 + with: + path: website/dist + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/website/package.json b/website/package.json index 6bc865d..63c1625 100644 --- a/website/package.json +++ b/website/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "rescript -w & vite", - "build": "rescript && vite build", + "build": "rescript && vite build && cp dist/index.html dist/404.html", "preview": "vite preview", "res:build": "rescript", "res:watch": "rescript -w", From 0bb2c273d14cb6eb27f88c4aa24efa20b911ec99 Mon Sep 17 00:00:00 2001 From: Bernardo Gurgel Date: Wed, 1 Apr 2026 20:04:43 +0000 Subject: [PATCH 09/17] Add static site generation with Xote SSR prerendering Prerender all routes to static HTML at build time for GitHub Pages: - scripts/prerender.mjs: uses Router.initSSR + SSR.renderNodeToString to render each route and inject into the Vite-built HTML shell - scripts/dom-shim.mjs: browser API polyfills for Node.js SSR - scripts/node-loader.mjs: ESM loader that stubs CSS/asset imports - Main.res: detects SSR content and hydrates instead of fresh mount - 404.html generated as SPA fallback for unknown routes https://claude.ai/code/session_01B8MgrWxixKEWaxDiHyy8Bx --- website/package.json | 2 +- website/scripts/dom-shim.mjs | 106 +++++++++++++++++++++++ website/scripts/node-loader-register.mjs | 8 ++ website/scripts/node-loader.mjs | 16 ++++ website/scripts/prerender.mjs | 79 +++++++++++++++++ website/src/Main.res | 13 ++- 6 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 website/scripts/dom-shim.mjs create mode 100644 website/scripts/node-loader-register.mjs create mode 100644 website/scripts/node-loader.mjs create mode 100644 website/scripts/prerender.mjs diff --git a/website/package.json b/website/package.json index 63c1625..585521f 100644 --- a/website/package.json +++ b/website/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "rescript -w & vite", - "build": "rescript && vite build && cp dist/index.html dist/404.html", + "build": "rescript && vite build && node --import ./scripts/node-loader-register.mjs scripts/prerender.mjs", "preview": "vite preview", "res:build": "rescript", "res:watch": "rescript -w", diff --git a/website/scripts/dom-shim.mjs b/website/scripts/dom-shim.mjs new file mode 100644 index 0000000..47f15d5 --- /dev/null +++ b/website/scripts/dom-shim.mjs @@ -0,0 +1,106 @@ +/** + * Minimal DOM/browser API shims so the compiled ReScript modules + * can be imported in Node.js for SSR prerendering. + */ + +// localStorage stub +globalThis.localStorage = { + _store: {}, + getItem(key) { + return this._store[key] ?? null; + }, + setItem(key, value) { + this._store[key] = String(value); + }, + removeItem(key) { + delete this._store[key]; + }, + clear() { + this._store = {}; + }, +}; + +// window stub +globalThis.window = globalThis; +globalThis.window.__XOTE_HYDRATED__ = false; +globalThis.window.__XOTE_STATE__ = {}; + +// Minimal document stub — enough for Basefn theme init and other module-level code. +const noopEl = { + classList: { add() {}, remove() {}, toggle() {}, contains: () => false }, + setAttribute() {}, + getAttribute: () => null, + removeAttribute() {}, + style: {}, + innerHTML: "", + textContent: "", + appendChild: () => noopEl, + removeChild: () => noopEl, + querySelectorAll: () => [], + querySelector: () => null, + addEventListener() {}, + removeEventListener() {}, + children: [], + childNodes: [], + parentNode: null, + tagName: "DIV", + id: "", +}; + +globalThis.document = { + documentElement: { ...noopEl }, + body: { ...noopEl }, + head: { ...noopEl }, + createElement: () => ({ ...noopEl }), + createTextNode: (t) => ({ ...noopEl, textContent: t }), + createComment: (t) => ({ ...noopEl, textContent: t }), + createDocumentFragment: () => ({ ...noopEl, children: [] }), + getElementById: () => null, + querySelector: () => null, + querySelectorAll: () => [], + addEventListener() {}, + removeEventListener() {}, +}; + +// history / location stubs +globalThis.history = { + pushState() {}, + replaceState() {}, + state: null, +}; + +globalThis.location = { + pathname: "/", + search: "", + hash: "", + href: "http://localhost/", + origin: "http://localhost", +}; + +// addEventListener / scrollTo stubs +globalThis.addEventListener = () => {}; +globalThis.removeEventListener = () => {}; +globalThis.scrollTo = () => {}; + +// navigator stub +try { + globalThis.navigator = { + userAgent: "node", + clipboard: { writeText: () => Promise.resolve() }, + }; +} catch { + // navigator is read-only in some Node versions +} + +// Animation frame stubs +globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); +globalThis.cancelAnimationFrame = (id) => clearTimeout(id); + +// setTimeout / setInterval already exist in Node + +// matchMedia stub (used by some theme detection) +globalThis.matchMedia = () => ({ + matches: false, + addEventListener: () => {}, + removeEventListener: () => {}, +}); diff --git a/website/scripts/node-loader-register.mjs b/website/scripts/node-loader-register.mjs new file mode 100644 index 0000000..2a4b5e7 --- /dev/null +++ b/website/scripts/node-loader-register.mjs @@ -0,0 +1,8 @@ +/** + * Register the custom ESM loader using the non-deprecated module.register() API. + * Used via: node --import ./scripts/node-loader-register.mjs + */ +import { register } from "node:module"; +import { pathToFileURL } from "node:url"; + +register("./scripts/node-loader.mjs", pathToFileURL("./")); diff --git a/website/scripts/node-loader.mjs b/website/scripts/node-loader.mjs new file mode 100644 index 0000000..f230bce --- /dev/null +++ b/website/scripts/node-loader.mjs @@ -0,0 +1,16 @@ +/** + * Custom Node.js ESM loader that stubs non-JS asset imports + * (CSS, SVG, images) so the app modules can be loaded in Node for SSR. + */ + +const assetExtensions = [".css", ".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp"]; + +export async function resolve(specifier, context, nextResolve) { + if (assetExtensions.some((ext) => specifier.endsWith(ext))) { + return { + shortCircuit: true, + url: "data:text/javascript,export default ''", + }; + } + return nextResolve(specifier, context); +} diff --git a/website/scripts/prerender.mjs b/website/scripts/prerender.mjs new file mode 100644 index 0000000..b75deb3 --- /dev/null +++ b/website/scripts/prerender.mjs @@ -0,0 +1,79 @@ +/** + * Static Site Generation for rescript-tone docs. + * + * For every route: + * 1. Calls Router.initSSR to set the path (no browser APIs). + * 2. Calls App.make({}) to build the virtual node tree. + * 3. Calls SSR.renderNodeToString to serialise it to HTML. + * 4. Injects the markup into the Vite-built index.html shell. + * 5. Writes the result to dist//index.html. + * + * Run with: node --loader ./scripts/node-loader.mjs scripts/prerender.mjs + */ + +// DOM/browser shims must load before any app code +import "./dom-shim.mjs"; + +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const distDir = join(__dirname, "..", "dist"); + +const routes = [ + "/", + "/getting-started", + "/api/core", + "/api/instruments", + "/api/effects", + "/api/sources", + "/api/components", + "/api/signals", + "/api/scheduling", + "/examples", +]; + +// Read the Vite-built shell +const shell = readFileSync(join(distDir, "index.html"), "utf-8"); + +// Import Xote and the compiled App component +const { Router, SSR } = await import("xote"); +const App = await import("../src/App.res.mjs"); + +console.log("Prerendering routes...\n"); + +for (const route of routes) { + // Initialise the router for this path (server-safe, no DOM access) + // JS signature: initSSR(basePath, pathname, search, hash, _unit) + Router.initSSR("/rescript-tone", route, "", "", undefined); + + // Build the component tree (App.make already wraps with Layout) + const appNode = App.make({}); + + // Render the virtual node tree to an HTML string + const html = SSR.renderNodeToString(appNode); + + // Inject into the shell, replacing the empty #app div + const page = shell.replace( + '
', + `
${html}
` + ); + + // Write to the correct path + const outPath = + route === "/" + ? join(distDir, "index.html") + : join(distDir, route.slice(1), "index.html"); + + mkdirSync(dirname(outPath), { recursive: true }); + writeFileSync(outPath, page, "utf-8"); + console.log(` ✓ ${route}`); +} + +// Also write 404.html from the index shell (SPA fallback) +const fallback = readFileSync(join(distDir, "index.html"), "utf-8"); +writeFileSync(join(distDir, "404.html"), fallback, "utf-8"); + +console.log("\n ✓ 404.html (SPA fallback)"); +console.log("\nDone — prerendered", routes.length, "pages.\n"); diff --git a/website/src/Main.res b/website/src/Main.res index eb48d85..d79e281 100644 --- a/website/src/Main.res +++ b/website/src/Main.res @@ -3,4 +3,15 @@ open Xote %%raw(`import './styles.css'`) Router.init(~basePath="/rescript-tone", ()) -Component.mountById(, "app") + +// If the page was prerendered (SSR), hydrate to attach reactivity. +// Otherwise, mount fresh (dev server, or non-prerendered 404 fallback). +let hasSSRContent: bool = %raw(` + document.getElementById('app')?.childNodes.length > 0 +`) + +if hasSSRContent { + Hydration.hydrateById(() => , "app") +} else { + Component.mountById(, "app") +} From 96ce91c60ea27c47ec912a7a2433a2e51aa5563c Mon Sep 17 00:00:00 2001 From: Bernardo Gurgel Date: Wed, 1 Apr 2026 20:15:15 +0000 Subject: [PATCH 10/17] Self-register ESM loader in prerender script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moved module.register() call into prerender.mjs itself so the script works with a plain `node scripts/prerender.mjs` — no --import flag needed. Fixes CSS import errors on Node v25. https://claude.ai/code/session_01B8MgrWxixKEWaxDiHyy8Bx --- website/package.json | 2 +- website/scripts/node-loader-register.mjs | 8 -------- website/scripts/prerender.mjs | 8 +++++++- 3 files changed, 8 insertions(+), 10 deletions(-) delete mode 100644 website/scripts/node-loader-register.mjs diff --git a/website/package.json b/website/package.json index 585521f..f5011e6 100644 --- a/website/package.json +++ b/website/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "rescript -w & vite", - "build": "rescript && vite build && node --import ./scripts/node-loader-register.mjs scripts/prerender.mjs", + "build": "rescript && vite build && node scripts/prerender.mjs", "preview": "vite preview", "res:build": "rescript", "res:watch": "rescript -w", diff --git a/website/scripts/node-loader-register.mjs b/website/scripts/node-loader-register.mjs deleted file mode 100644 index 2a4b5e7..0000000 --- a/website/scripts/node-loader-register.mjs +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Register the custom ESM loader using the non-deprecated module.register() API. - * Used via: node --import ./scripts/node-loader-register.mjs - */ -import { register } from "node:module"; -import { pathToFileURL } from "node:url"; - -register("./scripts/node-loader.mjs", pathToFileURL("./")); diff --git a/website/scripts/prerender.mjs b/website/scripts/prerender.mjs index b75deb3..e16bca9 100644 --- a/website/scripts/prerender.mjs +++ b/website/scripts/prerender.mjs @@ -8,9 +8,15 @@ * 4. Injects the markup into the Vite-built index.html shell. * 5. Writes the result to dist//index.html. * - * Run with: node --loader ./scripts/node-loader.mjs scripts/prerender.mjs + * Run with: node scripts/prerender.mjs */ +// Register the custom ESM loader to stub CSS/asset imports. +// Must happen before any app code is imported. +import { register } from "node:module"; +import { pathToFileURL } from "node:url"; +register("./scripts/node-loader.mjs", pathToFileURL("./")); + // DOM/browser shims must load before any app code import "./dom-shim.mjs"; From 1550a94df23b45ba7960aad8f5fe606c4a0f58bc Mon Sep 17 00:00:00 2001 From: Bernardo Gurgel Date: Wed, 1 Apr 2026 20:20:38 +0000 Subject: [PATCH 11/17] Fix trailing slash routing for GitHub Pages Generate both slug/index.html and slug.html for each route so URLs work with and without trailing slashes. Without this, GitHub Pages 301-redirects /route to /route/ causing a visible flash. Also simplified the build command to plain `node scripts/prerender.mjs` since the ESM loader is now self-registered via module.register(). https://claude.ai/code/session_01B8MgrWxixKEWaxDiHyy8Bx --- website/scripts/prerender.mjs | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/website/scripts/prerender.mjs b/website/scripts/prerender.mjs index e16bca9..fb3eca3 100644 --- a/website/scripts/prerender.mjs +++ b/website/scripts/prerender.mjs @@ -66,14 +66,25 @@ for (const route of routes) { `
${html}
` ); - // Write to the correct path - const outPath = - route === "/" - ? join(distDir, "index.html") - : join(distDir, route.slice(1), "index.html"); - - mkdirSync(dirname(outPath), { recursive: true }); - writeFileSync(outPath, page, "utf-8"); + // Write to the correct paths. + // For GitHub Pages we need both forms so the URL works with AND without + // a trailing slash (without the slash GH Pages would 301-redirect to + // the directory, causing a flash): + // dist/getting-started/index.html → /rescript-tone/getting-started/ + // dist/getting-started.html → /rescript-tone/getting-started + if (route === "/") { + writeFileSync(join(distDir, "index.html"), page, "utf-8"); + } else { + const slug = route.slice(1); // e.g. "getting-started" or "api/core" + // directory form: dist//index.html + const dirPath = join(distDir, slug, "index.html"); + mkdirSync(dirname(dirPath), { recursive: true }); + writeFileSync(dirPath, page, "utf-8"); + // file form: dist/.html (handles no-trailing-slash) + const filePath = join(distDir, slug + ".html"); + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, page, "utf-8"); + } console.log(` ✓ ${route}`); } From ed7b1863db30c08c733220f5d720b33fb562d470 Mon Sep 17 00:00:00 2001 From: Bernardo Gurgel Date: Wed, 1 Apr 2026 20:32:03 +0000 Subject: [PATCH 12/17] Redesign homepage layout and fix footer link placement Homepage: Hero -> 3 compact interactive demos grid (synth, effects, sequencer) -> feature descriptions list -> CTA with install command. Removed the tabbed code demo and 6-card features grid. Footer: Moved link columns (Docs, Links) to the left side after the brand/tagline. Added Tone.js link. Made overall footer more compact. https://claude.ai/code/session_01B8MgrWxixKEWaxDiHyy8Bx --- website/src/Layout.res | 16 +- website/src/pages/Pages__Home.res | 643 ++++++++++++++++++++---------- website/src/styles.css | 374 ++++++++++++----- 3 files changed, 711 insertions(+), 322 deletions(-) diff --git a/website/src/Layout.res b/website/src/Layout.res index 0ff0ef6..5d2f931 100644 --- a/website/src/Layout.res +++ b/website/src/Layout.res @@ -317,20 +317,20 @@ module Footer = { let make = (_props: props) => {