From ef86dcbf44360996cc2ad324eed4480a0c6d224b Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Fri, 16 Oct 2026 09:42:58 -0400 Subject: [PATCH 1/9] feat(website): user-benefit copy with (i) detail tooltips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Homepage copy led with protocol internals — NIP numbers, sliding-window rate limits, SSE, Redis fan-out — where visitors want the job it does. Visible copy now states the benefit; the technical detail moved under a new InfoTip component (hover, keyboard focus, or tap) on every pillar, audience bullet and console caption. 'Limits that hold' becomes 'Keeps abusive traffic out — throttles users with efficient methods to prevent abuse', per Cameri's example. --- .changeset/website-user-copy.md | 5 ++ website/site/src/components/InfoTip.astro | 80 +++++++++++++++++++ .../site/src/components/home/Audience.astro | 25 +++--- .../site/src/components/home/Console.astro | 29 ++++--- .../site/src/components/home/Pillars.astro | 34 +++++--- 5 files changed, 141 insertions(+), 32 deletions(-) create mode 100644 .changeset/website-user-copy.md create mode 100644 website/site/src/components/InfoTip.astro diff --git a/.changeset/website-user-copy.md b/.changeset/website-user-copy.md new file mode 100644 index 000000000..135666b52 --- /dev/null +++ b/.changeset/website-user-copy.md @@ -0,0 +1,5 @@ +--- +"nostream-website": patch +--- + +Website copy now leads with what each feature does for the user; the technical detail (NIP numbers, protocol names, command flags) moved into (i) info tooltips on hover, focus or tap. diff --git a/website/site/src/components/InfoTip.astro b/website/site/src/components/InfoTip.astro new file mode 100644 index 000000000..c7bfebb7a --- /dev/null +++ b/website/site/src/components/InfoTip.astro @@ -0,0 +1,80 @@ +--- +// Inline "(i)" affordance: the visible copy stays user-facing; the technical +// detail lives in a hover/focus/tap tooltip. Progressively enhanced — works +// without JS (focus + tap toggles via checkbox-free focus/active states). +interface Props { + /** The technical detail shown inside the tooltip. */ + detail: string + /** Accessible label context, e.g. "rate limiting". */ + label?: string +} +const { detail, label = 'more detail' } = Astro.props +--- + + + + {detail} + + + diff --git a/website/site/src/components/home/Audience.astro b/website/site/src/components/home/Audience.astro index c2e58676d..b9d21af0c 100644 --- a/website/site/src/components/home/Audience.astro +++ b/website/site/src/components/home/Audience.astro @@ -1,13 +1,15 @@ --- +import InfoTip from '../InfoTip.astro' + const audiences = [ { code: 'PUB', title: 'Public relay operators', body: 'Serve the open network without getting flattened by it.', points: [ - 'Sliding-window rate limits with backoff hints clients can respect', - 'Adaptive proof-of-work, event size and created_at limits', - 'Horizontal workers with Redis fan-out', + { text: 'Handles floods of traffic without slowing everyone else down', tip: 'Sliding-window rate limits with backoff hints clients can respect' }, + { text: 'Blocks spam before it lands, without turning people away', tip: 'Adaptive proof-of-work, event size and created_at limits' }, + { text: 'Grows with your audience by adding more machines', tip: 'Horizontal workers with Redis fan-out' }, ], }, { @@ -15,9 +17,9 @@ const audiences = [ title: 'Paid relay operators', body: 'Turn a relay into a service. Admission fees settle over Lightning.', points: [ - 'ZEBEDEE, Nodeless, OpenNode, LNbits and LNURL processors', - 'Admission checks and NIP-43 invite codes', - 'Admission fee schedules with pubkey whitelists', + { text: 'Get paid the way you already do', tip: 'ZEBEDEE, Nodeless, OpenNode, LNbits and LNURL processors' }, + { text: 'Invite-only groups and member checks, built in', tip: 'Admission checks and NIP-43 invite codes' }, + { text: 'Set your own prices and member lists', tip: 'Admission fee schedules with pubkey whitelists' }, ], }, { @@ -25,9 +27,9 @@ const audiences = [ title: 'Self-hosters', body: 'A relay for you, your family or your community, on your own box.', points: [ - 'One command: nostream start, with Tor, I2P or nginx + certbot', - 'Import and export events as JSON or JSONL', - 'Restricted kinds, pubkey allow lists and NIP-42 auth', + { text: 'One command and you are running', tip: 'nostream start, with Tor, I2P or nginx + certbot' }, + { text: 'Your posts stay yours — copy them out any time', tip: 'Import and export events as JSON or JSONL' }, + { text: 'You decide who can post and who can read', tip: 'Restricted kinds, pubkey allow lists and NIP-42 auth' }, ], }, ] @@ -58,7 +60,10 @@ const audiences = [

{a.body}

diff --git a/website/site/src/components/home/Console.astro b/website/site/src/components/home/Console.astro index 8652b31be..09959fc08 100644 --- a/website/site/src/components/home/Console.astro +++ b/website/site/src/components/home/Console.astro @@ -2,6 +2,7 @@ import type { ImageMetadata } from 'astro' import { Image } from 'astro:assets' import ScreenBar from '../ScreenBar.astro' +import InfoTip from '../InfoTip.astro' // Drop admin console screenshots into src/assets/screens/ using these names (png, jpg or webp). const files = import.meta.glob<{ default: ImageMetadata }>('../../assets/screens/*.{png,jpg,jpeg,webp}', { eager: true }) const byName = Object.fromEntries( @@ -12,40 +13,45 @@ const shots = [ { names: ['metrics', 'metrics-1'], title: 'Live metrics', - body: 'Events per second, rejections, connections, CPU and memory, streamed over SSE.', + body: 'Watch traffic and load as it happens, so you catch problems while they are small.', + tip: 'Events per second, rejections, connections, CPU and memory, streamed over SSE.', tag: 'SSE', main: true, }, { name: 'network-health', title: 'Network health', - body: 'NIP-66 probes for DNS, TLS, WebSocket read/write RTT and NIP-11, per target.', + body: 'Know your relay is reachable and fast from the outside, not just from your own machine.', + tip: 'NIP-66 probes for DNS, TLS, WebSocket read/write RTT and NIP-11, per target.', tag: 'NIP-66', }, { name: 'mismatches', - title: 'NIP-11 mismatch warnings', - body: 'Flags when what your relay advertises disagrees with what probes measure.', + title: 'Honest advertising', + body: 'Catches when your relay promises clients something it does not actually do.', + tip: 'NIP-11 mismatch warnings: flags when what your relay advertises disagrees with what probes measure.', tag: 'NIP-11', }, { name: 'settings', title: 'Settings editor', - body: 'Guided, validated edits with automatic backups and one-click restore.', + body: 'Change settings confidently — mistakes are caught before they ship, and undo is one click.', + tip: 'Guided, validated edits with automatic backups and one-click restore.', tag: 'YAML', }, { name: 'notifications', title: 'Notifications', - body: 'Outbound alerts with a delivery log, so you hear about trouble first.', + body: 'Hear about trouble first, before your users notice.', + tip: 'Outbound alerts with a delivery log, so nothing gets lost silently.', tag: 'ALERT', }, ] const capabilities = [ - 'Login-gated with IP allow lists and rate limits', - 'Prometheus-compatible metrics and OpenTelemetry hooks', - '/readyz for load balancers and blue/green deploys', + 'Login-gated so only you can reach it', + 'Plugs into the monitoring you already run', + 'Health endpoint for load balancers and zero-downtime deploys', 'Self-hosted probes, labelled as one monitor among many', ] --- @@ -96,7 +102,10 @@ const capabilities = [

{shot.title}

-

{shot.body}

+

+ {shot.body} + {shot.tip && } +

) diff --git a/website/site/src/components/home/Pillars.astro b/website/site/src/components/home/Pillars.astro index 623b53e4f..461646112 100644 --- a/website/site/src/components/home/Pillars.astro +++ b/website/site/src/components/home/Pillars.astro @@ -1,37 +1,44 @@ --- +import InfoTip from '../InfoTip.astro' import { SUPPORTED_MIPS, SUPPORTED_NIPS } from '../../data/site' const pillars = [ { code: 'NIP', - title: 'Protocol breadth', - body: `${SUPPORTED_NIPS.length} NIPs implemented relay-side, from basic flow to auth, counts, search and encrypted payloads, plus Marmot MIPs ${SUPPORTED_MIPS[0]}–${SUPPORTED_MIPS[SUPPORTED_MIPS.length - 1]} for E2EE group messaging.`, + title: 'Speaks the whole protocol', + body: 'Works with the Nostr apps your people already use — chat, feeds, search and encrypted groups — with nothing to configure. ', + tip: `${SUPPORTED_NIPS.length} NIPs implemented relay-side, from basic flow to auth, counts, search and encrypted payloads, plus Marmot MIPs ${SUPPORTED_MIPS[0]}–${SUPPORTED_MIPS[SUPPORTED_MIPS.length - 1]} for E2EE group messaging.`, wide: true, }, { code: 'NET', - title: 'Tor & I2P built in', - body: 'Publish an .onion or .i2p address with a flag. nostream start --tor --i2p, then nostream info --tor-hostname.', + title: 'Reachable from anywhere', + body: 'Publish an .onion or .i2p address with a flag, so people on censored or privacy-first networks can still reach you.', + tip: 'nostream start --tor --i2p, then nostream info --tor-hostname.', }, { code: 'OPS', - title: 'Admin console', - body: 'Live metrics, NIP-66 network health, NIP-11 mismatch warnings and a validated settings editor.', + title: 'Stay on top of your relay', + body: 'See how your relay is doing at a glance, spot trouble before your users do, and change settings without fear of breaking things.', + tip: 'Live metrics, NIP-66 network health, NIP-11 mismatch warnings and a validated settings editor.', }, { code: 'LN', - title: 'Paid admission', - body: 'Charge to publish. Invoices via ZEBEDEE, Nodeless, OpenNode, LNbits or any LNURL-verify provider.', + title: 'Charge for admission', + body: 'Turn publish access into a service. Invoices settle over Lightning with the provider you already run.', + tip: 'Invoices via ZEBEDEE, Nodeless, OpenNode, LNbits or any LNURL-verify provider.', }, { code: 'LIM', - title: 'Limits that hold', - body: 'Rate limits, adaptive proof-of-work, kind and size rules. Clients get a rate-limited hint with a backoff.', + title: 'Keeps abusive traffic out', + body: 'Throttles users with efficient methods to prevent abuse, so your relay stays fast for everyone who plays by the rules. ', + tip: 'Rate limits, adaptive proof-of-work, kind and size rules. Clients get a rate-limited hint with a backoff.', }, { code: 'I/O', title: 'Your data, portable', - body: 'Import and export events as JSON or JSONL in batches. Back up, migrate, or seed a new relay.', + body: 'Take your events with you. Back up, migrate, or seed a new relay whenever you want.', + tip: 'Import and export events as JSON or JSONL in batches.', full: true, command: 'nostream export --output backup.jsonl --format jsonl', }, @@ -55,7 +62,10 @@ const pillars = [
{p.code}

{p.title}

-

{p.body}

+

+ {p.body} + {p.tip && } +

{p.command && ( From 427d763cd0953c46a224b02ca8d109aa1ec9d57f Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Fri, 16 Oct 2026 09:57:52 -0400 Subject: [PATCH 2/9] feat(website): per-PR previews at /preview/ PRs touching the site build under the /preview// base and deploy their own assets-only worker (nostream-web-pr-, workers.dev URL); the PR gets a comment with the preview link, and a closed-PR run deletes the worker. The production worker becomes a router (worker/router.ts + wrangler.router.jsonc): it serves the production assets itself via an ASSETS binding and proxies /preview//* to the PR worker, keeping the preview URL on ema.tnsor.network. The custom-domain cutover happens on the next production deploy; rollback is deploying the old config again. Preview workflow (deploy-website-preview.yml) skips with a notice when the website-preview GitHub environment or its CLOUDFLARE_API_TOKEN is absent, so PRs are not blocked before the one-time setup. Base-path handling: astro.config reads SITE_BASE; hand-written root paths (nav, media, icons) are prefixed by scripts/apply-base.mjs at build time (Astro's base covers managed URLs only). Media refs now go through import.meta.env.BASE_URL. --- .changeset/website-pr-previews.md | 5 + .changeset/website-user-copy.md | 2 + .github/workflows/deploy-website-preview.yml | 122 ++++++++++++++++++ .github/workflows/deploy-website.yml | 4 +- website/site/astro.config.mjs | 2 + website/site/scripts/apply-base.mjs | 74 +++++++++++ .../site/src/components/PlanetBackdrop.astro | 2 +- website/site/src/components/home/Hero.astro | 6 +- website/site/worker/router.ts | 45 +++++++ website/site/wrangler.preview.jsonc | 14 ++ website/site/wrangler.router.jsonc | 21 +++ 11 files changed, 292 insertions(+), 5 deletions(-) create mode 100644 .changeset/website-pr-previews.md create mode 100644 .github/workflows/deploy-website-preview.yml create mode 100644 website/site/scripts/apply-base.mjs create mode 100644 website/site/worker/router.ts create mode 100644 website/site/wrangler.preview.jsonc create mode 100644 website/site/wrangler.router.jsonc diff --git a/.changeset/website-pr-previews.md b/.changeset/website-pr-previews.md new file mode 100644 index 000000000..10a36a8a1 --- /dev/null +++ b/.changeset/website-pr-previews.md @@ -0,0 +1,5 @@ +--- +"nostream-website": patch +--- + +Add PR preview deployments: site changes are previewable at ema.tnsor.network/preview// and cleaned up on close. Requires the website-preview environment (CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID) to activate. diff --git a/.changeset/website-user-copy.md b/.changeset/website-user-copy.md index 135666b52..ba18e9fe7 100644 --- a/.changeset/website-user-copy.md +++ b/.changeset/website-user-copy.md @@ -3,3 +3,5 @@ --- Website copy now leads with what each feature does for the user; the technical detail (NIP numbers, protocol names, command flags) moved into (i) info tooltips on hover, focus or tap. + +Also ships PR previews: every PR touching the site builds under `/preview//` and deploys its own worker (`nostream-web-pr-`); the production router worker (new) proxies `ema.tnsor.network/preview//*` to it, so the preview URL stays on the production domain. Previews are deleted when the PR closes. The production deploy switches from the assets-only worker to the router on the next release — same assets, same domain, plus the preview route. diff --git a/.github/workflows/deploy-website-preview.yml b/.github/workflows/deploy-website-preview.yml new file mode 100644 index 000000000..4141e68e1 --- /dev/null +++ b/.github/workflows/deploy-website-preview.yml @@ -0,0 +1,122 @@ +name: Deploy Website Preview + +# Per-PR preview of the marketing site at https://ema.tnsor.network/preview// +# (the production router worker proxies /preview/ to this PR's worker, +# nostream-web-pr-, served from its workers.dev URL). The preview is deleted +# when the PR closes (merged or not). +# +# Requires the `website-preview` GitHub environment to exist with +# CLOUDFLARE_API_TOKEN (Workers Scripts edit) and CLOUDFLARE_ACCOUNT_ID — +# one-time setup. While the environment or its secrets are absent, this +# workflow skips with a notice instead of failing the PR. + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - closed + paths: + - "website/site/**" + - "website/hero-animation/**" + - ".github/workflows/deploy-website-preview.yml" + +concurrency: + group: website-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + preview: + runs-on: ubuntu-latest + # Guard the environment reference so a missing setup skips rather than fails: + # the job targets the environment only when it exists. + steps: + - name: Check preview environment is configured + id: guard + env: + GH_TOKEN: ${{ github.token }} + run: | + set -eu + if ! gh api repos/${{ github.repository }}/environments/website-preview >/dev/null 2>&1; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "::notice::website-preview environment does not exist yet — PR preview skipped. Create it with CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID to enable previews." + elif gh api repos/${{ github.repository }}/environments/website-preview/secrets --jq '.secrets | map(.name)' | grep -q CLOUDFLARE_API_TOKEN; then + echo "skip=false" >> "$GITHUB_OUTPUT" + else + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "::notice::website-preview environment lacks CLOUDFLARE_API_TOKEN — PR preview skipped." + fi + + - name: Checkout (for cleanup metadata) + if: steps.guard.outputs.skip == 'false' + uses: actions/checkout@v3 + + - uses: pnpm/action-setup@v4 + if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' + + - uses: actions/setup-node@v3 + if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' + with: + node-version-file: .nvmrc + cache: pnpm + + - name: Install workspace dependencies + if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' + run: pnpm install --frozen-lockfile + + - name: Build the site under the preview base + if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' + working-directory: website/site + env: + SITE_URL: https://ema.tnsor.network + SITE_BASE: /preview/${{ github.event.pull_request.number }}/ + run: | + pnpm run build + node scripts/apply-base.mjs + + - name: Deploy the preview worker + if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' + working-directory: website/site + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + run: | + npx wrangler deploy \ + --config wrangler.preview.jsonc \ + --name nostream-web-pr-${{ github.event.pull_request.number }} + + - name: Link the preview on the PR + if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' + env: + GH_TOKEN: ${{ github.token }} + URL: https://ema.tnsor.network/preview/${{ github.event.pull_request.number }}/ + run: | + gh pr comment ${{ github.event.pull_request.number }} --repo ${{ github.repository }} \ + --body "🔍 Site preview for this PR: ${URL} (path stays on the production domain; no CDN caching). + + It updates with every push to this PR and is deleted when the PR closes." + + - name: Delete the preview worker + if: steps.guard.outputs.skip == 'false' && github.event.action == 'closed' + working-directory: website/site + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + run: | + # A missing worker exits non-zero; treat already-gone as success. + npx wrangler delete --config wrangler.preview.jsonc \ + --name nostream-web-pr-${{ github.event.pull_request.number }} \ + || echo "preview worker already absent" + + - name: Note the cleanup on the PR + if: steps.guard.outputs.skip == 'false' && github.event.action == 'closed' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh pr comment ${{ github.event.pull_request.number }} --repo ${{ github.repository }} \ + --body "🧹 Site preview for this PR was deleted (PR closed)." || true diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 0effa158f..ec0363792 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -55,4 +55,6 @@ jobs: env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} - run: pnpm exec wrangler deploy + # The router worker: serves the production assets itself and proxies + # /preview/ to per-PR preview workers (deploy-website-preview.yml). + run: pnpm exec wrangler deploy --config wrangler.router.jsonc diff --git a/website/site/astro.config.mjs b/website/site/astro.config.mjs index fcf27132b..36d2b48e4 100644 --- a/website/site/astro.config.mjs +++ b/website/site/astro.config.mjs @@ -50,6 +50,8 @@ function rehypeRepoLinks() { export default defineConfig({ site: process.env.SITE_URL ?? 'https://nostream.dev', + // PR previews build under /preview/; empty when unset (production). + base: process.env.SITE_BASE || undefined, markdown: { rehypePlugins: [rehypeRepoLinks], shikiConfig: { theme: 'github-dark-default', wrap: false }, diff --git a/website/site/scripts/apply-base.mjs b/website/site/scripts/apply-base.mjs new file mode 100644 index 000000000..442b547cf --- /dev/null +++ b/website/site/scripts/apply-base.mjs @@ -0,0 +1,74 @@ +// Prefix root-absolute asset/link paths in built HTML with the site base. +// Astro's `base` rewrites astro-managed URLs; hand-written hrefs (nav links, +// icons, media) stay root-absolute, so this pass rewrites them for PR previews +// served under /preview//. +// Usage: SITE_BASE=/preview/42 node scripts/apply-base.mjs +import { readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs' +import { join } from 'node:path' + +const base = process.env.SITE_BASE +if (!base || base === '/') { + console.log('apply-base: SITE_BASE not set, nothing to do') + process.exit(0) +} +const norm = base.endsWith('/') ? base : `${base}/` + +const DIST = new URL('../dist/', import.meta.url).pathname + +const walk = (dir) => + readdirSync(dir).flatMap((name) => { + const p = join(dir, name) + if (statSync(p).isDirectory()) return walk(p) + return p + }) + +// HTML attributes that carry site-internal root paths. External (http(s):), +// protocol-relative (//), in-page (#) and already-based paths are skipped. +const ATTRS = ['href', 'src', 'poster', 'content'] +let changed = 0 + +for (const file of walk(DIST)) { + if (!/\.(html|css)$/.test(file)) continue + let html = readFileSync(file, 'utf8') + let out = html + if (file.endsWith('.html')) { + // Prefix each root-absolute path with the base; skip paths already based, + // protocol-relative URLs and pure-fragment links. + const prefix = norm.replace(/\/$/, '') // '/preview/99' + out = html + for (const attr of ATTRS) { + const re = new RegExp(`(${attr}=)"(/[^"]*)"`, 'gi') + out = out.replace(re, (m, a, path) => { + if (path.startsWith(prefix + '/') || path === prefix) return m + return `${a}"${prefix}${path}"` + }) + } + // srcset entries ("path 640w, path 1280w") and CSS url("/..."). + out = out.replace(/(srcset=")([^"]*)(")/gi, (m, a, list, z) => { + const parts = list.split(',').map((entry) => { + const [p, ...rest] = entry.trim().split(/\s+/) + if (!p || !p.startsWith('/') || p.startsWith(prefix + '/')) return entry.trim() + return [prefix + p, ...rest].join(' ') + }) + return `${a}${parts.join(', ')}${z}` + }) + out = out.replace(/url\("(\/[^"]*)"\)/gi, (m, p) => + p.startsWith(prefix + '/') ? m : `url("${prefix}${p}")`) + out = out.replace(/url\('(\/[^']*)'\)/gi, (m, p) => + p.startsWith(prefix + '/') ? m : `url('${prefix}${p}')`) + } else { + // CSS files: url(/...) only. + const prefix = norm.replace(/\/$/, '') + out = html.replace(/url\("(\/[^"]*)"\)/gi, (m, p) => + p.startsWith(prefix + '/') ? m : `url("${prefix}${p}")`) + out = out.replace(/url\('(\/[^']*)'\)/gi, (m, p) => + p.startsWith(prefix + '/') ? m : `url('${prefix}${p}')`) + out = out.replace(/url\((\/[^)]*)\)/gi, (m, p) => + p.startsWith(prefix + '/') ? m : `url(${prefix}${p})`) + } + if (out !== html) { + writeFileSync(file, out) + changed++ + } +} +console.log(`apply-base: rewrote ${changed} files under ${norm}`) diff --git a/website/site/src/components/PlanetBackdrop.astro b/website/site/src/components/PlanetBackdrop.astro index b9b4a31e5..a1674c7a2 100644 --- a/website/site/src/components/PlanetBackdrop.astro +++ b/website/site/src/components/PlanetBackdrop.astro @@ -11,7 +11,7 @@ const spawnChance = density === 'high' ? 0.94 : 0.85 ---