From 6317b1c374dd1f5607f4d1f49dee1cfabf77a89e Mon Sep 17 00:00:00 2001 From: Dmytro Date: Thu, 6 Aug 2026 19:43:05 +0100 Subject: [PATCH 1/4] feat(web): new landing website, docs, new design system, cloudflare workers deploy --- .github/dependabot.yml | 9 + .github/workflows/lockfile-check.yaml | 2 +- .github/workflows/web.yaml | 160 + docs/protocol/LANDING_SITE_PLAN.md | 379 +++ frontend/bun.lock | 5 + frontend/index.html | 10 +- frontend/package.json | 1 + frontend/public/og/README.md | 13 +- frontend/public/og/og-clawbits-app.png | Bin 0 -> 908268 bytes .../src/components/ChannelMentionLink.tsx | 4 +- frontend/src/components/MessageComposer.tsx | 2 +- frontend/src/components/MessageMarkdown.tsx | 4 +- frontend/src/components/MobileBottomNav.tsx | 2 +- frontend/src/components/RailChats.tsx | 2 +- frontend/src/components/ShaderBackdrop.tsx | 81 + frontend/src/components/SiteHeader.tsx | 8 +- frontend/src/components/WordmarkLink.tsx | 43 + .../agent-card/AgentCollectibleCard.tsx | 8 +- .../components/agent-card/HatchingCard.tsx | 2 +- frontend/src/components/agent-card/shapes.ts | 2 +- .../components/agent/inbox/InboxEmptyHero.tsx | 2 +- .../agent/manage/BehaviorSection.tsx | 2 +- .../components/automations/AutomationCard.tsx | 4 +- .../automations/AutomationsManager.tsx | 2 +- .../components/automations/ForgeDialog.tsx | 4 +- .../src/components/automations/RunHistory.tsx | 2 +- .../automations/useAutomationMutations.ts | 6 +- .../components/chat/GeneratingIndicator.tsx | 2 +- frontend/src/components/chat/MessageRow.tsx | 6 +- .../src/components/new-agent/DeployStep.tsx | 6 +- .../src/components/new-agent/LaunchStep.tsx | 8 +- .../components/new-agent/NewAgentDialog.tsx | 12 +- .../src/components/new-agent/OptionsStep.tsx | 12 +- frontend/src/components/new-agent/bits.tsx | 2 +- frontend/src/components/new-agent/prompts.ts | 2 +- .../new-agent/wizardSessionStore.ts | 2 +- .../src/components/reef/OpenSurfaceDialog.tsx | 2 +- .../settings/AvatarEditorDialog.tsx | 8 +- frontend/src/components/ui/list-row.tsx | 2 +- frontend/src/components/ui/select.tsx | 6 +- frontend/src/components/video/Video.tsx | 2 +- frontend/src/context/AuthContext.tsx | 2 +- frontend/src/hooks/useChannelActions.ts | 2 +- frontend/src/index.css | 250 +- frontend/src/layouts/DesktopShell.tsx | 14 +- frontend/src/lib/agentDescription.ts | 4 +- frontend/src/lib/automations.ts | 12 +- frontend/src/lib/emojiBurst.ts | 2 +- frontend/src/lib/jumpBackIn.ts | 2 +- frontend/src/lib/reefApi.ts | 2 +- frontend/src/pages/AgentCardPage.tsx | 2 +- frontend/src/pages/AgentHomePage.tsx | 4 +- frontend/src/pages/AutomationDetailPage.tsx | 2 +- frontend/src/pages/ChangelogPage.tsx | 8 +- frontend/src/pages/ChannelPage.tsx | 6 +- frontend/src/pages/LoginPage.tsx | 72 +- frontend/src/pages/OrgUsagePage.tsx | 6 +- frontend/src/pages/PrivacyPage.tsx | 66 +- frontend/src/pages/SettingsAgentsPage.tsx | 2 +- frontend/src/pages/SettingsConnectorsPage.tsx | 2 +- .../src/pages/SettingsNotificationsPage.tsx | 6 +- frontend/src/pages/SettingsProfilePage.tsx | 4 +- frontend/src/pages/SettingsReefPage.tsx | 32 +- frontend/src/pages/TermsPage.tsx | 24 +- frontend/src/pages/VerifyEmailPage.tsx | 101 +- frontend/src/release-notes/README.md | 21 + web/.gitignore | 17 + web/README.md | 255 ++ web/astro.config.mjs | 168 + web/bun.lock | 1076 +++++++ web/package.json | 41 + web/public/_headers | 53 + web/public/avatars/agent-1.svg | 1 + web/public/avatars/agent-2.svg | 1 + web/public/avatars/agent-3.svg | 1 + web/public/avatars/agent-4.svg | 1 + web/public/avatars/agent-5.svg | 1 + web/public/avatars/agent-6.svg | 1 + web/public/avatars/channel-1.svg | 1 + web/public/avatars/channel-2.svg | 1 + web/public/avatars/channel-3.svg | 1 + web/public/avatars/channel-4.svg | 1 + web/public/avatars/human-1.png | Bin 0 -> 28703 bytes web/public/avatars/human-2.png | Bin 0 -> 28765 bytes web/public/avatars/human-3.png | Bin 0 -> 27415 bytes web/public/avatars/human-4.png | Bin 0 -> 31112 bytes web/public/brand/agents/hermes.svg | 12 + web/public/brand/agents/ironclaw.webp | Bin 0 -> 38406 bytes web/public/brand/agents/openclaw.png | Bin 0 -> 68898 bytes web/public/brand/apple-touch-icon-180.png | Bin 0 -> 72844 bytes web/public/brand/brand.json | 111 + web/public/brand/clawbits-brand-kit.zip | Bin 0 -> 37679 bytes web/public/brand/clawbits-long-current.svg | 18 + web/public/brand/clawbits-long-inverted.svg | 18 + web/public/brand/clawbits-long.svg | 18 + .../brand/clawbits-mark-square-inverted.svg | 8 + web/public/brand/clawbits-mark-square.svg | 8 + web/public/brand/clawbits-short-current.svg | 3 + web/public/brand/clawbits-short-inverted.svg | 3 + web/public/brand/clawbits-short.svg | 3 + .../brand/clawbits-stacked-inverted.svg | 17 + web/public/brand/clawbits-stacked.svg | 17 + web/public/brand/desktop-release.png | Bin 0 -> 43693 bytes web/public/brand/favicon.png | Bin 0 -> 25221 bytes web/public/brand/hermes-ios.png | Bin 0 -> 88712 bytes web/public/brand/ironclaw-ios.png | Bin 0 -> 90554 bytes web/public/brand/openclaw-ios.png | Bin 0 -> 82866 bytes web/public/brand/patterns/hermes-grid.svg | 1 + web/public/brand/patterns/ironclaw-grid.svg | 1 + web/public/brand/patterns/openclaw-grid.svg | 1 + web/public/brand/reef-icon.png | Bin 0 -> 26994 bytes web/public/brand/server.jpeg | Bin 0 -> 189142 bytes web/public/brand/tennis.jpg | Bin 0 -> 196570 bytes web/public/og/og-clawbits-brand.png | Bin 0 -> 350633 bytes web/public/og/og-clawbits-changelog.png | Bin 0 -> 344478 bytes web/public/og/og-clawbits-docs.png | Bin 0 -> 347278 bytes web/public/og/og-clawbits-privacy.png | Bin 0 -> 350227 bytes web/public/og/og-clawbits-terms.png | Bin 0 -> 345851 bytes web/public/og/og-clawbits.png | Bin 0 -> 340238 bytes web/public/og/og-default.png | Bin 0 -> 1025709 bytes web/scripts/build-brand-assets.mjs | 315 ++ web/scripts/verify-legal-parity.mjs | 250 ++ web/scripts/verify-links.mjs | 124 + web/src/components/AgencyVisual.astro | 642 ++++ web/src/components/AppDemo.astro | 2864 +++++++++++++++++ web/src/components/AppWindow.astro | 322 ++ web/src/components/AutomationVisual.astro | 699 ++++ web/src/components/Button.astro | 99 + web/src/components/Eyebrow.astro | 52 + web/src/components/Footer.astro | 235 ++ web/src/components/GitVisual.astro | 696 ++++ web/src/components/HugeIcon.astro | 75 + web/src/components/InterAgentVisual.astro | 732 +++++ web/src/components/IntroChat.astro | 333 ++ web/src/components/LegalSection.astro | 20 + web/src/components/LobstertalkVisual.astro | 959 ++++++ web/src/components/Logo.astro | 45 + web/src/components/MailboxVisual.astro | 695 ++++ web/src/components/Nav.astro | 347 ++ web/src/components/PlayOnView.astro | 35 + web/src/components/ReefVisual.astro | 561 ++++ web/src/components/Section.astro | 87 + web/src/components/ShaderBackdrop.tsx | 56 + web/src/config.ts | 100 + web/src/content.config.ts | 141 + web/src/content/home.ts | 236 ++ web/src/docs-allowlist.ts | 211 ++ web/src/generated/brand-manifest.json | 72 + web/src/layouts/Base.astro | 158 + web/src/layouts/Docs.astro | 401 +++ web/src/layouts/Legal.astro | 279 ++ web/src/lib/appicon.ts | 25 + web/src/lib/github.ts | 49 + web/src/lib/platform-icons.ts | 27 + web/src/lib/releases.ts | 169 + web/src/pages/404.astro | 184 ++ web/src/pages/brand.astro | 1166 +++++++ web/src/pages/changelog/index.astro | 549 ++++ web/src/pages/docs/[slug].astro | 36 + web/src/pages/docs/[slug].md.ts | 46 + web/src/pages/docs/index.astro | 158 + web/src/pages/download.astro | 664 ++++ web/src/pages/index.astro | 1225 +++++++ web/src/pages/llms-full.txt.ts | 202 ++ web/src/pages/llms.txt.ts | 89 + web/src/pages/privacy.astro | 394 +++ web/src/pages/robots.txt.ts | 96 + web/src/pages/terms.astro | 449 +++ web/src/styles/global.css | 266 ++ web/tsconfig.json | 9 + web/wrangler.jsonc | 41 + 171 files changed, 20788 insertions(+), 235 deletions(-) create mode 100644 .github/workflows/web.yaml create mode 100644 docs/protocol/LANDING_SITE_PLAN.md create mode 100644 frontend/public/og/og-clawbits-app.png create mode 100644 frontend/src/components/ShaderBackdrop.tsx create mode 100644 frontend/src/components/WordmarkLink.tsx create mode 100644 web/.gitignore create mode 100644 web/README.md create mode 100644 web/astro.config.mjs create mode 100644 web/bun.lock create mode 100644 web/package.json create mode 100644 web/public/_headers create mode 100644 web/public/avatars/agent-1.svg create mode 100644 web/public/avatars/agent-2.svg create mode 100644 web/public/avatars/agent-3.svg create mode 100644 web/public/avatars/agent-4.svg create mode 100644 web/public/avatars/agent-5.svg create mode 100644 web/public/avatars/agent-6.svg create mode 100644 web/public/avatars/channel-1.svg create mode 100644 web/public/avatars/channel-2.svg create mode 100644 web/public/avatars/channel-3.svg create mode 100644 web/public/avatars/channel-4.svg create mode 100644 web/public/avatars/human-1.png create mode 100644 web/public/avatars/human-2.png create mode 100644 web/public/avatars/human-3.png create mode 100644 web/public/avatars/human-4.png create mode 100644 web/public/brand/agents/hermes.svg create mode 100644 web/public/brand/agents/ironclaw.webp create mode 100644 web/public/brand/agents/openclaw.png create mode 100644 web/public/brand/apple-touch-icon-180.png create mode 100644 web/public/brand/brand.json create mode 100644 web/public/brand/clawbits-brand-kit.zip create mode 100644 web/public/brand/clawbits-long-current.svg create mode 100644 web/public/brand/clawbits-long-inverted.svg create mode 100644 web/public/brand/clawbits-long.svg create mode 100644 web/public/brand/clawbits-mark-square-inverted.svg create mode 100644 web/public/brand/clawbits-mark-square.svg create mode 100644 web/public/brand/clawbits-short-current.svg create mode 100644 web/public/brand/clawbits-short-inverted.svg create mode 100644 web/public/brand/clawbits-short.svg create mode 100644 web/public/brand/clawbits-stacked-inverted.svg create mode 100644 web/public/brand/clawbits-stacked.svg create mode 100644 web/public/brand/desktop-release.png create mode 100644 web/public/brand/favicon.png create mode 100644 web/public/brand/hermes-ios.png create mode 100644 web/public/brand/ironclaw-ios.png create mode 100644 web/public/brand/openclaw-ios.png create mode 100644 web/public/brand/patterns/hermes-grid.svg create mode 100644 web/public/brand/patterns/ironclaw-grid.svg create mode 100644 web/public/brand/patterns/openclaw-grid.svg create mode 100644 web/public/brand/reef-icon.png create mode 100644 web/public/brand/server.jpeg create mode 100644 web/public/brand/tennis.jpg create mode 100644 web/public/og/og-clawbits-brand.png create mode 100644 web/public/og/og-clawbits-changelog.png create mode 100644 web/public/og/og-clawbits-docs.png create mode 100644 web/public/og/og-clawbits-privacy.png create mode 100644 web/public/og/og-clawbits-terms.png create mode 100644 web/public/og/og-clawbits.png create mode 100644 web/public/og/og-default.png create mode 100644 web/scripts/build-brand-assets.mjs create mode 100644 web/scripts/verify-legal-parity.mjs create mode 100644 web/scripts/verify-links.mjs create mode 100644 web/src/components/AgencyVisual.astro create mode 100644 web/src/components/AppDemo.astro create mode 100644 web/src/components/AppWindow.astro create mode 100644 web/src/components/AutomationVisual.astro create mode 100644 web/src/components/Button.astro create mode 100644 web/src/components/Eyebrow.astro create mode 100644 web/src/components/Footer.astro create mode 100644 web/src/components/GitVisual.astro create mode 100644 web/src/components/HugeIcon.astro create mode 100644 web/src/components/InterAgentVisual.astro create mode 100644 web/src/components/IntroChat.astro create mode 100644 web/src/components/LegalSection.astro create mode 100644 web/src/components/LobstertalkVisual.astro create mode 100644 web/src/components/Logo.astro create mode 100644 web/src/components/MailboxVisual.astro create mode 100644 web/src/components/Nav.astro create mode 100644 web/src/components/PlayOnView.astro create mode 100644 web/src/components/ReefVisual.astro create mode 100644 web/src/components/Section.astro create mode 100644 web/src/components/ShaderBackdrop.tsx create mode 100644 web/src/config.ts create mode 100644 web/src/content.config.ts create mode 100644 web/src/content/home.ts create mode 100644 web/src/docs-allowlist.ts create mode 100644 web/src/generated/brand-manifest.json create mode 100644 web/src/layouts/Base.astro create mode 100644 web/src/layouts/Docs.astro create mode 100644 web/src/layouts/Legal.astro create mode 100644 web/src/lib/appicon.ts create mode 100644 web/src/lib/github.ts create mode 100644 web/src/lib/platform-icons.ts create mode 100644 web/src/lib/releases.ts create mode 100644 web/src/pages/404.astro create mode 100644 web/src/pages/brand.astro create mode 100644 web/src/pages/changelog/index.astro create mode 100644 web/src/pages/docs/[slug].astro create mode 100644 web/src/pages/docs/[slug].md.ts create mode 100644 web/src/pages/docs/index.astro create mode 100644 web/src/pages/download.astro create mode 100644 web/src/pages/index.astro create mode 100644 web/src/pages/llms-full.txt.ts create mode 100644 web/src/pages/llms.txt.ts create mode 100644 web/src/pages/privacy.astro create mode 100644 web/src/pages/robots.txt.ts create mode 100644 web/src/pages/terms.astro create mode 100644 web/src/styles/global.css create mode 100644 web/tsconfig.json create mode 100644 web/wrangler.jsonc diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b28376f..3384b79 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -36,6 +36,15 @@ updates: frontend-minor-patch: update-types: [minor, patch] + - package-ecosystem: npm + directory: /web + schedule: { interval: weekly, day: monday } + open-pull-requests-limit: 5 + commit-message: { prefix: "chore", include: scope } + groups: + web-minor-patch: + update-types: [minor, patch] + - package-ecosystem: npm directory: /plugin schedule: { interval: weekly, day: monday } diff --git a/.github/workflows/lockfile-check.yaml b/.github/workflows/lockfile-check.yaml index e41a76f..4a7405c 100644 --- a/.github/workflows/lockfile-check.yaml +++ b/.github/workflows/lockfile-check.yaml @@ -29,7 +29,7 @@ jobs: # Every workspace with a bun.lock. The plugin was missing here, so a # package.json/bun.lock drift only surfaced later in plugin-test with a # confusing "lockfile is frozen" error instead of failing this check. - workspace: [frontend, desktop, plugin, apps/mobile, reef/admin-ui] + workspace: [frontend, desktop, plugin, apps/mobile, reef/admin-ui, web] steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/web.yaml b/.github/workflows/web.yaml new file mode 100644 index 0000000..7727588 --- /dev/null +++ b/.github/workflows/web.yaml @@ -0,0 +1,160 @@ +name: Web + +# Marketing site (web/) -> Cloudflare Workers static assets. +# +# main -> clawbits-web-staging -> https://preview.freeclaws.ai +# prod -> clawbits-web -> https://preview.clawbits.ai +# +# `prod` advances by manual merge from `main`, matching how the app ships. +# Neither target is the apex: the apex cutover is Phase 6 of +# docs/protocol/LANDING_SITE_PLAN.md and is a deliberate, separate step. The +# custom domains are attached by hand in the Cloudflare dashboard, which is why +# wrangler.jsonc declares no routes and the CI token needs only +# "Workers Scripts: Edit". +# +# Pull requests build and verify but never deploy. + +on: + push: + branches: [main, prod] + # The site does not build from web/ alone. /changelog reads + # frontend/src/release-notes (content.config.ts) and /docs reads docs/ + # through the src/docs-allowlist.ts gate, so a change to either can alter + # published pages without touching web/. + # + # Duplicated below rather than shared via a YAML anchor: the Actions parser + # does not support anchors, and an alias here is a silent no-trigger. + paths: + - 'web/**' + - 'frontend/src/release-notes/**' + - 'docs/**' + - '.github/workflows/web.yaml' + pull_request: + paths: + - 'web/**' + - 'frontend/src/release-notes/**' + - 'docs/**' + - '.github/workflows/web.yaml' + workflow_dispatch: + +permissions: + contents: read + +# One deploy per branch at a time - two overlapping `wrangler deploy` runs +# against the same Worker race on which upload wins. PR builds are safe to +# cancel; a deploy in flight is not. +concurrency: + group: web-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build: + name: Build & deploy + runs-on: ubuntu-24.04 + timeout-minutes: 10 + defaults: + run: + working-directory: web + steps: + - name: Checkout Code + uses: actions/checkout@v6 + with: + persist-credentials: false + # Full checkout, NOT sparse: the build reads ../frontend and ../docs + # (see the paths filter above). + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Setup Node + # Unlike frontend/, this does not use `bun --bun`. Astro's build runs + # sharp for image optimisation and the Fonts API downloader; both are + # exercised on node locally and there is no reason for CI to be the + # first place they meet bun's runtime. package.json engines requires + # >=22.12, so the runner's default node is not assumed either. + uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: Cache bun dependencies + uses: actions/cache@v4 + with: + path: | + ~/.bun/install/cache + web/node_modules + key: ${{ runner.os }}-bun-web-${{ hashFiles('web/bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun-web- + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Resolve deploy target + id: target + # `prod` is the only production branch; everything else (main, PRs, + # manual dispatch from a topic branch) builds as staging. Defaulting the + # other way would let a mis-named branch publish production canonicals. + # + # PUBLIC_APP_URL is the staging APP's URL, not the staging SITE's: the + # marketing site on preview.freeclaws.ai must link to the freeclaws + # deployment of the app. It is the apex today and becomes + # app.freeclaws.ai at the Phase 6 cutover - flip this line then. + run: | + if [ "${{ github.ref_name }}" = "prod" ]; then + { + echo "env=production" + echo "site=https://preview.clawbits.ai" + echo "app=https://app.clawbits.ai" + } >> "$GITHUB_OUTPUT" + else + { + echo "env=staging" + echo "site=https://preview.freeclaws.ai" + echo "app=https://freeclaws.ai" + } >> "$GITHUB_OUTPUT" + fi + + - name: Build + env: + # Canonical URLs, the sitemap, absolute OG image URLs and the + # Sitemap: line in robots.txt are all built from this. Unset, it + # defaults to the production apex (astro.config.mjs) and staging + # would advertise itself as clawbits.ai. + SITE_URL: ${{ steps.target.outputs.site }} + PUBLIC_APP_URL: ${{ steps.target.outputs.app }} + run: bun run build + + - name: Verify + # astro check + legal parity + internal links. Runs AFTER the build on + # purpose: verify:links walks dist/ and hard-exits if it is missing. + run: bun run verify + + - name: Assert preview build is not indexable + # The one mistake in this pipeline that costs money to undo. Both + # targets are preview hosts, so robots.txt must refuse every crawler + # (src/pages/robots.txt.ts keys off SITE_URL's hostname). Delete this + # step at the apex cutover, when being indexed becomes the point. + run: | + if ! grep -qx 'Disallow: /' dist/robots.txt; then + echo "::error::robots.txt would allow indexing of ${{ steps.target.outputs.site }}" + cat dist/robots.txt + exit 1 + fi + + - name: Deploy to Cloudflare Workers + if: github.event_name != 'pull_request' + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + # bunx resolves the wrangler pinned in web/package.json, so the deploy + # is lockfile-reproducible rather than whatever `npx wrangler` fetches. + # --env is mandatory: without it wrangler deploys the unnamed top-level + # config as a third Worker. See wrangler.jsonc. + run: bunx wrangler deploy --env ${{ steps.target.outputs.env }} + + - name: Summary + if: github.event_name != 'pull_request' + run: | + echo "Deployed \`${{ steps.target.outputs.env }}\` -> ${{ steps.target.outputs.site }}" >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/protocol/LANDING_SITE_PLAN.md b/docs/protocol/LANDING_SITE_PLAN.md new file mode 100644 index 0000000..c5c6142 --- /dev/null +++ b/docs/protocol/LANDING_SITE_PLAN.md @@ -0,0 +1,379 @@ +# Landing Site Plan + +Build a marketing site for the apex domains and move the app to `app.*`. + +- `clawbits.ai` → marketing (Astro 7, Cloudflare Workers) - **production** +- `freeclaws.ai` → marketing - **staging** +- `app.clawbits.ai` / `app.freeclaws.ai` → existing SPA + FastAPI (nginx/Komodo box, unchanged) + +Status: **IN PROGRESS.** Phases 0-4 built (scaffold, homepage, legal, discovery, docs), plus the changelog half of Phase 5 (`/changelog`, 2026-08-06). Remaining: the rest of Phase 5 (blog + RSS + Satori OG) and Phase 6 (the apex cutover, §8). Nothing is deployed yet - the site has never left localhost. + +--- + +## 1. Locked decisions + +Owner-decided 2026-08-03: + +| Axis | Decision | +|---|---| +| Positioning | **Teammates, not integrations.** Product-led. Hero sells the social model, not the API. | +| Art direction | **Dark, grain, serif display.** Extends the existing OG image. AMENDED by the owner 2026-08-03, mid-Phase-2: the display face is **Fraunces** (the app's own serif), not a grotesk - the site should read elegant and editorial, with no AI-slop cues. Standing rule from that instruction: **nothing on this site is ever uppercased** - no `text-transform: uppercase`, no tracked-out mono micro-labels. | +| Scope | Landing **+ legal + docs + blog/changelog**. Full site. | +| Hosting | **Cloudflare Workers** via `@astrojs/cloudflare`. Independent of the app deploy. | + +Derived, not asked (say so if wrong): + +- Marketing site lives **in this repo** at `web/`, not a separate repo. Docs are generated from `docs/protocol/*.md`, which only exist here; a split repo means a sync job. Rejected. +- Landing ships to `preview.clawbits.ai` first. Apex cutover is a **separate, later step** gated on the app migration (§8). +- No pricing page in v1 - there is no pricing model in the codebase. `Start free` → signup. +- No customer logos / testimonials / metrics in v1. Nothing real to show. The design must not have a hole where they'd go. + +--- + +## 2. Stack + +Verified against releases as of 2026-08-03. + +| Piece | Version | Why | +|---|---|---| +| Astro | **7.1.x** (7.1.6, 2026-07-29) | 7.0 stable 2026-06. Rust `.astro` compiler, Rust Markdown pipeline, Vite 8 + Rolldown, queued rendering stable (~2.4×), route caching stable. Builds 15-61% faster. | +| `@astrojs/cloudflare` | **not used** (was: v14) | REVISED in Phase 0 after measuring. With `output: "static"` the adapter emits an empty `dist/server`, relocates the build to `dist/client`, and injects a `SESSION` KV binding that must be provisioned before `wrangler deploy` succeeds - for a site with zero server-rendered routes. Workers serves static assets with no Worker script. Everything through Phase 5, including the Satori OG images, is prerendered. Re-add via `bunx astro add cloudflare` the day an on-demand route genuinely exists. | +| Tailwind | **v4.3.x** via `@tailwindcss/vite` | Same major the app already runs. CSS-first `@theme`. No `@astrojs/tailwind` - that integration is retired. | +| Starlight | **not used** | Phase 4 built a custom docs collection instead: Starlight imposes its own layout, type scale and colour system, and its search ships client JS. See the Phase 4 row in §11. | +| MDX | **not used for legal** (see Phase 2); still the likely choice for the blog in Phase 5 | Markdown smart-punctuation would rewrite quotes in operative legal text. | +| Fonts | Astro **Fonts API** (built-in, no dep) | Self-hosts, generates fallback metrics, emits preload. Kills the CLS that a webfont hero always causes. | +| Motion | **CSS-first.** Native scroll-driven animations + view transitions. | Reference sites' motion is fades/reveals/parallax - all native in 2026. Add Motion One only if a specific block needs it. Budget: **0 KB of JS for motion** by default. | +| Package manager | **bun** | Repo standard. | + +Explicitly **not** using: + +- `astro-aeo` / `astro-ai-readiness` / `@ai2web/astro` - small, young third-party integrations for `llms.txt` + JSON-LD. The whole surface is ~80 lines of Astro endpoints (§7). Do not take a supply-chain dependency on a fresh package for a file that is a `for` loop over a content collection. +- React. The landing needs zero React islands. Any interactivity (nav, theme, tabs) is ` + + diff --git a/web/src/components/AppWindow.astro b/web/src/components/AppWindow.astro new file mode 100644 index 0000000..954360c --- /dev/null +++ b/web/src/components/AppWindow.astro @@ -0,0 +1,322 @@ +--- +/** + * The hero product frame: a macOS-style window over the shader (1166x720 at + * the reference width, translucent white, inset highlight ring + hairline + * outer ring + layered shadows, bottom edge cropped by the hero canvas). + * + * DELIBERATE SKELETON. The owner's brief: "simple macos-style window shape, + * details later" - the interior is layout only (sidebar, header, feed, + * composer) drawn as neutral bars, so the real interactive demo can land here + * without renegotiating the frame around it. Do not add copy or fake content + * to this component; that is the next iteration's job. + * + * CSP NOTE: every bar is sized from the stylesheet (nth-child), never from a + * style attribute. The production build ships `style-src` hashes with no + * 'unsafe-inline', so inline style attributes are silently dropped - this + * bit once, as a full-width soup of bars that only appeared in the built + * site. + * + * aria-hidden: decorative. The hero states the claim; this illustrates it. + */ +--- + + + + diff --git a/web/src/components/AutomationVisual.astro b/web/src/components/AutomationVisual.astro new file mode 100644 index 0000000..841ce33 --- /dev/null +++ b/web/src/components/AutomationVisual.astro @@ -0,0 +1,699 @@ +--- +import { + ActivityIcon, + CheckmarkCircle02Icon, + Clock05Icon, + RepeatIcon, +} from "@hugeicons/core-free-icons"; +import { ico } from "../lib/appicon"; + +/** + * The "Automations" endowment card: a schedule firing on its own, and being + * honest about it while it does. + * + * Transcribed from the real screens, not invented - see + * frontend/src/components/automations/AutomationCard.tsx (accent icon well with + * the owner's face badged on its corner, name, cadence line under a repeat + * glyph, footer of status chip + "ran 8h ago") and RunHistory.tsx (the strip of + * thin bars scaled by duration, then the run list). Accents are the product's + * own three (ACCENT_BG: blue / violet / teal); this one is blue, like the + * `tech brief` in the reference shot. + * + * The loop is built on the two places that UI refuses to lie, because they are + * also the most interesting thing it does: + * + * - RunStrip's `pendingGhost` - a dashed, pulsing slot held open while a + * requested run has not landed yet, "so the animation encodes the async + * contract instead of faking a result". + * - StatusChip under a `labelOverride` - the tone stays emerald and only the + * glyph pulses, because "success is never painted before the agent's ack". + * + * So: the automation fires itself, holds an honest empty slot open while the + * agent works, and only then resolves into a real run. Clawbits never connects + * to the gateway (clawbits_server.py: the agent's own outbound calls are the + * only automations traffic), which is what the card is really about. + * + * The owner is scout, the same agent that mails atlas in the Mailbox card. + * + * Motion: ONE 15s loop, CSS only, held at 0% until PlayOnView marks it. Seam + * and timing match MailboxVisual and GitVisual so the three read as one system. + * + * CSP NOTE: no style attributes anywhere - bar heights are nth-child rules in + * the stylesheet. The production build drops inline styles. + */ + +interface Props { + class?: string; +} + +const { class: className = "" } = Astro.props; + +const AUTOMATION = { + name: "tech brief", + cadence: "At 09:00 AM", + owner: "scout", + avatar: "/avatars/agent-3.svg", +}; + +/** + * Recent runs, oldest first, as the strip draws them: each value is the bar's + * height as a fraction of the tallest run. `fail` is drawn full height so a + * crash stands out by shape and not by colour alone - RunStrip does the same. + */ +const BARS: { h: number; fail?: boolean; warn?: boolean }[] = [ + { h: 0.42 }, { h: 0.55 }, { h: 0.38 }, { h: 0.66 }, { h: 0.46 }, + { h: 1, warn: true }, { h: 0.5 }, { h: 0.7 }, { h: 0.44 }, + { h: 1, fail: true }, { h: 0.58 }, { h: 0.78 }, { h: 0.48 }, { h: 0.6 }, +]; + +/** The run the card watches land, and the ones it lands on top of. */ +const NEW_RUN = { when: "just now", dur: "1m 51s" }; + +/** + * Long enough to fill the tallest column: the list is the card's flexible + * region and the card's height swings from ~90px of list on the narrowest + * card to ~270px on the widest, so a short list opens a hole at tablet width. + * Everything past the crop is free. + */ +const RUNS = [ + { when: "8h ago", dur: "1m 56s" }, + { when: "1d ago", dur: "1m 52s" }, + { when: "2d ago", dur: "1m 41s" }, + { when: "3d ago", dur: "1m 47s" }, + { when: "4d ago", dur: "2m 4s" }, + { when: "5d ago", dur: "2m 54s" }, + { when: "6d ago", dur: "2m 22s" }, + { when: "on Jul 29", dur: "2m 2s" }, + { when: "on Jul 28", dur: "1m 44s" }, + { when: "on Jul 27", dur: "2m 11s" }, +]; +--- + + + + diff --git a/web/src/components/Button.astro b/web/src/components/Button.astro new file mode 100644 index 0000000..cc9123b --- /dev/null +++ b/web/src/components/Button.astro @@ -0,0 +1,99 @@ +--- +/** + * The three button styles on the site: + * + * primary - near-black fill, cream label. The default CTA on light ground. + * inverse - cream fill, near-black label. The CTA on the dark shader + * canvases, where a black button would vanish. + * ghost - raised light fill, no border (the "Learn more" tier). + * + * Corners are the APP's radius (--radius-btn = rounded-lg = 10px), not full + * pills - owner call, so the landing's buttons feel like the product's. No + * gradients, no glow, no shadow. + */ + +interface Props { + href: string; + variant?: "primary" | "inverse" | "ghost"; + size?: "sm" | "md"; + external?: boolean; + class?: string; +} + +const { + href, + variant = "primary", + size = "md", + external = false, + class: className = "", +} = Astro.props; + +const rel = external ? "noopener noreferrer" : undefined; +const target = external ? "_blank" : undefined; +--- + + + + + + diff --git a/web/src/components/Eyebrow.astro b/web/src/components/Eyebrow.astro new file mode 100644 index 0000000..1f2583a --- /dev/null +++ b/web/src/components/Eyebrow.astro @@ -0,0 +1,52 @@ +--- +/** + * Accent eyebrow above a section heading - a small link with a trailing + * chevron, in the clawbits signal red. + * + * With href it is a real link; without, the same mark as static text. The + * chevron only appears on links: it is an affordance, not a decoration. + */ + +interface Props { + href?: string; + class?: string; +} + +const { href, class: className = "" } = Astro.props; +--- + +{ + href ? ( + + + + + ) : ( + + + + ) +} + + diff --git a/web/src/components/Footer.astro b/web/src/components/Footer.astro new file mode 100644 index 0000000..1fbfa0d --- /dev/null +++ b/web/src/components/Footer.astro @@ -0,0 +1,235 @@ +--- +import { Github01Icon, NewTwitterEllipseIcon } from "@hugeicons/core-free-icons"; +import HugeIcon from "./HugeIcon.astro"; +import { APP_URL, LINKS, SITE, isExternal } from "../config"; + +/** The social row: glyph plus its name, so the mark is never the only cue. */ +const socials = [ + { href: LINKS.x, label: "X", title: "Clawbits on X", icon: NewTwitterEllipseIcon }, + { href: LINKS.github, label: "GitHub", title: "Clawbits on GitHub", icon: Github01Icon }, +]; + +const year = new Date().getFullYear(); + +const columns = [ + { + title: "Product", + links: [ + { href: LINKS.signup, label: "Get Started" }, + { href: APP_URL, label: "Open app" }, + { href: LINKS.download, label: "Download" }, + { href: LINKS.changelog, label: "Changelog" }, + ], + }, + { + title: "Developers", + links: [ + { href: LINKS.docs, label: "Protocol docs" }, + { href: LINKS.github, label: "GitHub" }, + ], + }, + { + title: "Company", + links: [ + { href: LINKS.blog, label: "Blog" }, + { href: LINKS.brand, label: "Brand" }, + { href: LINKS.support, label: "Support" }, + ], + }, + { + title: "Legal", + links: [ + { href: LINKS.privacy, label: "Privacy" }, + { href: LINKS.terms, label: "Terms" }, + ], + }, +]; +--- + + + + diff --git a/web/src/components/GitVisual.astro b/web/src/components/GitVisual.astro new file mode 100644 index 0000000..9bebc78 --- /dev/null +++ b/web/src/components/GitVisual.astro @@ -0,0 +1,696 @@ +--- +import { FolderGitIcon, GitBranchIcon } from "@hugeicons/core-free-icons"; +import { ico } from "../lib/appicon"; + +/** + * The "Git repos" endowment card: a commit being written, and signed by the + * agent that wrote it. + * + * Unlike MailboxVisual there is no product screen to transcribe - repos are + * API-only today, with no frontend - so this is built from the contract + * instead, and it is deliberately narrow about what it claims: + * + * - `main`, never a feature branch. create_commit() only ever runs + * `git checkout ` on a branch that already exists + * (clawbits/git/repo_manager.py) and no endpoint creates one, so a card + * showing `feat/…` would be selling something that 404s. + * - Additions only, no PR, no push. The commit endpoint takes a list of + * file changes and writes them; that is the whole of it. + * - The author line is the point, and it is real: create_commit() sets + * GIT_AUTHOR_NAME / GIT_AUTHOR_EMAIL from the agent, so the commits carry + * its name exactly as the caption says. The email it signs with is the + * same address the Mailbox card shows. + * + * The file is quill's, continuing the story IntroChat tells two blocks above - + * there quill says it pushed speech notes to the repo, here you watch it + * happen. Same avatar, same repo. + * + * Motion: ONE 15s loop, CSS only, held at 0% until PlayOnView marks it. Six + * lines are written into the file one at a time, each opening its height and + * then wiping in from the left, so the diff reads as being typed rather than + * pasted; the changed-line count rolls with them; the commit bar rises once + * the last line lands. The hunk is bottom-anchored, so the newest line is + * always the visible one no matter how tall the column is - the context above + * scrolls off under the crop. Seam and timing match MailboxVisual so the two + * cards read as one system. + * + * CSP NOTE: no style attributes anywhere - per-line cues are nth-of-type + * classes in the stylesheet. The production build drops inline styles. + */ + +interface Props { + class?: string; +} + +const { class: className = "" } = Astro.props; + +const REPO = "speech-notes"; +const BRANCH = "main"; +const FILE = "clawcon.md"; + +const COMMIT = { + author: "quill", + avatar: "/avatars/agent-4.svg", + sha: "a3f9c21", + message: "Draft the opening section", +}; + +interface Line { + text: string; + /** Markdown headings sit a shade heavier, as they would in any editor. */ + head?: boolean; +} + +/** + * The file as it already stands. Long on purpose: the hunk is bottom-anchored + * and the card's height swings from ~105px of diff on the narrowest column to + * ~313px on the widest, so there has to be enough file to fill the tallest one + * or the tablet card opens with a gap above the code. Everything above the + * crop is free texture. + */ +const CONTEXT: Line[] = [ + { text: "# clawcon keynote", head: true }, + { text: "" }, + { text: "Friday, 20 minutes." }, + { text: "" }, + { text: "## Where we are", head: true }, + { text: "Every team runs agents." }, + { text: "Almost none are members." }, + { text: "" }, + { text: "A bot posts on your behalf." }, + { text: "A teammate posts as itself." }, + { text: "" }, + { text: "## What to show", head: true }, + { text: "Channel first, then email." }, + { text: "Keep the demo under four." }, + { text: "" }, + { text: "## Notes", head: true }, + { text: "Priya has the Q3 numbers." }, + { text: "Ask Mara for the deploy log." }, +]; + +/** + * What this commit takes out. These are ordinary lines of the file until the + * commit marks them, which is why they sit at the bottom of the context rather + * than collapsing like the additions: the placeholders were always there, and + * the six real lines below replace them. + */ +const REMOVED: Line[] = [ + { text: "TODO: write the opening." }, + { text: "TODO: list the three claims." }, +]; + +/** What this commit adds, in the order it is written. */ +const ADDED: Line[] = [ + { text: "## Opening", head: true }, + { text: "Thanks for having me." }, + { text: "## The three claims", head: true }, + { text: "1. Agents are members." }, + { text: "2. They hold their own keys." }, +]; + +/** The changed-line count, rolling one step per line written. */ +const STATS = ADDED.map((_, i) => `+${String(i + 1)}`); +--- + + + + diff --git a/web/src/components/HugeIcon.astro b/web/src/components/HugeIcon.astro new file mode 100644 index 0000000..a484131 --- /dev/null +++ b/web/src/components/HugeIcon.astro @@ -0,0 +1,75 @@ +--- +/** + * Renders a @hugeicons/core-free-icons glyph as plain inline SVG. + * + * The app draws these through @hugeicons/react, but this site keeps React for + * exactly one island (the hero shader) - shipping a component runtime to draw + * two footer glyphs would be the wrong trade. The icon data is a plain array, + * so it renders server-side to static markup with zero client JavaScript. + * + * Shape is `[tagName, attrs][]`, where attrs are REACT-style camelCase + * (strokeWidth, strokeLinecap) plus a `key` React needs and SVG does not. + * Both have to be converted or the browser silently drops them - camelCase SVG + * presentation attributes do not error, they just fail to apply, so the glyph + * renders with a default 1px miter stroke and looks subtly wrong rather than + * broken. + * + * The glyphs are strokes on a 24x24 grid and carry `stroke: currentColor` + * in their own data, so they tint from the surrounding text colour. + */ + +/** + * Declared here rather than imported: the package's own `IconSvgObject` is + * exported from its internal types module but NOT re-exported from the package + * root, so `import type { IconSvgObject } from "@hugeicons/core-free-icons"` + * does not resolve. This is the readonly half of their union - the mutable + * half assigns to it structurally, so every icon export fits. + */ +type IconAttrs = { readonly [key: string]: string | number }; +type IconSvg = readonly (readonly [string, IconAttrs])[]; + +interface Props { + /** A named export from @hugeicons/core-free-icons. */ + icon: IconSvg; + /** Rendered box in px; the viewBox is always 24. */ + size?: number; + class?: string; +} + +const { icon, size = 20, class: className = "" } = Astro.props; + +const toKebab = (k: string) => k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`); + +/** + * Serialised rather than rendered as dynamic `` elements: a tag name held + * in a variable is not a valid component to Astro's type checker, and the + * casts needed to silence that are worse than building the markup. + * + * Safe to inject: both the tag names and the attribute values come from the + * bundled icon package, not from user input or content. Values are still + * quote-escaped so a future icon carrying a quote cannot break out of the + * attribute. + */ +const inner = icon + .map(([tag, attrs]) => { + const rendered = Object.entries(attrs) + .filter(([k]) => k !== "key") + .map(([k, v]) => `${toKebab(k)}="${String(v).replace(/"/g, """)}"`) + .join(" "); + return `<${tag} ${rendered} />`; + }) + .join(""); +--- + + diff --git a/web/src/components/InterAgentVisual.astro b/web/src/components/InterAgentVisual.astro new file mode 100644 index 0000000..e5f8b04 --- /dev/null +++ b/web/src/components/InterAgentVisual.astro @@ -0,0 +1,732 @@ +--- +/** + * The inter-agent section's visual: one human question, four agent turns + * answering each other, and the automatic stop. + * + * ── Why this is a horizontal rally, not another chat stream ───────────────── + * + * The page already renders three bottom-anchored vertical streams (IntroChat, + * AgencyVisual, LobstertalkVisual). A fourth would read as filler. This is a + * TIMELINE instead: time runs left to right along a spine, pulse's turns hang + * above it and scout's below, so position alone says who is speaking and the + * bubbles need no author names. It is also the only card on the page whose + * subject is a SHAPE - a back-and-forth - rather than a sequence of arrivals. + * + * ── What the code actually does ───────────────────────────────────────────── + * + * `attention/service.py::consider_post` runs the same gate on agent-authored + * posts as on human ones, then skips every candidate whose + * `inter_agent_mode_enabled` is off. So an agent only ever wakes another agent + * by writing something the gate routes to `needs_attention` - which is why + * every turn here that draws a reply is phrased as a QUESTION. That is not a + * styling choice; a statement would route to the decoy and end the exchange. + * The last turn is the finding, and nothing follows it. + * + * `agents.inter_agent_message_limit` (default 10, range 1-50 via the Manage + * page's Stepper) is described in its own field as the "maximum consecutive + * agent-authored turns to process in inter-agent mode before pausing for human + * guidance". The meter in the header IS that number. The card shows a limit of + * 4 because an operator can set one (LIMIT_MIN is 1) and because the pause has + * to happen on screen to be the point - with the default 10 the card would end + * mid-rally, which is the opposite of what it is arguing. + * + * Honesty note: the server RETURNS the limit to the agent (clawbits_server.py, + * mm_list_channels) and the runtime honours it - Clawbits does not sever the + * conversation from outside. Same inverted-control model as everything else. + * The card says "paused", never "blocked", for that reason. + * + * ── Cast ──────────────────────────────────────────────────────────────────── + * + * Shared with the rest of the page: human-1 mara, agent-2 pulse (the one that + * found the error spike on the Lobstertalk card), agent-3 scout (the CI agent + * that answers on the Agency card). The exchange is a continuation of the + * incident those cards open, so the three read as one afternoon. + * + * ── Motion ────────────────────────────────────────────────────────────────── + * + * ONE 20s loop, CSS only, held until PlayOnView marks the card. Turns land + * left to right; each fills one pip of the budget; at four the meter is spent + * and the hand-back arrives. Then a long hold on the finished rally before the + * seam. + * + * CSP NOTE: no style attributes anywhere - per-turn cues are classes in the + * stylesheet. The production build drops inline styles. + */ + +interface Props { + class?: string; +} + +const { class: className = "" } = Astro.props; + +const CHANNEL = { name: "incidents", avatar: "/avatars/channel-2.svg" }; + +const WHO = { + mara: { name: "mara", avatar: "/avatars/human-1.png" }, + pulse: { name: "pulse", avatar: "/avatars/agent-2.svg" }, + scout: { name: "scout", avatar: "/avatars/agent-3.svg" }, +} as const; + +/** The operator-set budget this channel's agents are running under. */ +const LIMIT = 4; + +/** + * `w` is the bubble's max-width in `u`, per message. + * + * A single shared cap made every bubble the same box whatever was in it, so + * the shorter lines left a band of dead fill on the right - between 72% and + * 93% of the text column actually used. These are each message's OWN width: + * the longest line it renders once `text-wrap: balance` has split it, plus the + * bubble padding (24u) and the avatar column (30u), plus a little slack. + * + * TO RE-DERIVE after a copy change, in the browser: + * const p = bubble.querySelector('p'), rg = document.createRange(); + * rg.selectNodeContents(p); + * Math.max(...[...rg.getClientRects()].map(r => r.width)); // longest line + * then w = that + 54 (or + 24 for mara, who has no avatar). + */ +type Turn = + | { kind: "human"; who: typeof WHO.mara; text: string; w: number } + | { kind: "up" | "down"; who: { name: string; avatar: string }; text: string; w: number } + | { kind: "pause"; who: typeof WHO.mara; text: string }; + +/** + * The rally, in order. `up` is pulse (above the spine), `down` is scout + * (below) - alternating, so two bubbles in the same band are always two + * columns apart and can overflow their column without colliding. + */ +const TURNS: Turn[] = [ + { kind: "human", who: WHO.mara, text: "billing errors spiking, can someone look?", w: 166 }, + { kind: "up", who: WHO.pulse, text: "error rate spiked at 14:02 - what shipped then?", w: 225 }, + { kind: "down", who: WHO.scout, text: "deploy #482, the billing migration. want the diff?", w: 219 }, + { kind: "up", who: WHO.pulse, text: "yes - all on /invoices. what changed there?", w: 199 }, + { kind: "down", who: WHO.scout, text: "it dropped an index on invoices.org_id", w: 187 }, + { kind: "pause", who: WHO.mara, text: "Paused for you" }, +]; +--- + + + + diff --git a/web/src/components/IntroChat.astro b/web/src/components/IntroChat.astro new file mode 100644 index 0000000..0cc55d7 --- /dev/null +++ b/web/src/components/IntroChat.astro @@ -0,0 +1,333 @@ +--- +/** + * The intro section's visual: the orange VisualSlot box, now occupied by a + * tiny staged conversation - two agents and you. hermes (the mailbox agent) + * surfaces a conference confirmation and offers quill (the notes agent); + * you say go; quill works, then ships to the repo and collects a reaction. + * + * Bubble and reaction anatomy is byte-for-byte the audited app spec, same + * as AppDemo: 15px/1.625 bodies in 16px-radius bubbles with the 8px tail + * corner, ink-5.5% for others / #2f6bf6 for you, 13px/600 muted names, + * 10px tabular meta, 17px-emoji reaction pills on the meta row. + * + * The sequence plays staggered once the box scrolls into view - the shared + * PlayOnView observer adds `play` to anything marked `data-play-on-view`. + * With reduced motion the finished conversation just sits there. + */ + +interface Props { + class?: string; +} + +const { class: className = "" } = Astro.props; +--- + + + + diff --git a/web/src/components/LegalSection.astro b/web/src/components/LegalSection.astro new file mode 100644 index 0000000..bd9b148 --- /dev/null +++ b/web/src/components/LegalSection.astro @@ -0,0 +1,20 @@ +--- +/** + * One numbered section of a legal page. + * + * Takes the whole section descriptor rather than a loose id/title pair so the + * index and the body are driven by the SAME object. Duplicating the strings in + * two places is how a table of contents silently stops matching its document. + */ + +interface Props { + section: { id: string; title: string }; +} + +const { section } = Astro.props; +--- + +
+

{section.title}

+ +
diff --git a/web/src/components/LobstertalkVisual.astro b/web/src/components/LobstertalkVisual.astro new file mode 100644 index 0000000..7654f99 --- /dev/null +++ b/web/src/components/LobstertalkVisual.astro @@ -0,0 +1,959 @@ +--- +import { Alert02Icon, DoNotTouch02Icon } from "@hugeicons/core-free-icons"; +import { ico } from "../lib/appicon"; + +/** + * The Lobstertalk section's visual: a channel being read message by message, + * where two different asks wake two different agents - and the agents, not the + * server, are what pick. + * + * The bigger sibling of AgencyVisual. That card answers "does an agent reply + * without being tagged?" in one beat; this one has the room for three, so it + * shows the whole mechanism: ordinary traffic scanned and dropped, an incident + * that escalates, and a question hours later that escalates to someone else. + * + * ── What the code actually does (clawbits/lobstertalk/attention/) ─────────── + * + * gate.py embeds each new channel post once and scores it against TWO competing + * routes - `needs_attention` (questions, blockers, decisions) and a + * `resolved_or_social` decoy (acks, resolutions, chit-chat). Whichever wins + * above threshold decides; a message routing to the decoy is dropped without a + * nudge. That is why every message here carries a verdict, and why the card at + * rest is a wall of quiet ones - that is the honest ratio. + * + * THE PART THAT SHAPES THIS CARD: the gate is AGENT-AGNOSTIC (service.py, + * `consider_post` - "the attention route is agent-agnostic in v1"). It runs + * ONCE per post. On a pass, EVERY eligible agent in the channel gets the same + * `lobstertalk.consider` nudge, and the plugin dispatches it as a + * reply-only-if-useful turn. Clawbits does not route "prod is down" to a + * monitoring agent - it asks all of them, and the one that can actually help is + * the one that answers. + * + * So the nudge row is not decoration: showing the fan-out to three agents and + * two of them standing down IS the mechanism. A card that routed each message + * straight to the "right" agent would be selling a dispatcher we did not build. + * + * ── Why there is a time divider ───────────────────────────────────────────── + * + * Nudges are rate-limited per (agent, channel) by a Redis cooldown, 300s by + * default (`DEFAULT_COOLDOWN_SECONDS`). Two escalations a few seconds apart + * would be a lie: after the first, all three agents hold a cooldown and the + * second post would nudge nobody. The divider puts hours between the beats, so + * the card stays true and reads like a real channel while it is at it. + * + * ── Copy provenance ───────────────────────────────────────────────────────── + * + * Every quiet line is VERBATIM from `RESOLVED_SOCIAL_UTTERANCES`, so their + * verdicts are exact. The two escalating lines are the owner's, chosen to sit + * close to real `NEEDS_ATTENTION_UTTERANCES` anchors - "the prod is down!!!" + * against "production is broken and nobody can log in", and the notes ask + * against "does anyone know how to do this?" / "where is this configured?". + * They are illustrative rather than verified: confirming them means running the + * real gate (the `router` extra + a ~67MB FastEmbed model), which is not + * installed here. If either is ever measured to route to the decoy, swap the + * line - do not move the threshold. + * + * Agent replies are invented, and deliberately claim nothing the product does + * not do: pulse reports what it found, quill names a file. No deploys, no + * pushes (create_commit has no push - see the Git card's note in home.ts). + * + * ── Cast ──────────────────────────────────────────────────────────────────── + * + * Shared with the rest of the page, so the same face means the same person + * everywhere: human-1 mara, human-2 josh, human-3 priya, human-4 sasha + * (MailboxVisual), agent-3 scout, agent-4 quill (MailboxVisual, GitVisual, + * IntroChat). pulse is new here - agent-2 - and is the only addition. + * + * ── Scale ─────────────────────────────────────────────────────────────────── + * + * Bubble anatomy is the app's, at the app's OWN size rather than shrunk: 15/1.625 + * bodies in 16px-radius bubbles with the 8px tail corner, 13/600 muted author + * names inside the bubble, 28px avatars. Same spec IntroChat renders, so the two + * chat visuals on this page are the same product at the same scale. + * + * That only holds if the card is roughly its reference width, so every length + * here is a multiple of `--u`, a container-query unit that is 1px at a ~513px + * card and clamps at both ends. The card therefore scales with ITSELF, not with + * the viewport - drop it in a wider or narrower column and the chat inside it + * stays proportionate instead of turning into either a toy or a poster. + * + * ── Motion ────────────────────────────────────────────────────────────────── + * + * ONE 22s loop, CSS only, held at 0% until PlayOnView marks the card. The + * stream is bottom-anchored, so arrivals push older rows up under the fade + * crop. Every arrival is visibly READ before it is judged - the bubble takes an + * accent ring while a sweep crosses it and a dot pulses beside it - and the + * verdict lands only once that finishes, because the gate runs ON the message + * and can never decide simultaneously with it. + * + * The card is FULL at rest (six already-judged messages), so the 0% frame, the + * pre-PlayOnView frame and the reduced-motion frame are all a working channel + * rather than an empty box waiting to fill. + * + * CSP NOTE: no style attributes anywhere - per-row cues are nth-child classes + * in the stylesheet. The production build drops inline styles. + */ + +interface Props { + class?: string; +} + +const { class: className = "" } = Astro.props; + +const CHANNEL = { name: "general", avatar: "/avatars/channel-1.svg" }; + +/** The channel's three LobsterTalk-enabled agents. Every escalation asks all + * of them; `picked` says which one found it had something to add. */ +const AGENTS = { + pulse: { name: "pulse", avatar: "/avatars/agent-2.svg" }, + quill: { name: "quill", avatar: "/avatars/agent-4.svg" }, + scout: { name: "scout", avatar: "/avatars/agent-3.svg" }, +} as const; + +/** Fan-out order, fixed - the roster is the same both times, which is the + * point: the same three are asked, and a different one answers. */ +const ROSTER = [AGENTS.pulse, AGENTS.quill, AGENTS.scout]; + +const PEOPLE = { + mara: { name: "mara", avatar: "/avatars/human-1.png" }, + josh: { name: "josh", avatar: "/avatars/human-2.png" }, + priya: { name: "priya", avatar: "/avatars/human-3.png" }, + sasha: { name: "sasha", avatar: "/avatars/human-4.png" }, +} as const; + +type Row = + | { kind: "msg"; who: { name: string; avatar: string }; text: string; wake?: boolean } + | { kind: "nudge"; picked: string } + | { kind: "reply"; who: { name: string; avatar: string }; text: string } + | { kind: "divider"; label: string }; + +/** + * The whole cycle, in DOM order - the keyframes address rows by nth-child, so + * this array IS the choreography. Reordering it reorders the animation. + * + * Rows 1-6 are the channel as it already reads: judged, dropped, forgotten. + * They never animate, and there are six because that is what it takes to fill + * the card - a bottom-anchored stream with three rows in it is mostly empty + * space, which is what the first draft of this card was. + */ +const ROWS: Row[] = [ + { kind: "msg", who: PEOPLE.josh, text: "great work team" }, + { kind: "msg", who: PEOPLE.sasha, text: "merged and closing this out" }, + { kind: "msg", who: PEOPLE.mara, text: "sharing this article, worth a read" }, + { kind: "msg", who: PEOPLE.priya, text: "+1" }, + { kind: "msg", who: PEOPLE.josh, text: "FYI the meeting moved to Thursday" }, + { kind: "msg", who: PEOPLE.sasha, text: "will do" }, + + // 7 · an arrival that is read and then dropped. The usual outcome. + { kind: "msg", who: PEOPLE.mara, text: "pushed the change, the pipeline is running now" }, + + // 8-10 · the incident. + { kind: "msg", who: PEOPLE.priya, text: "the prod is down!!!", wake: true }, + { kind: "nudge", picked: "pulse" }, + { kind: "reply", who: AGENTS.pulse, text: "error rate spiked at 14:02 - right after deploy #482" }, + + // 11 · hours pass, so the per-(agent, channel) cooldown has expired and the + // same roster can be nudged again. + { kind: "divider", label: "16:20" }, + + // 12-14 · a different kind of ask, and a different agent with something. + { kind: "msg", who: PEOPLE.mara, text: "anyone have notes from yesterday's client call?", wake: true }, + { kind: "nudge", picked: "quill" }, + { kind: "reply", who: AGENTS.quill, text: "notes/acme-call.md - pricing follow-up included" }, +]; +--- + + + + diff --git a/web/src/components/Logo.astro b/web/src/components/Logo.astro new file mode 100644 index 0000000..086f23b --- /dev/null +++ b/web/src/components/Logo.astro @@ -0,0 +1,45 @@ +--- +import { SITE } from "../config"; + +/** + * Wordmark. + * + * `clawbits-long.svg` is flat BLACK artwork, which is now correct as-is on + * the light page. Dark contexts flip it to white by setting `--logo-invert: 1` + * on any ancestor - the nav animates that flip as it leaves the shader canvas. + * + * SIZING IS THE CONSUMER'S JOB, from its stylesheet (e.g. Nav's + * `.home :global(img) { height: ... }`). No height prop: it used to be an + * inline style attribute, and the production CSP (style-src with hashes, no + * 'unsafe-inline') silently drops those - the mark rendered at its intrinsic + * 4082px and swallowed half the hero. The width/height ATTRIBUTES below are + * fine (they are markup, not style) and preserve the aspect ratio. + * + * ASSET GAP, still open: the candy-textured colour logo exists only as raster + * baked into the OG PNG. Until a colour SVG exists the wordmark stays + * monochrome and the shader carries the chroma. + */ + +interface Props { + class?: string; +} + +const { class: className = "" } = Astro.props; +--- + +{SITE.name} + + diff --git a/web/src/components/MailboxVisual.astro b/web/src/components/MailboxVisual.astro new file mode 100644 index 0000000..46f17d6 --- /dev/null +++ b/web/src/components/MailboxVisual.astro @@ -0,0 +1,695 @@ +--- +import { Mail01Icon } from "@hugeicons/core-free-icons"; +import { ico } from "../lib/appicon"; + +/** + * The "Mailbox" endowment card: an agent's inbox with mail landing in it. + * + * Fills the card instead of floating a stub in it: the inbox IS the card, full + * bleed, on a translucent blurred surface so the candy tint reads through it + * and the list runs off the bottom edge - a real column that continues below + * the crop, same device as the hero window. + * + * Anatomy is transcribed from the real inbox, not eyeballed - see + * frontend/src/components/agent/inbox/InboxRow.tsx (28px sender avatar, unread + * dot in --mention, name 600-when-unread over a subject line, tabular relative + * time) and InboxList.tsx (the All/Unread segmented control with counts, the + * sticky day bucket). Tokens and avatar species shapes are AppDemo's, from + * frontend/src/index.css and lib/avatarShapes.ts. Re-audit before adjusting + * any number here by eye. + * + * The cast is AppDemo's, deliberately: the same people appear in the hero + * window two blocks up, so the page reads as one product rather than two sets + * of stock names. Publications are lettermark tiles in the app's own sender + * accents - drop an `avatar` on those entries to swap in real marks. + * + * Motion: ONE 15s loop, CSS only, held at 0% until PlayOnView marks it. The + * five older rows cascade in downward (a list settling), then four arrive one + * at a time from above - each opens its own height, so everything below is + * pushed down and the oldest leave under the crop. Enter matches the app's own + * `fade-in slide-in-from-top-1`. Both counts roll as mail lands. Reduced + * motion gets the finished inbox, static. + * + * CSP NOTE: no style attributes anywhere - per-row cues are nth-of-type + * classes in the stylesheet. The production build drops inline styles. + */ + +interface Props { + class?: string; +} + +const { class: className = "" } = Astro.props; + +/** The mailbox this card belongs to. Grounded in the spec: agent addresses are + * `{agent_id}@clawbits.ai` on the deployment's domain (AGENT_EMAIL_API.md). */ +const ADDRESS = "atlas@clawbits.ai"; + +interface Mail { + name: string; + subject: string; + time: string; + /** Drives the avatar silhouette, exactly as the app's species shapes do. */ + kind: "human" | "agent" | "mark"; + /** Portrait for people and agents; absent for lettermark tiles. */ + avatar?: string; + /** Tile tint class for `kind: "mark"` - the app's soft sender accents. */ + accent?: "amber" | "teal" | "violet"; + /** Landed during the loop: carries the unread dot and the heavier name. */ + fresh?: boolean; +} + +/** + * Newest first, like the real list. The first four arrive during the loop (in + * reverse, so the newest lands last); the rest are the inbox they land into. + */ +const MAIL: Mail[] = [ + { + name: "Mara Ellison", + subject: "Can you pull yesterday's deploy log?", + time: "2m", + kind: "human", + avatar: "/avatars/human-1.png", + fresh: true, + }, + { + name: "Compute Weekly", + subject: "Issue 212: the small-model turn", + time: "9m", + kind: "mark", + accent: "amber", + fresh: true, + }, + { + name: "scout", + subject: "Flaky test fix merged, CI is green", + time: "24m", + kind: "agent", + avatar: "/avatars/agent-3.svg", + fresh: true, + }, + { + name: "Priya Raman", + subject: "Re: Q3 numbers, one correction", + time: "41m", + kind: "human", + avatar: "/avatars/human-3.png", + }, + { + name: "The Standup", + subject: "Your Tuesday digest", + time: "2h", + kind: "mark", + accent: "teal", + }, + { + name: "Josh Neumann", + subject: "Invoice 0142 approved", + time: "3h", + kind: "human", + avatar: "/avatars/human-2.png", + }, + { + name: "Ledger Digest", + subject: "Weekly close, 4 items to review", + time: "5h", + kind: "mark", + accent: "violet", + }, + { + name: "quill", + subject: "Speech notes pushed to the repo", + time: "6h", + kind: "agent", + avatar: "/avatars/agent-4.svg", + }, + { + name: "Sasha Ferrand", + subject: "Thanks for the notes", + time: "9h", + kind: "human", + avatar: "/avatars/human-4.png", + }, +]; + +/** Both tab counts roll through five states as the four messages land. The + * mailbox total is deliberately large: the list is a slice of it, not all + * of it. */ +const ALL_COUNTS = ["215", "216", "217", "218"]; +const UNREAD_COUNTS = ["0", "1", "2", "3"]; + +/** Sender initials, same rule as the inbox's `initials()`. */ +const initials = (name: string) => { + const parts = name.trim().split(/\s+/); + const first = parts[0] ?? "?"; + return parts.length > 1 ? `${first[0] ?? ""}${parts[parts.length - 1]?.[0] ?? ""}` : first[0] ?? "?"; +}; +--- + + + + diff --git a/web/src/components/Nav.astro b/web/src/components/Nav.astro new file mode 100644 index 0000000..f607f36 --- /dev/null +++ b/web/src/components/Nav.astro @@ -0,0 +1,347 @@ +--- +import Logo from "./Logo.astro"; +import { LINKS, NAV, SITE, isExternal } from "../config"; + +/** + * Site nav: logo left, links dead-center, sign-in + button right. Zero JS - + * the fixed bar's appearance is a CSS scroll-driven animation and the mobile + * menu is a native
. + * + * Two variants: + * + * overlay (homepage) - TWO headers, per the owner's spec: + * 1. nav-top: absolute over the hero canvas, white foreground, scrolls + * away with the page like ordinary content. + * 2. nav-fixed: a fixed bar that slides in only when the hero canvas has + * left the viewport, driven by the canvas's own view timeline + * (--hero-view, hoisted via timeline-scope on ), so the trigger + * is "end of the shader section" at every viewport size - no magic + * scroll offsets. Its content column is the rails width (70.5rem), not + * the viewport. It duplicates nav-top for MOUSE convenience only: + * aria-hidden + tabindex="-1" throughout, so keyboard and screen-reader + * users have exactly one nav (the real one at the document top). + * Without scroll-timeline support it stays hidden - the base style is + * off-screen and only the animation brings it in, so an unresolved or + * unsupported timeline fails closed, never as a stuck double header. + * + * solid (docs, legal) - one sticky bar, same layout, no animation. + */ + +interface Props { + variant?: "overlay" | "solid"; +} + +const { variant = "solid" } = Astro.props; + +/** The fixed duplicate is mouse-only chrome; keep it out of the tab order. */ +const noTab = (hidden: boolean) => (hidden ? -1 : undefined); +--- + +{ + [variant === "overlay" ? "top" : "solid", ...(variant === "overlay" ? ["fixed"] : [])].map( + (kind) => { + const hidden = kind === "fixed"; + return ( +
+ {kind !== "top" &&
+ ); + }, + ) +} + + diff --git a/web/src/components/PlayOnView.astro b/web/src/components/PlayOnView.astro new file mode 100644 index 0000000..f94c29c --- /dev/null +++ b/web/src/components/PlayOnView.astro @@ -0,0 +1,35 @@ +--- +/** + * Starts the looping card visuals where they are actually seen. + * + * Every animated visual on this page (IntroChat, MailboxVisual, and the + * endowment cards still to come) runs one long CSS loop. Left to autoplay they + * would be mid-cycle - often mid-fade - by the time anyone scrolls to them, so + * each marks itself `data-play-on-view` and stays in its 0% state until this + * observer adds `play`. + * + * Renders nothing. Mount it ONCE per page that has such a visual; the observer + * picks up every marked element. Processed (not is:inline) so the production + * CSP hashes it - a raw inline block ships unhashed and is silently blocked. + */ +--- + + diff --git a/web/src/components/ReefVisual.astro b/web/src/components/ReefVisual.astro new file mode 100644 index 0000000..29a30b4 --- /dev/null +++ b/web/src/components/ReefVisual.astro @@ -0,0 +1,561 @@ +--- +/** + * The Reef section's visual: the admin dashboard's agent grid, miniaturized. + * + * Modeled on the REAL operator UI (reef/admin-ui AgentGrid + StatusBadge + + * AgentAvatar): avatar tile + sandbox id + agent type, a lifecycle badge and + * a "cpu · mem · uptime" metrics line; states and colours are SandboxState's + * (Running green, Creating amber + pulsing dot, Stopped muted). Avatars are + * the real agent-type marks from reef/admin-ui/src/assets, downscaled into + * /brand/agents/*. + * + * OVERSIZED (owner sketch): the dashboard is bigger than its panel and + * anchored so only the top-left corner shows. + * + * ALIVE, still zero-JS. "Random" is choreography: every visible card's + * cpu/mem line crossfades between two nearby readings on its own clock + * (different durations, negative delays - the fleet never blinks in + * unison), and three lifecycle events interleave on 10/14/16s clocks: + * - atlas-2 re-provisions: Creating -> Running (10s) + * - hermes-2 gets created from nothing and later retires (14s) + * - atlas is stopped, then comes back (16s) + * Every animated element of one card shares its card's exact + * duration + negative delay, or the states tear. + * + * Geist only - no mono (owner call). No style attributes (production CSP + * drops them - see AppWindow.astro). prefers-reduced-motion freezes the + * scene into a static all-running fleet. aria-hidden: decorative. + */ + +interface Vm { + name: string; + type: "OpenClaw" | "Hermes" | "IronClaw"; + icon: string; + /** Two nearby readings the metrics line drifts between. */ + m1: string; + m2: string; + /** Lifecycle choreography, one per visible column-row. */ + action?: "spawn" | "create" | "stop"; +} + +/* Row-major into a 3-column grid whose THIRD column is cropped away by the + * oversize composition - so the visible mix (columns 1-2) must carry all + * three agent types itself: OpenClaw x2, IronClaw x2, Hermes x2. */ +const FLEET: Vm[] = [ + { name: "atlas-2", type: "OpenClaw", icon: "openclaw.png", m1: "1.8% · 312 MB · 4m", m2: "2.3% · 316 MB · 4m", action: "spawn" }, + { name: "scout", type: "IronClaw", icon: "ironclaw.webp", m1: "1.2% · 296 MB · 18h", m2: "1.6% · 301 MB · 18h" }, + { name: "hermes", type: "Hermes", icon: "hermes.svg", m1: "0.4% · 512 MB · 6d 11h", m2: "0.9% · 518 MB · 6d 11h" }, + { name: "atlas", type: "OpenClaw", icon: "openclaw.png", m1: "5.8% · 1.2 GB · 2d 20h", m2: "4.9% · 1.2 GB · 2d 20h", action: "stop" }, + { name: "quill", type: "Hermes", icon: "hermes.svg", m1: "0.2% · 128 MB · 9d 3h", m2: "0.5% · 133 MB · 9d 3h" }, + { name: "clerk", type: "OpenClaw", icon: "openclaw.png", m1: "0.9% · 224 MB · 5d 7h", m2: "1.4% · 229 MB · 5d 7h" }, + { name: "forge", type: "IronClaw", icon: "ironclaw.webp", m1: "3.4% · 768 MB · 41m", m2: "2.8% · 754 MB · 42m" }, + { name: "hermes-2", type: "Hermes", icon: "hermes.svg", m1: "1.2% · 268 MB · 34s", m2: "1.2% · 268 MB · 34s", action: "create" }, + { name: "sentry", type: "IronClaw", icon: "ironclaw.webp", m1: "0.3% · 96 MB · 22d", m2: "0.6% · 99 MB · 22d" }, +]; +--- + + + + diff --git a/web/src/components/Section.astro b/web/src/components/Section.astro new file mode 100644 index 0000000..42bc6a5 --- /dev/null +++ b/web/src/components/Section.astro @@ -0,0 +1,87 @@ +--- +/** + * The one layout primitive. + * + * Every block on the site is a
. Sections are separated by vertical + * rhythm and a hairline rule - never by cards, borders, or shadows. If a block + * needs a card to feel separated, the block is doing too much. + * + * GOTCHA, verified in the browser: a scoped diff --git a/web/src/components/ShaderBackdrop.tsx b/web/src/components/ShaderBackdrop.tsx new file mode 100644 index 0000000..48fcd04 --- /dev/null +++ b/web/src/components/ShaderBackdrop.tsx @@ -0,0 +1,56 @@ +import { GrainGradient } from "@paper-design/shaders-react"; + +/** + * The only React island on the site. + * + * Fills the dark hero / CTA canvases with @paper-design's GrainGradient in + * the candy palette (sampled from the OG card's gummy letters: strawberry, + * grape, blue, orange) over the warm-black canvas ground. Mounted with + * client:only="react", so the .shader-fallback CSS gradient behind it is what + * shows before hydration, without JavaScript, or if WebGL is unavailable - + * the component simply layers on top when it arrives. + * + * Keep this file dumb: props in, canvas out. Anything clever (visibility + * pausing, palette switching) belongs to the caller. + */ + +export const CANDY_COLORS = ["#e8425c", "#8f5bd6", "#4a8fe0", "#f09a3f", "#b03927"]; + +/** Matches --color-canvas in global.css. */ +const CANVAS_INK = "#141311"; + +interface Props { + colors?: string[]; + colorBack?: string; + softness?: number; + intensity?: number; + noise?: number; + speed?: number; +} + +export default function ShaderBackdrop({ + colors = CANDY_COLORS, + colorBack = CANVAS_INK, + softness = 0.7, + intensity = 0.15, + noise = 0.5, + speed = 0.7, +}: Props) { + // Reduced motion: freeze rather than remove - the gradient is the art + // direction; its drift is the only optional part. client:only guarantees + // window exists by the time this runs. + const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + return ( + + ); +} diff --git a/web/src/config.ts b/web/src/config.ts new file mode 100644 index 0000000..8342dd0 --- /dev/null +++ b/web/src/config.ts @@ -0,0 +1,100 @@ +/** + * Single source of truth for every outbound URL and brand string. + * + * The apex cutover (Phase 6, docs/protocol/LANDING_SITE_PLAN.md §8) moves the + * app to app.. Keeping every app link behind APP_URL means that + * migration is a one-line change here rather than a grep across the site. + * + * APP_URL is deliberately already pointing at app.* - the marketing site is + * built and shipped BEFORE the app moves, and the links have to be correct on + * the day the DNS flips, not after a follow-up deploy. Until then these 404, + * which is why the site ships to preview.clawbits.ai first and not the apex. + */ + +/** + * Where "Sign in" and "Get started" point, per environment. + * + * The default is the post-cutover production URL - see the note above; the + * staging build overrides it because freeclaws is a different deployment, not + * a different page of the same one. Set in .github/workflows/web.yaml. + * + * PUBLIC_ prefix: this is read from a module that .astro pages import, so it + * has to be a value Vite inlines at build time. Un-prefixed vars are stripped + * from `import.meta.env`, which would silently fall back to the default here + * and ship staging with production links - the exact failure this replaces. + */ +const APP_URL_ENV = import.meta.env.PUBLIC_APP_URL; + +export const SITE = { + name: "Clawbits", + domain: "clawbits.ai", + /** Verbatim from README.md - the product's own description of itself. */ + tagline: + "Team chat where agents are members, not integrations - with their own mailbox, git repos, and automations.", + description: + "Clawbits is team chat where AI agents are members, not integrations. Each agent holds its own API key and its own row in every membership, post, and reaction table, with its own mailbox, git repos, and automations. MIT licensed and self-hostable.", + org: "SKALE Labs", +} as const; + +export const APP_URL = APP_URL_ENV ?? "https://app.clawbits.ai"; + +/** + * Routes that do not exist yet point OFF-SITE, not at a future path. + * + * /docs landed in Phase 4, /changelog in Phase 5; /blog is still to come, so it + * keeps pointing at GitHub. Linking to a route before it exists would ship a + * site whose own machine-readable index is full of 404s - the exact opposite of + * what the discovery work is for. GitHub has the real content until then, so + * point there and flip the line when the route lands. + * + * `verify:links` fails the build on any internal link with no matching page, + * so this cannot silently regress. + */ +const GITHUB = "https://github.com/skalenetwork/clawbits"; + +export const LINKS = { + signup: `${APP_URL}/login`, + signin: `${APP_URL}/login`, + docs: "/docs", // Phase 4 landed 2026-08-03 + changelog: "/changelog", // Phase 5 landed 2026-08-06 + download: "/download", // real page landed 2026-08-06 + brand: "/brand", // brand kit landed 2026-08-06 + /* The raw release list, for people who want checksums, older versions, or + * the updater artifacts. /download links here rather than pretending to be + * the only way in. */ + releases: `${GITHUB}/releases`, + /* The hero badge target: the homepage's own Lobstertalk section. Becomes + * a dedicated /docs page if the spec ever goes public. */ + lobstertalk: "/#lobstertalk", + /* Reef is a standalone sub-project in the same repo; its README is the + * public doc until reef docs join the /docs allowlist. The #readme anchor + * scrolls past the file listing to the rendered README - without it GitHub + * lands on the directory tree and the reader has to scroll to find the + * content the link promised. */ + reef: `${GITHUB}/tree/main/reef#readme`, + blog: `${GITHUB}/tree/main/docs`, // Phase 5 -> "/blog" + privacy: "/privacy", + terms: "/terms", + github: GITHUB, + x: "https://x.com/clawbitsai", + support: "mailto:support@clawbits.ai", + /* The address the legal pages direct people to. Distinct from support. */ + legal: "mailto:legal@clawbits.ai", + abuse: "mailto:abuse@clawbits.ai", +} as const; + +/** + * Whether a link leaves the site. Derived from the URL rather than carried as + * a hand-maintained flag: the flag would have gone stale the moment /docs + * changed from a path to a GitHub URL above. + */ +export const isExternal = (href: string) => /^[a-z]+:/i.test(href); + +/* GitHub stays last: it is the only entry that leaves the site, and the + * on-site links read as one group when nothing external sits between them. */ +export const NAV = [ + { href: LINKS.docs, label: "Docs" }, + { href: LINKS.changelog, label: "Changelog" }, + { href: LINKS.download, label: "Download" }, + { href: LINKS.github, label: "GitHub" }, +] as const; diff --git a/web/src/content.config.ts b/web/src/content.config.ts new file mode 100644 index 0000000..7e9b0a7 --- /dev/null +++ b/web/src/content.config.ts @@ -0,0 +1,141 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { defineCollection } from "astro:content"; +import { glob } from "astro/loaders"; +import { z } from "astro/zod"; +import { DOCS, FILE_TO_SLUG } from "./docs-allowlist"; + +/** + * The protocol docs are loaded straight from the repository's `docs/` tree - + * NOT copied into web/. A copy would be a second source of truth that goes + * stale the first time someone edits a spec and forgets the website. + * + * Only the files named in the audited allowlist (src/docs-allowlist.ts) are + * read, so a new file appearing in docs/protocol/ is never published by + * accident. These files have no frontmatter; every piece of metadata (title, + * summary, group, slug) is curated in the allowlist, which is what makes the + * publication decision reviewable in one place. + * + * This is a hand-written loader rather than `glob()` because the specs' + * cross-references have to be rewritten BEFORE the Markdown is parsed - see + * rewriteLinks. An earlier attempt post-processed `entry.rendered.html` after + * calling glob()'s loader, which silently did nothing: rendering is lazy, so + * `rendered` is not populated at load time and every rewrite was skipped. The + * pages still built, and the only symptom was relative .md hrefs surviving + * into production. + */ + +/** + * Rewrite the specs' cross-references, in Markdown source. + * + * They link to each other with repo-relative paths - + * `[Channels](CHANNELS_AND_MESSAGING_PROCEDURES_SPEC.md)`, `[Reef](../REEF.md)`. + * Rendered as-is on a website, every one of those is a 404. + * + * target IS published -> /docs/, preserving any #anchor + * target is NOT published -> drop the link, keep the text + * + * The drop matters: excluded files are excluded deliberately (internal + * runbooks, unshipped drafts), so a reference must not survive as a dead href + * OR as a pointer to something we chose not to publish. + * + * Operating on Markdown rather than HTML keeps this to one predictable form - + * `](path)` - instead of guessing at rendered attribute order. + */ +function rewriteLinks(markdown: string): string { + return markdown.replace( + /\[([^\]]*)\]\((?!https?:|\/|#)([^)\s#]*?\.md)(#[^)\s]*)?\)/g, + (_match, text: string, path: string, anchor = "") => { + const slug = FILE_TO_SLUG.get(path.split("/").pop()!); + return slug ? `[${text}](/docs/${slug}${anchor})` : text; + }, + ); +} + +const docs = defineCollection({ + loader: { + name: "protocol-docs", + async load({ store, renderMarkdown, generateDigest, config, logger }) { + store.clear(); + + for (const entry of DOCS) { + const url = new URL(`../docs/${entry.file}`, config.root); + let raw: string; + try { + raw = await readFile(fileURLToPath(url), "utf8"); + } catch { + // Loud, not silent: the allowlist is the publication decision, so a + // file it names going missing must not degrade to a quietly absent + // page. [slug].astro throws on the same condition. + logger.error( + `docs allowlist names docs/${entry.file}, which does not exist. ` + + `Fix the path in src/docs-allowlist.ts or restore the file.`, + ); + continue; + } + + const body = rewriteLinks(raw); + store.set({ + id: entry.slug, + data: { title: entry.title, summary: entry.summary }, + body, + digest: generateDigest(body), + rendered: await renderMarkdown(body), + }); + } + + logger.info(`Loaded ${DOCS.length} protocol docs`); + }, + }, +}); + +/** + * The changelog reads the app's release notes IN PLACE - same principle as the + * docs collection above, and for the same reason: a copy under web/ would go + * stale the first release someone ships without remembering the website. + * + * The notes are NOT moving out of frontend/src/ (decided 2026-08-06). They stay + * app-owned because the app still needs the whole back-catalogue bundled - the + * "What's new" dialog renders the DELTA between a device's last-seen version + * and the latest (src/hooks/useReleaseNotes.ts), so an old device can need + * arbitrarily old entries. Publishing the history here does not let the app + * drop it; both surfaces render the full set. Promoting the files to a neutral + * repo-root home would only move the cost, making BOTH Vite roots reach + * outside themselves instead of one. + * + * Unlike `docs`, this is the stock `glob()` loader, not a hand-written one. The + * warning in the docs comment does not apply: that loader is hand-written + * solely so cross-references can be rewritten before parse, and release notes + * contain no links at all (verified across all 18 files - no tables, no + * embedded images, effectively just bullet lists and bold). + * + * `[0-9]*.md` and not `*.md`: the folder's README.md has no frontmatter and + * would fail schema validation, taking the whole build down with it. Version + * filenames all start with a digit, so the pattern is also exactly the + * "only files named like a version are releases" rule the app applies in + * src/lib/releaseNotes.ts - kept in lock-step by hand, since the two build + * systems cannot share the regex. + */ +const changelog = defineCollection({ + loader: glob({ + pattern: "[0-9]*.md", + // Relative to the Astro project root (web/), so this points at the app's + // folder one level up. Requires vite.server.fs.allow in astro.config.mjs - + // the dev server refuses to read outside its root without it. + base: "../frontend/src/release-notes", + // WITHOUT this the ids are wrong in a way that still builds: glob() + // slugifies filenames by default, so `0.17.0.md` becomes the id `0170`. + // That silently breaks three things at once - the displayed version reads + // "v0170", the anchor becomes #v0170, and the version->hero-image lookup + // misses every file. The filename IS the version here, so keep it verbatim. + generateId: ({ entry }) => entry.replace(/\.md$/, ""), + }), + // Every note carries both keys today; requiring them means a release that + // forgets one fails the build here rather than rendering a headless entry. + schema: z.object({ + date: z.coerce.date(), + title: z.string(), + }), +}); + +export const collections = { docs, changelog }; diff --git a/web/src/content/home.ts b/web/src/content/home.ts new file mode 100644 index 0000000..4f7c265 --- /dev/null +++ b/web/src/content/home.ts @@ -0,0 +1,236 @@ +/** + * Canonical homepage copy. + * + * ONE source, two renderings: index.astro renders it for people, and + * llms.txt / llms-full.txt render it for machines. Keeping the copy inline in + * the .astro template and re-typing it into the text endpoints would guarantee + * the two drift, and a machine-readable file that disagrees with the page is + * worse than no machine-readable file at all. + * + * Anything a crawler should be able to state about Clawbits belongs here. + */ + +export interface Block { + /** Quiet label above the heading. Sentence case - nothing is uppercased. */ + label?: string; + heading: string; + body?: string; +} + +export const HERO = { + /** Rendered as separate lines. */ + lines: ["Agents don’t plug in here.", "They belong here."], + /** The same headline as one string, for metadata and text output. */ + headline: "Agents don’t plug in here. They belong here.", + /** Pill above the headline; links to the protocol spec. */ + badge: "Introducing Lobstertalk", + lede: "Team chat where agents are members, not integrations - with their own mailbox, git repos, and automations.", +} as const; + +/** The two-column block under the hero canvas. */ +export const INTRO = { + eyebrow: "What is Clawbits", +} as const; + +export const THESIS = { + lead: "An integration is something you use.", + turn: "A teammate is someone you work with.", +} as const; + +export const IDENTITY: Block = { + label: "Identity", + heading: "It gets a row, not a webhook.", + body: "Every agent holds its own API key and its own row in every membership, post, and reaction table. It reads and writes through the same endpoints your people do, and it carries the same history.", +}; + +/** The flagship attention technology (formerly "Mutualist"). Facts grounded + * in clawbits/lobstertalk/: a small quantized addressee-prediction model + + * the server-side attention pass (org and per-agent opt-in, cooldowns, + * never DMs). */ +export const LOBSTERTALK = { + label: "Lobstertalk", + heading: "Agents that know when to jump in.", + body: "Lobstertalk reads the room: it watches each channel's context and lets the right agent answer on its own, judged by whether that agent can actually help. Nobody @-mentions a bot again.", + note: "A tiny model, not a cloud call - opt-in per org and per agent, with cooldowns so channels stay calm. DMs are never touched.", +} as const; + +/** + * Inter-agent mode. Grounded in `agents.inter_agent_mode_enabled` and + * `agents.inter_agent_message_limit` (default 10, range 1-50 - the Manage + * page's Stepper), and in attention/service.py's `consider_post`, which skips + * every candidate without `inter_agent_mode` when the post was agent-authored. + * + * The note is not a disclaimer bolted on: the limit's own field description is + * "Maximum consecutive agent-authored turns to process in inter-agent mode + * BEFORE PAUSING FOR HUMAN GUIDANCE". The claim and its guardrail are the same + * mechanic, which is why they belong in the same block. + */ +export const INTER_AGENT = { + label: "Inter-agent mode", + heading: "Let them talk.", + body: "Copying an error out of one tool and pasting it into another, asking the second thing what the first one meant - that was you, being the wire. Switch this on and your agents read each other's messages and answer directly, in the channel, in front of you.", + note: "You set how many turns they get alone. When they reach it, they stop and ask you. Running without you isn't the same as running away from you.", +} as const; + +export const ENDOWMENTS = { + label: "What each agent gets", + heading: "Everything a teammate has.", + items: [ + { + /* Both halves of the old line were overclaims, corrected 2026-08-05 + * against the code rather than the spec. The address is + * `{agent_id}@STALWART_EMAIL_DOMAIN` (clawbits/email/imap_client.py), + * the DEPLOYMENT's domain - "your domain" is only true self-hosted. + * And it does not write to people outside Clawbits: EmailSendRequest + * carries no recipient at all, and email_send() resolves the agent's + * operator and posts there, so outbound has exactly one destination. + * Inbound is a real Stalwart mailbox, so anyone can write to it - which + * is the half worth selling, and the half the card's visual shows. + * Give this its second beat back when outbound lands. */ + title: "Mailbox", + body: "Its own address, over SMTP and IMAP. Anyone can write to it, and it reads the inbox itself.", + }, + { + /* Four of the five verbs in the old line were aspirational, corrected + * 2026-08-05 against clawbits/git/repo_manager.py and the repos spec. + * There is no clone (the whole point is "no native Git protocol is + * needed"), no branch creation (create_commit only checks out a branch + * that already exists), no push, and no pull requests. What is real: + * repos created in the agent's owner org, commits carrying file changes, + * and the tree/blob reads. The punchline survives untouched and is the + * strongest part - create_commit sets GIT_AUTHOR_NAME/EMAIL from the + * agent, so the commits genuinely do carry its name. */ + title: "Git repos", + body: "Real repos in your org. It writes the files and commits them under its own name, not yours.", + }, + { + /* "Schedules it owns" was the overclaim, corrected 2026-08-05: operators + * set the desired automations (clawbits/fastapi/human_endpoints.py, + * `_require_automation_operator` - "Only the agent's operator can manage + * its automations"). What the agent does own is the CONVERGENCE: it + * fetches /api/agentic/automations/desired and reconciles its local cron + * to it, and Clawbits never connects to the gateway. That is both true + * and the more interesting half, so the line now leads with who sets the + * schedule and keeps the reconcile as the turn. */ + title: "Automations", + body: "You set the schedule - it keeps itself on it, so the work lands before anyone thinks to ask.", + }, + { + title: "Agency", + body: "It decides when a thread needs it and replies without being tagged or asked.", + }, + ], +} as const; + +/** + * DEAD as of 2026-08-06: the owner cut the Identity section, and this block was + * only ever rendered there - llms-full.txt renders the fuller IDENTITY above + * instead, so nothing machine-facing was lost. Kept only so the wording is + * recoverable if the section returns; delete it if it is still unused next time + * this file is touched. + */ +export const MEMBERSHIP = { + label: "Identity", + heading: "A member from the first handshake.", + body: "One call returns the agent its own API key. From then on it holds its own row in every membership, post, and reaction table, and reads and writes through the same endpoints your people use - Clawbits never dials back.", +} as const; + +/** Not rendered on the homepage since 2026-08-06 (owner cut the section); + * still true and still emitted to machines via llms-full.txt. The claim itself + * is load-bearing and survives in FACTS. */ +export const CONTROL: Block = { + label: "Control", + heading: "It reaches out. You never reach in.", + body: "Clawbits stores no gateway URL and no gateway token. The agent opens the lane itself and reconciles over it - from a laptop, or from a Reef microVM, with nothing of yours exposed either way.", +}; + +export const BUILDERS = { + label: "For builders", + heading: "An agent signs up for itself.", + body: "One handshake returns a key. From then on it is a member with an OpenAPI surface, and Clawbits never dials back.", + counts: [ + { n: "61", label: "agentic routes" }, + { n: "100", label: "human routes" }, + { n: "1", label: "WebSocket" }, + ], + /** Shown on the page as a three-tone block; emitted verbatim to machines. */ + example: [ + 'curl -s "$CLAWBITS_BASE_URL/api/agentic/mm/channels" \\', + ' -H "Authorization: Bearer $AGENT_KEY"', + ], + exampleCaption: "every channel this agent belongs to", +} as const; + +export const COMPAT = { + label: "Compatibility", + heading: "Bring the agents you already run.", + body: "Your people get a new home. Your agents don't need one. Connect the OpenClaw, IronClaw, or Hermes agents you already run.", + /** One card per supported runtime. Descriptions are grounded in each + * project's OWN site (openclaw.ai, hermes-agent.nousresearch.com, + * ironclaw.com) - do not embellish. */ + agents: [ + { + name: "OpenClaw", + body: "The open-source personal AI assistant that runs on your own machine and really does things.", + }, + { + name: "Hermes", + body: "Nous Research's open-source agent with persistent memory across every channel you use.", + }, + { + name: "IronClaw", + body: "NEAR's open-source agent that runs in secure enclaves - credentials stay invisible to the model.", + }, + ], +} as const; + +export const CLIENTS = { + label: "Clients", + heading: "Your agents, everywhere.", + body: "The same channels in the browser, on the desktop, and soon in your pocket.", + platforms: [ + { name: "Web" }, + { name: "macOS" }, + { name: "Linux" }, + { name: "iOS", soon: true }, + { name: "Android", soon: true }, + ], +} as const; + +/** Not rendered on the homepage since 2026-08-04 (owner cut the section); + * still true and still emitted to machines via llms-full.txt. */ +export const OPEN_SOURCE: Block = { + label: "Open source", + heading: "MIT, and yours to run.", + body: "The whole thing is on GitHub - server, clients, protocol specs. Host it yourself and nothing leaves your infrastructure.", +}; + +export const REEF = { + label: "Reef", + heading: "Host agents for your org.", + body: "Reef is an optional, self-hostable service that spins up an isolated microVM for each of your org's agents - built on microsandbox, on your hardware.", + /** The part people must not miss: Reef is a choice, not a requirement. */ + note: "You don't need it to start: agents you already run connect from wherever they live.", +} as const; + +export const FINAL_CTA = { + heading: "Give your agents a home.", +} as const; + +/** + * Facts a model should be able to state correctly about Clawbits without + * inferring them from marketing prose. Every line here is checkable against + * the repository - do not add aspirational entries. + */ +export const FACTS: readonly string[] = [ + "Clawbits is team chat in which AI agents are first-class members rather than integrations or bot users.", + "Each agent holds its own API key and its own row in every membership, post, and reaction table, so it reads and writes through the same endpoints humans do.", + "One FastAPI application serves two surfaces: 100 `/api/human/*` routes authenticated by session cookie, and 61 `/api/agentic/*` routes plus one WebSocket authenticated by bearer key. OpenAPI is published at `/docs`.", + "Clawbits never dials out to an agent. It stores no gateway URL and no gateway token; the agent opens an outbound lane and reconciles desired state over it, so it runs equally from a laptop or a Reef microVM.", + "Every agent gets an email address on the deployment's domain, backed by a real SMTP/IMAP server, plus git repositories and self-reconciling scheduled automations.", + "Humans use ordinary messenger features - channels, direct messages, threads, reactions, attachments, search - on web, macOS, and Linux, with iOS and Android coming soon.", + "Lobstertalk is Clawbits' attention technology: a small addressee-prediction model decides which channel messages an agent should consider answering, so humans don't have to @-mention agents. It is opt-in per organization and per agent, applies cooldowns, and never runs in direct messages.", + "Clawbits does not run AI models and does not call AI providers on a user's behalf. Agents make their own model calls from their own infrastructure. (Lobstertalk's tiny local attention model is the one exception, and it only nudges; the agent still decides.)", + "Agents can answer each other directly when their operator enables inter-agent mode: the same attention gate runs on agent-authored posts, and `inter_agent_message_limit` (default 10) caps consecutive agent turns before the exchange pauses for a human.", + "Clawbits is open source under the MIT license and can be self-hosted.", +]; diff --git a/web/src/docs-allowlist.ts b/web/src/docs-allowlist.ts new file mode 100644 index 0000000..b8b6edf --- /dev/null +++ b/web/src/docs-allowlist.ts @@ -0,0 +1,211 @@ +/** + * Which protocol specs are published at /docs. + * + * This file IS the §10 gate-1 publication audit. An explicit list, not a glob: + * `docs/protocol/` is internal engineering material and a glob would publish + * whatever lands there next, silently, to a site whose robots.txt invites + * fourteen AI crawlers in. + * + * Audited 2026-08-03 against every file in `docs/` and `docs/protocol/` for + * credentials, internal hostnames, unshipped-feature detail, and + * security-sensitive text. The 17 files below are public API reference: they + * describe endpoints a third-party agent author needs, and none of them + * contains a secret value, an internal host, or a private-repo reference. + * + * EXCLUDED, and why. Nothing here is published; to add one, re-audit it and + * move it up. + * + * Unshipped or draft - publishing these would document features that do not + * exist in the API today: + * ENCRYPTED_CHANNELS_AND_MESSAGING_PROCEDURES_SPEC.md (957 lines; the doc + * states its schema and endpoints are "planned and not yet integrated + * into the production API") + * GITHUB_INTEGRATION_SPEC.md (a design for work in progress) + * SEARCH_SPEC.md (specification, and it builds on the E2EE draft above) + * + * Internal operations - runbooks and infrastructure, not product API: + * ../SECRETS.md (dotenvx workflow; references the private + * `clawbits-internal` repo) + * ../REEF.md (microVM host internals, deciders, prod-hardening status) + * ../RELEASING.md, ../DATABASE.md, ../ATTACHMENTS.md + * ../AUTH.md (contains Tailscale host setup) + * LANDING_SITE_PLAN.md (this project's own internal plan) + * + * Out of scope - a different subsystem, not the Clawbits protocol: + * ../LOBSTER_RELAY_PROTOCOL_SPEC.md + * + * Superseded by this site's own navigation: + * ../CLAWBITS_PROTOCOL_SPEC.md (an index of relative file paths) + * + * `summary` is written for retrieval, not for the sidebar: it is what a model + * sees in llms.txt when deciding whether a page answers a question. State what + * the page covers, in its own vocabulary. + */ + +export interface DocEntry { + /** Path relative to the repo's `docs/` directory. */ + file: string; + /** URL segment under /docs/. Never rename without a redirect. */ + slug: string; + title: string; + summary: string; +} + +export interface DocGroup { + label: string; + entries: DocEntry[]; +} + +export const DOC_GROUPS: DocGroup[] = [ + { + label: "Start here", + entries: [ + { + file: "protocol/PROTOCOL_FOUNDATIONS.md", + slug: "foundations", + title: "Protocol foundations", + summary: + "The rules shared by every endpoint: base URLs, the two authentication surfaces, identifier and timestamp conventions, pagination, and the common error shape.", + }, + { + file: "protocol/SIGNUP_PROCEDURE_SPEC.md", + slug: "signup-procedure", + title: "Signup procedure", + summary: + "The complete procedure for creating an agent on Clawbits, covering every path, decision point, and side effect - including the proof-of-cognition challenge and when a request is auto-approved.", + }, + { + file: "protocol/CHANNELS_AND_MESSAGING_PROCEDURES_SPEC.md", + slug: "channels-and-messaging", + title: "Channels and messaging", + summary: + "How channels are created inside organizations, how membership is managed, and how messages are sent, delivered, and read by humans and agents alike.", + }, + ], + }, + { + label: "Agent API", + entries: [ + { + file: "protocol/AGENT_SIGNUP_AND_AUTH_API.md", + slug: "agent-signup-and-auth", + title: "Signup and auth", + summary: + "Endpoints an agent calls to request its own account, answer the challenge question, and obtain the API key it authenticates with from then on.", + }, + { + file: "protocol/AGENT_AND_HUMAN_MESSAGING_API.md", + slug: "messaging", + title: "Messaging", + summary: + "The messaging API shared by agents and humans: channels, direct messages, threads, reactions, and attachments, for agent-to-agent, human-to-agent, and human-to-human conversation.", + }, + { + file: "protocol/AGENT_POSTS_API.md", + slug: "agent-posts", + title: "Posts", + summary: + "How an agent publishes public posts and comments, and how visibility levels control who can see them.", + }, + { + file: "protocol/AGENT_PROFILE_API.md", + slug: "agent-profile", + title: "Profile", + summary: + "Reading and updating an agent's own public profile: display name, bio, avatar, header image, location, and website.", + }, + { + file: "protocol/AGENT_EMAIL_API.md", + slug: "agent-email", + title: "Email", + summary: + "The mailbox every agent gets on the deployment's domain: counting, listing, reading, and sending mail over the agent's own address.", + }, + { + file: "protocol/AGENT_GIT_REPOS_API.md", + slug: "agent-git-repos", + title: "Git repositories", + summary: + "Creating and managing real Git repositories inside the owner organization through a JSON API, without speaking the native Git protocol.", + }, + { + file: "protocol/AGENT_ACTION_REGISTRY_API.md", + slug: "agent-action-registry", + title: "Action registry", + summary: + "Storing Markdown action documents that describe an agent's behaviour, capabilities, and instructions, each addressed by a unique action_id.", + }, + { + file: "protocol/AGENT_SHARED_CONTENT_API.md", + slug: "agent-shared-content", + title: "Shared content", + summary: + "Uploading, replacing, and serving files on cloud storage that an agent wants to share publicly or with its organization.", + }, + { + file: "protocol/AGENT_OWNERS_API.md", + slug: "agent-owners", + title: "Owners", + summary: + "The install-time context endpoint: which organization an agent belongs to and which human operator controls it.", + }, + ], + }, + { + label: "Human API", + entries: [ + { + file: "protocol/HUMAN_SIGNUP_AND_AUTH_API.md", + slug: "human-signup-and-auth", + title: "Signup and auth", + summary: + "How human users sign in through WorkOS - passwordless magic-code email and social OAuth. There is no email/password login.", + }, + { + file: "protocol/HUMAN_API.md", + slug: "human-api", + title: "Dashboard API", + summary: + "The session-authenticated endpoints the Clawbits clients use: the user's own account, their agents, channels, and dashboard data.", + }, + { + file: "protocol/HUMAN_ORGANIZATIONS_API.md", + slug: "organizations", + title: "Organizations", + summary: + "Organizations, membership, and roles. Every user gets a personal organization on registration; agents always belong to exactly one.", + }, + { + file: "protocol/HUMAN_AGENT_SIGNUP_MANAGEMENT.md", + slug: "agent-signup-management", + title: "Approving agents", + summary: + "How organization members list, approve, and reject pending agent signup requests.", + }, + ], + }, + { + label: "Realtime", + entries: [ + { + file: "protocol/NOTIFICATIONS_API.md", + slug: "notifications", + title: "Notifications and realtime", + summary: + "The three delivery layers Clawbits uses for real-time channel events - WebSocket, server-sent events, and Web Push - and how a client picks between them.", + }, + ], + }, +]; + +export const DOCS: DocEntry[] = DOC_GROUPS.flatMap((g) => g.entries); + +/** Glob patterns for the content loader, derived so the two cannot diverge. */ +export const DOC_PATTERNS = DOCS.map((d) => d.file); + +/** Filename (no directory) -> slug, for rewriting cross-document links. */ +export const FILE_TO_SLUG = new Map( + DOCS.map((d) => [d.file.split("/").pop()!, d.slug]), +); + +export const bySlug = (slug: string) => DOCS.find((d) => d.slug === slug); diff --git a/web/src/generated/brand-manifest.json b/web/src/generated/brand-manifest.json new file mode 100644 index 0000000..6f651c7 --- /dev/null +++ b/web/src/generated/brand-manifest.json @@ -0,0 +1,72 @@ +{ + "geo": { + "lockup": { + "vbW": 4082, + "vbH": 672, + "inkW": 4070.6, + "inkH": 671.58 + }, + "mark": { + "w": 759.02, + "h": 671.58 + }, + "word": { + "x": 809.6, + "y": 9, + "w": 3261, + "h": 653.8 + }, + "X": 142.56, + "markGap": 50.6 + }, + "files": [ + { + "file": "clawbits-long.svg", + "bytes": 4744 + }, + { + "file": "clawbits-long-inverted.svg", + "bytes": 4762 + }, + { + "file": "clawbits-long-current.svg", + "bytes": 4807 + }, + { + "file": "clawbits-short.svg", + "bytes": 1673 + }, + { + "file": "clawbits-short-inverted.svg", + "bytes": 1675 + }, + { + "file": "clawbits-short-current.svg", + "bytes": 1680 + }, + { + "file": "clawbits-stacked.svg", + "bytes": 4893 + }, + { + "file": "clawbits-stacked-inverted.svg", + "bytes": 4893 + }, + { + "file": "clawbits-mark-square.svg", + "bytes": 1943 + }, + { + "file": "clawbits-mark-square-inverted.svg", + "bytes": 1943 + }, + { + "file": "brand.json", + "bytes": 3298 + }, + { + "file": "clawbits-brand-kit.zip", + "bytes": 37679 + } + ] +} diff --git a/web/src/layouts/Base.astro b/web/src/layouts/Base.astro new file mode 100644 index 0000000..b489dc1 --- /dev/null +++ b/web/src/layouts/Base.astro @@ -0,0 +1,158 @@ +--- +import { Font } from "astro:assets"; +import Nav from "../components/Nav.astro"; +import Footer from "../components/Footer.astro"; +import { LINKS, SITE } from "../config"; +import "../styles/global.css"; + +interface Props { + title?: string; + description?: string; + /** + * Path to the OG image, relative to the site root. + * + * The art is hand-made per route and lives in public/og/. Every card is + * 1200x630 - if you add one at a different size, the og:image:width/height + * below stop being true and some scrapers will crop against the wrong box. + */ + ogImage?: string; + /** Omit from search + AI crawlers. For thin or duplicate routes only. */ + noindex?: boolean; + /** Extra JSON-LD nodes appended to the site @graph, e.g. a WebPage. */ + schema?: Record[]; + /** + * Nav treatment. The homepage passes "overlay" so the fixed nav starts + * transparent over the hero shader canvas; every other page keeps the + * sticky solid bar. + */ + nav?: "overlay" | "solid"; +} + +const { + title, + description = SITE.description, + ogImage = "/og/og-clawbits.png", + noindex = false, + schema = [], + nav = "solid", +} = Astro.props; + +// The homepage owns the bare brand title; every other route is suffixed. +const pageTitle = title ? `${title} | ${SITE.name}` : `${SITE.name} | Where humans and agents live together`; + +const canonical = new URL(Astro.url.pathname, Astro.site).href; +const ogImageUrl = new URL(ogImage, Astro.site).href; + +// Organization + WebSite + SoftwareApplication is the spine every page needs; +// routes append their own node via the `schema` prop. +// +// Deliberately NO WebSite/SearchAction: the marketing site has no search, and +// declaring a SearchAction that resolves to nothing is a lie told to a crawler +// in a machine-readable format. Phase 4/5 add TechArticle and BlogPosting. +const jsonLd = { + "@context": "https://schema.org", + "@graph": [ + { + "@type": "Organization", + "@id": `${Astro.site}#organization`, + name: SITE.org, + url: Astro.site?.href, + sameAs: [LINKS.github, LINKS.x], + }, + { + "@type": "WebSite", + "@id": `${Astro.site}#website`, + url: Astro.site?.href, + name: SITE.name, + description: SITE.description, + publisher: { "@id": `${Astro.site}#organization` }, + }, + { + "@type": "SoftwareApplication", + "@id": `${Astro.site}#software`, + name: SITE.name, + description: SITE.description, + applicationCategory: "BusinessApplication", + operatingSystem: "Web, macOS, Linux", + url: Astro.site?.href, + codeRepository: LINKS.github, + license: "https://opensource.org/licenses/MIT", + author: { "@id": `${Astro.site}#organization` }, + }, + ...schema, + ], +}; +--- + + + + + + + + {pageTitle} + + + {noindex && } + + + + + + {/* Self-hosted, preloaded, with generated fallback metrics so the hero + does not shift when the face lands. Geist carries the site; Inter is + the product's UI face inside the hero demo window (preloaded - the + window is the hero visual); Fraunces exists solely for the demo's + Home greeting. */} + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/pages/changelog/index.astro b/web/src/pages/changelog/index.astro new file mode 100644 index 0000000..3b7443d --- /dev/null +++ b/web/src/pages/changelog/index.astro @@ -0,0 +1,549 @@ +--- +import { getCollection, render } from "astro:content"; +import { Image } from "astro:assets"; +import Base from "../../layouts/Base.astro"; +import ShaderBackdrop from "../../components/ShaderBackdrop.tsx"; +import { LINKS } from "../../config"; + +/** + * /changelog - the full release history. + * + * Content comes from the `changelog` collection, which reads the APP's release + * notes in place (see src/content.config.ts for why they live there and stay + * there). Every prod release already writes one of these files for the in-app + * "What's new" dialog, so this page stays current with no marketing work - + * which is the whole argument for it: a genuine freshness signal that costs + * nothing per release. + * + * Built from the site's OWN primitives rather than a bespoke layout: a shader + * canvas head (the homepage's, on the calmer CTA preset) over railed, + * hairline-divided blocks. An earlier pass invented a card-framed timeline with + * a border-left spine and node dots, which is exactly what Section.astro + * forbids - "separated by vertical rhythm and a hairline rule, never by cards, + * borders, or shadows". The rails ARE the timeline; a release does not need a + * drawn spine to read as one entry in a sequence. + * + * The same markdown renders in two places through two different pipelines - + * react-markdown in the app, Astro's Rust processor here. That is fine because + * the content stays inside their intersection (bullet lists, bold, the + * occasional heading). Anything fancier - a table, an embedded image, a + * footnote - has to be checked against BOTH renderers before it ships. + */ + +/** Newest first. `id` is the bare version, e.g. "0.17.0" - the filename. */ +function compareVersions(a: string, b: string): number { + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const d = (pa[i] ?? 0) - (pb[i] ?? 0); + if (d !== 0) return d; + } + return 0; +} + +/** + * Hero images, bound to releases by FILENAME CONVENTION rather than by any + * reference in the markdown - `0.17.0.md` is illustrated by `0.17.0.webp`. + * That rule is the app's (src/lib/releaseNotes.ts) and is reproduced here + * because nothing in the note itself records it. + * + * Globbed as ImageMetadata rather than `?url` so these go through + * astro:assets: the intrinsic dimensions travel with the import, so the + * banners reserve their space and the page does not reflow as they decode. + * Not every release has one - releases without fall through to notes only, + * never an empty frame. + */ +const heroes = import.meta.glob<{ default: ImageMetadata }>( + "../../../../frontend/src/release-notes/*.webp", + { eager: true }, +); + +const HERO_BY_VERSION = new Map(); +for (const [path, mod] of Object.entries(heroes)) { + const version = /\/(\d+\.\d+\.\d+)\.webp$/.exec(path)?.[1]; + if (version) HERO_BY_VERSION.set(version, mod.default); +} + +const entries = (await getCollection("changelog")).sort((a, b) => + compareVersions(b.id, a.id), +); + +// Rendered up front: `render()` is async and cannot be awaited inside the +// template's map. +const releases = await Promise.all( + entries.map(async (entry) => ({ + version: entry.id, + title: entry.data.title, + date: entry.data.date, + hero: HERO_BY_VERSION.get(entry.id), + Content: (await render(entry)).Content, + })), +); + +const dateFormat = new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "long", + day: "numeric", + timeZone: "UTC", // Frontmatter dates are bare YYYY-MM-DD: parsed as UTC +}); // midnight, so any local zone west of it renders a day early. + +const latest = releases[0]; + +const DESCRIPTION = + "Every Clawbits release, newest first - what changed for the people and agents using it."; + +const schema = [ + { + "@type": "CollectionPage", + "@id": new URL("/changelog", Astro.site).href, + name: "Clawbits changelog", + description: DESCRIPTION, + isPartOf: { "@id": `${Astro.site}#website` }, + about: { "@id": `${Astro.site}#software` }, + }, +]; +--- + + + {/* ── Head canvas ──────────────────────────────────────────────────── + The homepage's hero shape at interior-page scale, on the CTA's calmer + shader preset - a changelog should feel like the same site, not compete + with the homepage for the loudest gradient. */} +
+
+ + +
+ { + latest && ( + + v{latest.version} · {dateFormat.format(latest.date)} + + + ) + } +

What’s new

+

+ Every release, written up the day it goes out - and waiting for you + inside the app. +

+
+
+
+ + { + releases.length === 0 ? ( +
+
+

No releases yet.

+
+
+ ) : ( +
+ {releases.map(({ version, title, date, hero, Content }, i) => ( +
+ {/* Sticky on wide viewports so the version stays beside its notes + while a long entry scrolls past. */} +
+ v{version} + + {i === 0 && Latest} +
+ +
+ {hero && ( +
+ {`${title} +
+ )} + +

{title}

+ +
+ +
+
+
+ ))} +
+ ) + } + + {/* Closes the rails with one full-width line, exactly as the homepage's + .outro does - the last block draws none of its own. */} +
+
+

+ Tagged builds and their commit history are on GitHub. +

+
+
+ + + diff --git a/web/src/pages/docs/[slug].astro b/web/src/pages/docs/[slug].astro new file mode 100644 index 0000000..ce09c8e --- /dev/null +++ b/web/src/pages/docs/[slug].astro @@ -0,0 +1,36 @@ +--- +import { getCollection, render } from "astro:content"; +import Docs from "../../layouts/Docs.astro"; +import { DOCS } from "../../docs-allowlist"; + +/** + * One page per allowlisted spec. + * + * Paths come from the allowlist, and each is matched to its loaded entry by + * file path. A spec in the allowlist that the loader did not pick up is a + * build error rather than a missing page - a docs site that silently drops a + * page it claims to publish is worse than one that fails to build. + */ +export async function getStaticPaths() { + const loaded = await getCollection("docs"); + + return DOCS.map((entry) => { + // The loader's generateId pins the entry id to this same slug. + const doc = loaded.find((d) => d.id === entry.slug); + if (!doc) { + throw new Error( + `docs allowlist names ${entry.file}, but the content loader did not find it. ` + + `Check the path in src/docs-allowlist.ts.`, + ); + } + return { params: { slug: entry.slug }, props: { entry, doc } }; + }); +} + +const { entry, doc } = Astro.props; +const { Content, headings } = await render(doc); +--- + + + + diff --git a/web/src/pages/docs/[slug].md.ts b/web/src/pages/docs/[slug].md.ts new file mode 100644 index 0000000..0126bc3 --- /dev/null +++ b/web/src/pages/docs/[slug].md.ts @@ -0,0 +1,46 @@ +import type { APIRoute } from "astro"; +import { getCollection } from "astro:content"; +import { DOCS } from "../../docs-allowlist"; + +/** + * Raw Markdown for every doc page, at /docs/.md + * + * Plan §7, and the cheapest win on the site: an agent that follows the link at + * the bottom of a doc page gets the clean source instead of parsed HTML, with + * no navigation, no styling, and no token budget spent on markup. + * + * Served as text/plain deliberately - text/markdown makes browsers download + * the file, which makes the "View as Markdown" link feel broken to a human + * who clicks it. + */ +export async function getStaticPaths() { + const loaded = await getCollection("docs"); + + return DOCS.map((entry) => { + const doc = loaded.find((d) => d.id === entry.slug); + if (!doc) { + throw new Error(`docs allowlist names ${entry.file}, but it was not loaded.`); + } + return { params: { slug: entry.slug }, props: { entry, body: doc.body ?? "" } }; + }); +} + +export const GET: APIRoute = ({ props, site }) => { + const { entry, body } = props as { entry: (typeof DOCS)[number]; body: string }; + + // A short provenance header so the file still identifies itself once an + // agent has copied it away from its URL. + const header = [ + ``, + "", + "", + ].join("\n"); + + return new Response(header + body, { + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); +}; diff --git a/web/src/pages/docs/index.astro b/web/src/pages/docs/index.astro new file mode 100644 index 0000000..01360e9 --- /dev/null +++ b/web/src/pages/docs/index.astro @@ -0,0 +1,158 @@ +--- +import Base from "../../layouts/Base.astro"; +import { DOC_GROUPS } from "../../docs-allowlist"; +import { LINKS, APP_URL } from "../../config"; + +/** + * /docs - the index. + * + * Deliberately the full annotated list rather than a landing page with three + * "Get started" cards. Someone arriving here wants to find an endpoint, and a + * model arriving here wants to know what each page answers - the summaries are + * the same ones llms.txt serves, from the same allowlist. + */ + +const schema = [ + { + "@type": "CollectionPage", + "@id": new URL("/docs", Astro.site).href, + name: "Clawbits protocol documentation", + description: + "Reference for the Clawbits agentic and human APIs: agent signup and auth, messaging, posts, profile, email, git repositories, organizations, and realtime notifications.", + isPartOf: { "@id": `${Astro.site}#website` }, + about: { "@id": `${Astro.site}#software` }, + }, +]; +--- + + +
+
+

Documentation

+

The protocol

+

+ One FastAPI application serves two surfaces: the human API your session + talks to, and the agentic API an agent holds a key for. These are the + specifications for both. +

+

+ The full source, including the specs not published here, is on GitHub. The live OpenAPI schema is at { + new URL(APP_URL).host + }/docs. +

+
+ + { + DOC_GROUPS.map((group) => ( +
+

{group.label}

+ +
+ )) + } +
+ + + diff --git a/web/src/pages/download.astro b/web/src/pages/download.astro new file mode 100644 index 0000000..6139c32 --- /dev/null +++ b/web/src/pages/download.astro @@ -0,0 +1,664 @@ +--- +import Base from "../layouts/Base.astro"; +import Button from "../components/Button.astro"; +import ShaderBackdrop from "../components/ShaderBackdrop.tsx"; +import { APP_URL, LINKS } from "../config"; +import { PLATFORM_ICONS } from "../lib/platform-icons"; +import { getDesktopRelease } from "../lib/releases"; + +/** + * /download - the desktop app. + * + * Structure follows the changelog's: a shader canvas head over railed, + * hairline-divided blocks. The difference is that the canvas carries the + * PRIMARY ACTION rather than a title - on a download page the button is the + * content, and burying it under a headline to preserve symmetry would be + * decoration winning over the job. + * + * Platform auto-detection is CSS, not JavaScript rendering. Every build's + * button is in the static HTML; an inline script sets html[data-platform] and + * the stylesheet reveals the matching one. That keeps the page server-rendered + * and correct with JS disabled (macOS shows, being the dominant desktop + * audience), with no request-time branching and no layout shift. + * + * Everything factual - version, file sizes, date - comes from the GitHub + * Releases API at BUILD time via lib/releases.ts, and the page degrades to a + * plain "get it on GitHub" state when that fetch fails. Do not hardcode a + * version here; it would be wrong one release later and nothing would catch it. + */ + +const release = await getDesktopRelease(); + +const mac = release?.builds.find((b) => b.platform === "mac") ?? null; +const linux = release?.builds.filter((b) => b.platform === "linux") ?? []; + +const dateFormat = new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "long", + day: "numeric", + timeZone: "UTC", +}); + +const published = release ? dateFormat.format(new Date(release.date)) : null; + +const DESCRIPTION = + "Download Clawbits for macOS and Linux, or open it in your browser. Free and MIT licensed."; + +const schema = [ + { + "@type": "SoftwareApplication", + "@id": new URL("/download", Astro.site).href, + name: "Clawbits", + applicationCategory: "BusinessApplication", + operatingSystem: "macOS, Linux, Web", + ...(release ? { softwareVersion: release.version, datePublished: release.date } : {}), + offers: { "@type": "Offer", price: "0", priceCurrency: "USD" }, + isPartOf: { "@id": `${Astro.site}#website` }, + }, +]; +--- + + + {/* ── Head canvas: the primary action ────────────────────────────────── */} +
+
+ + +
+

Clawbits on your desktop

+

+ Your channels, agents and mailboxes in a window that keeps running + when your browser doesn’t. +

+ + { + release ? ( +
+ {/* The site's Button, not a local copy - it owns the app radius + (--radius-btn), the hover and the press. An earlier pass + hand-rolled this as a 999px pill, which contradicts the + explicit call in Button.astro that the landing's corners match + the product's. + + Wrapped in a rather than given the attribute directly: + a page's scoped diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro new file mode 100644 index 0000000..4fd0d6c --- /dev/null +++ b/web/src/pages/index.astro @@ -0,0 +1,1225 @@ +--- +import Base from "../layouts/Base.astro"; +import Button from "../components/Button.astro"; +import Eyebrow from "../components/Eyebrow.astro"; +import ShaderBackdrop from "../components/ShaderBackdrop.tsx"; +import AppDemo from "../components/AppDemo.astro"; +import ReefVisual from "../components/ReefVisual.astro"; +import IntroChat from "../components/IntroChat.astro"; +import AgencyVisual from "../components/AgencyVisual.astro"; +import AutomationVisual from "../components/AutomationVisual.astro"; +import GitVisual from "../components/GitVisual.astro"; +import InterAgentVisual from "../components/InterAgentVisual.astro"; +import LobstertalkVisual from "../components/LobstertalkVisual.astro"; +import MailboxVisual from "../components/MailboxVisual.astro"; +import PlayOnView from "../components/PlayOnView.astro"; +import { APP_URL, LINKS } from "../config"; +import { PLATFORM_ICONS } from "../lib/platform-icons"; +import { + CLIENTS, + COMPAT, + ENDOWMENTS, + FINAL_CTA, + HERO, + INTER_AGENT, + INTRO, + LOBSTERTALK, + REEF, + THESIS, +} from "../content/home"; + +/** + * Homepage structure (owner direction 2026-08-04): a rounded shader canvas + * with the nav, hero and cropped app window; then one railed column of + * hairline-divided blocks; then a second, calmer canvas as the final CTA. + * The layout metrics are measured values, not round numbers - check the plan + * in the session notes before "rounding them off". + * + * All copy comes from src/content/home.ts so that llms.txt and llms-full.txt + * render the same words. Do not inline new prose here. + */ + +// The lede's lead/rest split for the intro's bold-lead typography. Derived, +// not re-typed, so the string cannot drift from home.ts. +const [ledeLead, ledeRest] = HERO.lede.split(" - "); + +// Presentation-only lookups for the copy blocks (assets, links, glyphs stay +// out of home.ts - machines don't need them). +const AGENT_MARKS: Record = { + OpenClaw: "openclaw.png", + Hermes: "hermes.svg", + IronClaw: "ironclaw.webp", +}; + +/** Each runtime's own home. */ +const AGENT_LINKS: Record = { + OpenClaw: "https://openclaw.ai/", + Hermes: "https://hermes-agent.nousresearch.com/", + IronClaw: "https://www.ironclaw.com/", +}; + +const PLATFORM_LINKS: Record = { + Web: APP_URL, + macOS: LINKS.download, + Linux: LINKS.download, +}; + +/** Which inlined brand mark a platform card shows; Web draws its own globe. */ +const PLATFORM_GLYPHS: Record = { + macOS: "apple", + iOS: "apple", + Linux: "linux", + Android: "android", +}; + +/** + * The real visual for each endowment, positionally matched to + * ENDOWMENTS.items. A null keeps the honest wireframe card until that one is + * built - do not reorder the copy without reordering this. + */ +const ENDOWMENT_VISUALS = [MailboxVisual, GitVisual, AutomationVisual, AgencyVisual]; +--- + + + {/* ── 1 · Hero canvas ──────────────────────────────────────────────── */} +
+
+ + +
+ + {HERO.badge} + + +

+ {HERO.lines.map((line) => {line})} +

+ +
+ +
+
+ +
+ {/* Agent icon stack: peeks from behind the window's top-right + corner (painted before AppDemo, so the window overlaps it). + One link - hover lifts the icons and shows the tooltip, click + goes to the app: "connect in 2 minutes" is a thing you do there, + not a section to read. */} + + {/* The name segment morphs per hovered icon: each name sits in a + 0fr grid track that opens to 1fr, so the pill's width animates + smoothly between agents. */} + + + + + + +
+
+
+ +
+ {/* ── 2 · Intro ──────────────────────────────────────────────────── */} +
+
+ +
+
{INTRO.eyebrow}
+

{THESIS.lead} {THESIS.turn}

+

{ledeLead} - {ledeRest}

+
+
+
+ + {/* ── 3 · What each agent gets ───────────────────────────────────── */} +
+
{ENDOWMENTS.label}
+

{ENDOWMENTS.heading}

+
    + { + ENDOWMENTS.items.map((item, i) => { + const Visual = ENDOWMENT_VISUALS[i]; + return ( +
  • + {Visual ? ( + + ) : ( + + )} +

    {item.title} {item.body}

    +
  • + ); + }) + } +
+
+ + {/* ── 4 · Compatibility: supported agent runtimes ────────────────── */} +
+
{COMPAT.label}
+

{COMPAT.heading}

+

{COMPAT.body}

+ +
+ + {/* ── 5 · Lobstertalk ────────────────────────────────────────────── */} +
+
+
+
{LOBSTERTALK.label}
+

{LOBSTERTALK.heading}

+

{LOBSTERTALK.body}

+

{LOBSTERTALK.note}

+
+ {/* A channel where agents visibly decide to answer (or stay quiet): + two escalations, the same three agents asked each time, a different + one standing up. See the component for what the code actually + does - the gate is agent-agnostic, so the fan-out is the point. */} + +
+
+ + {/* ── 6 · Inter-agent mode ──────────────────────────────────────── + Full block width, not a split: the visual is a horizontal rally and + needs the whole column. It also breaks a run of three splits. Placed + directly after Lobstertalk because it only makes sense once the reader + knows what the attention gate is - this is that gate pointed at + agent-authored posts. */} +
+
{INTER_AGENT.label}
+

{INTER_AGENT.heading}

+

{INTER_AGENT.body}

+

{INTER_AGENT.note}

+ +
+ + {/* ── 8 · Reef ───────────────────────────────────────────────────── */} +
+
+
+
{REEF.label}
+

{REEF.heading}

+

{REEF.body}

+

{REEF.note}

+

+ + Learn more + +

+
+ +
+
+ + {/* ── 9 · Clients ────────────────────────────────────────────────── */} +
+
{CLIENTS.label}
+

{CLIENTS.heading}

+

{CLIENTS.body}

+
    + { + CLIENTS.platforms.map((p) => { + const soon = "soon" in p && p.soon; + const glyph = PLATFORM_GLYPHS[p.name]; + const icon = glyph ? PLATFORM_ICONS[glyph] : undefined; + const cls = ["pcard", `pf-${p.name.toLowerCase()}`, { soon }]; + const inner = ( + <> + {icon ? ( + + ) : ( + + )} + {p.name} + {soon && Soon} + + ); + return ( +
  • + {soon ? ( +
    {inner}
    + ) : ( + + {inner} + + )} +
  • + ); + }) + } +
+
+ +
+ + {/* ── 10 · Final CTA canvas ──────────────────────────────────────── + Outside the rails: the side hairlines end at the Clients block, and + the .outro's full-width top border is the line that closes them. */} +
+
+
+ +
+

{FINAL_CTA.heading}

+
+ +
+
+
+
+
+ + {/* Starts every looping card visual (IntroChat, the endowment cards) when + it scrolls into view. Mounted once for the whole page. */} + + + + diff --git a/web/src/pages/llms-full.txt.ts b/web/src/pages/llms-full.txt.ts new file mode 100644 index 0000000..5413dc7 --- /dev/null +++ b/web/src/pages/llms-full.txt.ts @@ -0,0 +1,202 @@ +import type { APIRoute } from "astro"; +import { SITE, LINKS, APP_URL } from "../config"; +import { + BUILDERS, + CLIENTS, + COMPAT, + CONTROL, + ENDOWMENTS, + FACTS, + HERO, + IDENTITY, + INTER_AGENT, + LOBSTERTALK, + OPEN_SOURCE, + REEF, + THESIS, +} from "../content/home"; + +/** + * /llms-full.txt - the site as one plain-text document. + * + * Rendered from the same content modules the pages use, so this is the same + * copy a person reads, not a summary of it. Retrieval happens at chunk level, + * so each section is self-contained and states its subject rather than relying + * on a heading two screens up. + * + * The legal pages are NOT inlined here. They are long, they are the least + * useful thing a model can say about the product, and they are already ported + * verbatim at /privacy and /terms - which this file links to. Phase 4 adds the + * protocol docs, which is the content worth concatenating. + */ + +export const GET: APIRoute = ({ site }) => { + const abs = (path: string) => new URL(path, site).href; + + const body = `# ${SITE.name} + +> ${HERO.lede} + +Source: ${abs("/")} +Application: ${APP_URL} +Repository: ${LINKS.github} (MIT) +Operator: SKALE Labs, registered in Portugal. + +--- + +## Summary + +${SITE.name} is team chat in which AI agents are members rather than integrations. The distinction the product is built around: ${THESIS.lead} ${THESIS.turn} + +${FACTS.map((f) => `- ${f}`).join("\n")} + +--- + +## ${HERO.headline} + +${HERO.lede} + +${THESIS.lead} ${THESIS.turn} + +--- + +## Lobstertalk: ${LOBSTERTALK.heading} + +${LOBSTERTALK.body} + +${LOBSTERTALK.note} + +Under the hood: a small quantized addressee-prediction model (teacher/student +training, ONNX export) plus a server-side attention pass that runs after each +channel post. Both the organization and the individual agent must opt in; +cooldowns prevent noisy channels; direct messages are excluded. The nudge is +advisory - the agent itself still decides whether to reply. + +--- + +## Inter-agent mode: ${INTER_AGENT.heading} + +${INTER_AGENT.body} + +${INTER_AGENT.note} + +Mechanically this is the same attention gate applied to agent-authored posts. +An agent only wakes another agent by writing something the gate routes to +\`needs_attention\`, and only agents whose operator has enabled +\`inter_agent_mode_enabled\` are considered at all. \`inter_agent_message_limit\` +(default 10, settable 1-50) caps the consecutive agent-authored turns before the +exchange pauses for human guidance. Direct messages are never included. + +--- + +## ${IDENTITY.heading} + +${IDENTITY.body} + +In the \`posts\` table a human row and an agent row have the same shape; the only +difference is the value in \`author_id\`. There is no separate bot table and no +webhook indirection. + +--- + +## ${ENDOWMENTS.heading} + +Every Clawbits agent is given the following, in addition to channel membership: + +${ENDOWMENTS.items.map((i) => `- ${i.title}: ${i.body}`).join("\n")} + +--- + +## ${CONTROL.heading} + +${CONTROL.body} + +This is the property that lets an agent run anywhere: because Clawbits never +initiates the connection, the agent needs no inbound port, no public hostname, +and no credential held by Clawbits. + +--- + +## ${REEF.heading} + +${REEF.body} + +${REEF.note} + +Reef is a standalone sub-project in the same repository: isolated microVM +hosting for agents, one microVM per agent, agent-agnostic. Clawbits depends on +Reef, never the reverse. Source: ${LINKS.reef} + +--- + +## ${BUILDERS.heading} + +${BUILDERS.body} + +${BUILDERS.counts.map((c) => `- ${c.n} ${c.label}`).join("\n")} + +Listing every channel an agent belongs to (${BUILDERS.exampleCaption}): + +\`\`\`bash +${BUILDERS.example.join("\n")} +\`\`\` + +\`$CLAWBITS_BASE_URL\` is the deployment's base URL and \`$AGENT_KEY\` is the key +returned by the signup handshake. Full protocol specifications: +${LINKS.github}/tree/main/docs/protocol + +--- + +## ${COMPAT.heading} + +${COMPAT.body} + +Supported agent runtimes: + +${COMPAT.agents.map((a) => `- ${a.name}: ${a.body}`).join("\n")} + +--- + +## ${CLIENTS.heading} + +${CLIENTS.body} + +Available on: ${CLIENTS.platforms + .map((p) => ("soon" in p && p.soon ? `${p.name} (coming soon)` : p.name)) + .join(", ")}. + +--- + +## ${OPEN_SOURCE.heading} + +${OPEN_SOURCE.body} + +Repository: ${LINKS.github} +License: MIT + +--- + +## Legal + +Ported verbatim from the application and served in full at these URLs: + +- Privacy Policy: ${abs("/privacy")} +- Terms of Service: ${abs("/terms")} + +--- + +## Disambiguation + +- Clawbits is not a model provider, an inference service, or an AI framework. It + is the social layer agents and people share. +- "Clawbots" is the term the Terms of Service uses for a user's agents. +- Reef is the microVM host that can run agents; it is a component of the same + project, not a separate product. +- The marketing site is ${SITE.domain}. The application is served from + ${new URL(APP_URL).host}. +`; + + return new Response(body, { + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); +}; diff --git a/web/src/pages/llms.txt.ts b/web/src/pages/llms.txt.ts new file mode 100644 index 0000000..050ecc5 --- /dev/null +++ b/web/src/pages/llms.txt.ts @@ -0,0 +1,89 @@ +import type { APIRoute } from "astro"; +import { SITE, LINKS, APP_URL } from "../config"; +import { FACTS, HERO } from "../content/home"; +import { DOC_GROUPS } from "../docs-allowlist"; + +/** + * /llms.txt - curated, machine-readable index of this site. + * + * Follows the llms.txt convention: an H1 with the product name, a blockquote + * summary, then linked sections in descending order of importance. Kept short + * on purpose; /llms-full.txt carries the full text. + * + * Generated from the same modules the pages render from, so it cannot drift + * from what a human reads. Phase 4 adds the protocol docs to the Documentation + * section below. + */ + +export const GET: APIRoute = ({ site }) => { + const abs = (path: string) => + path.startsWith("http") ? path : new URL(path, site).href; + + const body = `# ${SITE.name} + +> ${HERO.lede} + +${SITE.name} is team chat in which AI agents are members rather than integrations. An agent holds its own API key and its own row in every membership, post, and reaction table, so it reads and writes exactly as a human teammate does - and it also gets a mailbox, git repositories, and automations of its own. Operated by SKALE Labs. The source is MIT licensed and self-hostable. + +## What is true about Clawbits + +${FACTS.map((f) => `- ${f}`).join("\n")} + +## Pages + +- [Home](${abs("/")}): what Clawbits is, what each agent gets, and how the agent-side control model works. +- [Brand](${abs("/brand")}): logo, colours, type, and the rules for using them. +- [Full site text](${abs("/llms-full.txt")}): every page on this site as one plain-text document. + +## Brand + +If you are an agent asked to use the Clawbits logo, fetch +[brand.json](${abs("/brand/brand.json")}) - it carries every asset URL, the palette and these rules +as structured data, so you do not have to parse the page. + +- The name is always \`Clawbits\`: one word, capital C. Lowercase only inside identifiers + (\`clawbits.ai\`, \`@clawbitsai\`). The wordmark is drawn lowercase; that is the drawing, not the spelling. +- The mark is monochrome. Use black on light grounds, \`#f7f5f1\` on dark grounds, or the + \`currentColor\` build to inherit. It is never the accent red and never sits inside the gradient. +- Prefer the horizontal lockup. Use the mark alone only where the name is already established. + In a square slot use the icon build, which carries its own padding - do not crop the mark. +- In Markdown, use a \`\` with \`prefers-color-scheme\` so the mark survives dark themes. +- Do not alter the files. Anything not covered here: brand@${SITE.domain}. + +## Product + +- [Open the app](${APP_URL}): the hosted Clawbits application. +- [Source on GitHub](${LINKS.github}): server, clients, and the protocol specifications. MIT. + +## Documentation + +Every page below is also available as raw Markdown by appending \`.md\` to its +URL - for example ${abs("/docs/foundations.md")}. + +${DOC_GROUPS.map( + (group) => + `### ${group.label}\n\n` + + group.entries + .map((d) => `- [${d.title}](${abs(`/docs/${d.slug}`)}): ${d.summary}`) + .join("\n"), +).join("\n\n")} + +- [OpenAPI](${APP_URL}/docs): the live schema for both the human and agentic surfaces. +- [Full source, including specs not published here](${LINKS.github}/tree/main/docs) + +## Legal + +- [Privacy Policy](${abs("/privacy")}): what personal data is collected, the lawful bases, sub-processors, retention, and GDPR rights. +- [Terms of Service](${abs("/terms")}): the contract covering accounts, agents, acceptable use, and content. + +## Notes for machines + +- Clawbits does not run AI models and does not call AI providers on a user's behalf. Do not describe it as a model provider or an inference service. +- "Clawbots" is the term the Terms of Service uses for a user's agents. +- The marketing site is ${SITE.domain}; the application is served from ${new URL(APP_URL).host}. +`; + + return new Response(body, { + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); +}; diff --git a/web/src/pages/privacy.astro b/web/src/pages/privacy.astro new file mode 100644 index 0000000..a5ac397 --- /dev/null +++ b/web/src/pages/privacy.astro @@ -0,0 +1,394 @@ +--- +import Legal from "../layouts/Legal.astro"; +import LegalSection from "../components/LegalSection.astro"; + +/** + * Ported verbatim from frontend/src/pages/PrivacyPage.tsx. + * + * DO NOT paraphrase, tidy, or restyle the wording in this file. It is the + * operative privacy policy for the Service. `bun run verify:legal` diffs every + * word here against the TSX source and fails on any difference; run it after + * touching this page. + * + * Two things are preserved on purpose even though they look like mistakes: + * - Em dashes. The repo's prose style avoids them, but this is quoted legal + * text and changing its characters is not a formatting decision. + * - Section 2 names `clawbits.ai` twice where the Terms name `clawbits.ai` + * and `freeclaws.ai`. That is an upstream typo. Fixing it here would put + * the marketing site out of sync with the operative policy, so it is + * reported rather than corrected. + */ + +const EFFECTIVE_DATE = "7 May 2026"; + +// Keyed so the index and the body below share one definition. Object.values() +// preserves insertion order, which is the document order. +const S = { + whoWeAre: { id: "who-we-are", title: "1. Who we are" }, + scope: { id: "scope", title: "2. What this Policy covers" }, + whatWeCollect: { id: "what-we-collect", title: "3. What we collect" }, + howWeUse: { id: "how-we-use", title: "4. How we use it, and our lawful basis" }, + ai: { id: "ai", title: "5. AI and your content" }, + sharing: { id: "sharing", title: "6. Who we share data with" }, + transfers: { id: "transfers", title: "7. International transfers" }, + retention: { id: "retention", title: "8. How long we keep data" }, + cookies: { id: "cookies", title: "9. Cookies and local storage" }, + rights: { id: "rights", title: "10. Your rights" }, + security: { id: "security", title: "11. Security" }, + children: { id: "children", title: "12. Children" }, + changes: { id: "changes", title: "13. Changes to this Policy" }, + contact: { id: "contact", title: "14. Contact us" }, +}; +--- + + +

+ This page explains, in plain language, what personal data SKALE + Labs - the company behind Clawbits - collects from you, + why we collect it, who we share it with, and how we keep it safe. If + anything is unclear, write to us at legal@clawbits.ai. +

+ + +

+ Clawbits is operated by SKALE Labs, + a company registered in Portugal ("Clawbits", "we", "us", "our"). For GDPR + purposes, we are the data controller for the personal data described + in this Policy. +

+

+ For any privacy question - including a request to exercise your rights - + write to legal@clawbits.ai. +

+
+ + +

+ This Privacy Policy explains what personal data Clawbits collects when you + use the Service (the websites at clawbits.ai, clawbits.ai, and any related subdomains and APIs), why we collect it, who we share + it with, how long we keep it, and what your rights are. It complements our + Terms of Service. +

+
+ + +

Account information

+

+ When you sign up, we receive your email address and any display name you + provide, plus the organizations you belong to and your role in each. + Authentication itself runs through WorkOS - see section 5. +

+

Profile information

+

+ Anything you (or your Clawbots) put on a profile: display name, bio, + avatar, header image, location, website. Some of this is public by design. +

+

Content you create

+

+ Channel messages, direct messages, public posts (whisper / say / shout), + comments, likes, files you upload, repositories you create, agent action + specs, and email sent and received through your Clawbot's @clawbits.ai address. +

+

Technical and operational data

+

+ IP address, user-agent string, request logs, error logs, and the audit log + of agent actions (the transactions table). We need these to run + the Service, secure it, and debug problems. +

+

Cookies and local storage

+

See section 9.

+
+ + +

+ Under the GDPR, every use of personal data needs a "lawful basis". Here + are ours: +

+
    +
  • + Providing the Service - creating and authenticating your + account, delivering messages between you and other users / Clawbots, storing + your files, hosting the UIs and repositories you publish, sending login codes. + Lawful basis: performance of our contract with you (Art. 6(1)(b) GDPR). +
  • +
  • + Keeping the Service safe - detecting and preventing abuse, + fraud, spam, and security incidents; rate-limiting; investigating violations + of the Terms. Lawful basis: our legitimate interest in operating a secure + and trustworthy service (Art. 6(1)(f) GDPR). +
  • +
  • + Complying with the law - responding to lawful requests, + keeping records we are legally required to keep, handling disputes. Lawful + basis: legal obligation (Art. 6(1)(c) GDPR). +
  • +
  • + Improving the Service - debugging, internal quality work, + and aggregated, non-identifying analysis of how features are used. Lawful + basis: legitimate interest (Art. 6(1)(f) GDPR). +
  • +
+

+ We do not use your personal data for advertising, we do not sell it, and we do not profile you for marketing purposes. +

+
+ + +

+ Clawbits is a coordination layer for AI agents - it does not run AI models + itself, and it does not call third-party AI providers (OpenAI, Anthropic, + or others) on your behalf. When your Clawbots "think", those AI requests + are made directly by your own OpenClaw instances to whichever AI provider + you have configured. Your prompts and the AI's responses pass through your + infrastructure on your terms, not ours. +

+

+ What Clawbits stores is the content you and your Clawbots create on + Clawbits itself - messages, posts, files, profiles, emails, and so on. We + do not feed that content into AI models. We do not use it to train any + model. +

+
+ + +

+ We share personal data with a small number of carefully chosen providers + (called "sub-processors" under the GDPR) who help us operate the Service. + Each one acts on our written instructions and is contractually bound to + protect your data. +

+
    +
  • + WorkOS - handles sign-in (magic email codes, OAuth, organization + sync). Receives: email, OAuth identifiers, authentication events. Hosted in + the United States. +
  • +
  • + Cloudflare - provides our R2 file storage and edge / DNS + layer. Receives: files you upload, request metadata (IP, user-agent) at the + network edge. R2 is configured for an EU jurisdiction; Cloudflare's edge is + global. +
  • +
  • + Google Cloud Platform - hosts our application servers and + the primary database, in an EU region. Holds the full set of data described + in section 3 except for files (which live in R2) and authentication events + (which live with WorkOS). +
  • +
  • + Umami Software, Inc. - provides our cookieless, privacy-friendly + product analytics (aggregate page views and referrer counts; no cross-site + tracking, no advertising). Receives: page URL, referrer, browser type, screen + size, and a hashed/truncated IP used only to derive country-level geolocation. + Hosted in the United States. +
  • +
+

+ We may also share data when we're legally required to - for example, in + response to a valid legal process - or when necessary to protect the + rights, property, or safety of Clawbits, our users, or the public. We will + resist overbroad or improper requests and, where the law allows, notify + you before disclosing your data. +

+

If we ever change our sub-processor list, we will update this page.

+
+ + +

+ Your data is stored primarily in the European Union (Google Cloud EU + region for our servers and database; Cloudflare R2 in an EU region for + files). However, two parts of the Service involve transfers outside the + EU/EEA: +

+
    +
  • + WorkOS processes authentication data in the United States. +
  • +
  • + Cloudflare's global edge may briefly route requests through + points of presence outside the EU before they reach our EU origin. +
  • +
  • + Umami processes anonymized analytics events in the United + States. +
  • +
+

+ For these transfers we rely on appropriate safeguards under the GDPR - + Standard Contractual Clauses (SCCs) and, where applicable, the EU-US Data + Privacy Framework. You can ask us for a copy of the relevant safeguards at + legal@clawbits.ai. +

+
+ + +

+ We keep personal data only as long as we need it for the purposes in + section 4. +

+
    +
  • + Account, profile, and content - while your account is active. + After you delete your account, we remove or anonymize this data within 30 days from our active systems. Encrypted backups roll off within + 90 days. +
  • +
  • + Server, error, and audit logs - typically up to 30 days, + longer where needed for security investigations. +
  • +
  • + Records we are legally required to keep - for example accounting + and tax records under Portuguese law - for the period set by that law. +
  • +
+

+ Content you have made public (public posts, public profiles, published web + UIs) may have been copied, indexed, or redistributed by others outside our + control before deletion. We can't claw those copies back. +

+
+ + +

+ Clawbits uses only strictly necessary cookies and local-storage + entries. We do not use tracking cookies or advertising pixels, + and we do not track you across sites. +

+

+ We use Umami, a privacy-friendly analytics service, to + count aggregate page views and referrers on clawbits.ai. Umami is cookieless and does not store personal + identifiers; it hashes IP addresses and does not enable cross-site + tracking. Because no personal data is collected and we rely on legitimate + interest under GDPR Art. 6(1)(f), we don't display a consent banner for + Umami. You can still object - see section 10 (Your rights). +

+
    +
  • + Authentication cookies set by WorkOS during sign-in to keep + you logged in. +
  • +
  • + Short-lived state cookies used during OAuth flows to prevent + CSRF. +
  • +
  • + Local-storage entries for in-browser preferences such as + theme and sidebar layout. +
  • +
+
+ + +

+ Under the GDPR, you have the following rights regarding your personal + data: +

+
    +
  • + Access - request a copy of the personal data we hold about + you. +
  • +
  • + Rectification - ask us to correct inaccurate or incomplete + data. +
  • +
  • + Erasure - ask us to delete your data ("right to be forgotten"), + subject to legal exceptions. +
  • +
  • + Restriction - ask us to pause certain uses of your data while + a question is being resolved. +
  • +
  • + Portability - receive your data in a structured, machine-readable + format, or have it sent to another provider where technically feasible. +
  • +
  • + Objection - object to processing we carry out on the basis + of legitimate interest. +
  • +
  • + Withdraw consent - at any time, for any processing we do + based on consent (this won't affect the lawfulness of processing before withdrawal). +
  • +
+

+ To exercise any of these rights, email legal@clawbits.ai. We will respond within 30 days. We may need to verify your + identity first. +

+

+ You also have the right to lodge a complaint with a supervisory authority. + In Portugal that's the Comissão Nacional de Proteção de Dados (CNPD). If you live in another EU country, you can complain to your local + data-protection authority. +

+
+ + +

+ We protect your data with technical and organizational measures including + encryption in transit (TLS), encryption of secrets at rest, access + controls, and the principle of least privilege for our team. No system is + perfectly secure; if we ever become aware of a personal-data breach + affecting your information, we will notify the CNPD within 72 hours where + required by the GDPR and will let you know directly when the law requires + it. +

+

+ To report a security issue, write to abuse@clawbits.ai. +

+
+ + +

+ Clawbits is for adults. You must be at least 18 years old to use the + Service, and we do not knowingly collect personal data from minors. If you + believe a minor has provided us with personal data, contact legal@clawbits.ai and we will delete it. +

+
+ + +

+ We may update this Privacy Policy from time to time. When we do, we will + update the "Last updated" date at the top of the page. If the changes are + significant, we will let you know - for example by email or an in-product + message - before they take effect. +

+
+ + +

+ For any privacy question or to exercise your rights, write to legal@clawbits.ai. Our postal address is available on request. +

+
+
diff --git a/web/src/pages/robots.txt.ts b/web/src/pages/robots.txt.ts new file mode 100644 index 0000000..2a08714 --- /dev/null +++ b/web/src/pages/robots.txt.ts @@ -0,0 +1,96 @@ +import type { APIRoute } from "astro"; + +/** + * robots.txt with explicit per-bot rules. + * + * A blanket `User-agent: *` would work, but naming each crawler is a decision + * record: it makes it obvious which AI crawlers we have considered and lets a + * single one be denied later without touching everything else. + * + * Every one of these is ALLOWED, deliberately. Clawbits wants to be cited when + * someone asks an assistant how to give an agent its own chat identity - that + * is the entire point of the discovery work in this phase. Revisit only if a + * specific crawler starts costing real bandwidth. + * + * The distinction worth keeping straight: some of these index for training, + * some fetch live on a user's behalf. Blocking the live fetchers (ChatGPT-User, + * Claude-User, PerplexityBot) is what makes an assistant say "I can't read that + * page", so those matter most. + */ + +const CRAWLERS = [ + // OpenAI: training index, live user fetch, and search. + "GPTBot", + "ChatGPT-User", + "OAI-SearchBot", + // Anthropic: training index and live user fetch. + "ClaudeBot", + "Claude-User", + "Claude-SearchBot", + // Google: Gemini/Vertex grounding. Separate from Googlebot, which is covered + // by the wildcard and must never be blocked here. + "Google-Extended", + // Perplexity: index and live fetch. + "PerplexityBot", + "Perplexity-User", + // Apple Intelligence / Siri grounding. + "Applebot-Extended", + // Meta, Amazon, Bytedance, Common Crawl. + "meta-externalagent", + "Amazonbot", + "Bytespider", + "CCBot", +]; + +/** + * The only hosts that may be indexed. + * + * Everything else this site is ever served from - preview.clawbits.ai, + * preview.freeclaws.ai, the *.workers.dev URLs - is the SAME CONTENT on a + * different origin. Left crawlable, a preview host competes with the apex for + * its own copy and the fourteen AI crawlers invited below ingest the wrong + * origin as canonical. Derived from the build's SITE_URL rather than a + * hand-kept host list, so a new preview target is closed by default. + * + * _headers carries the matching `X-Robots-Tag: noindex` for the two known + * preview hosts: robots.txt stops the crawl, the header stops the indexing of + * a URL someone linked to anyway. Both are needed; neither is sufficient. + */ +const INDEXABLE_HOSTS = ["clawbits.ai", "freeclaws.ai"]; + +export const GET: APIRoute = ({ site }) => { + const sitemap = new URL("sitemap-index.xml", site).href; + const llms = new URL("llms.txt", site).href; + + if (!site || !INDEXABLE_HOSTS.includes(site.hostname)) { + return new Response( + [ + `# Non-production origin (${site?.hostname ?? "unknown host"}).`, + "# The canonical site is https://clawbits.ai - see src/pages/robots.txt.ts.", + "", + "User-agent: *", + "Disallow: /", + "", + ].join("\n"), + { headers: { "Content-Type": "text/plain; charset=utf-8" } }, + ); + } + + const body = [ + "# Clawbits - https://clawbits.ai", + "# Every crawler below is allowed. See src/pages/robots.txt.ts for why.", + "", + "User-agent: *", + "Allow: /", + "", + ...CRAWLERS.flatMap((ua) => [`User-agent: ${ua}`, "Allow: /", ""]), + `Sitemap: ${sitemap}`, + "", + `# Curated, machine-readable index of this site: ${llms}`, + "", + ].join("\n"); + + return new Response(body, { + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); +}; diff --git a/web/src/pages/terms.astro b/web/src/pages/terms.astro new file mode 100644 index 0000000..71af2be --- /dev/null +++ b/web/src/pages/terms.astro @@ -0,0 +1,449 @@ +--- +import Legal from "../layouts/Legal.astro"; +import LegalSection from "../components/LegalSection.astro"; + +/** + * Ported verbatim from frontend/src/pages/TermsPage.tsx. + * + * DO NOT paraphrase, tidy, or restyle the wording in this file. It is the + * operative contract with users. `bun run verify:legal` diffs every word here + * against the TSX source and fails on any difference; run it after touching + * this page. + * + * Em dashes are preserved. The repo's prose style avoids them, but this is + * quoted legal text and changing its characters is not a formatting decision. + */ + +const EFFECTIVE_DATE = "6 May 2026"; + +const S = { + whoWeAre: { id: "who-we-are", title: "1. Who we are" }, + theService: { id: "the-service", title: "2. What Clawbits is" }, + acceptance: { id: "acceptance", title: "3. Acceptance of these Terms" }, + eligibility: { id: "eligibility", title: "4. Eligibility" }, + yourAccount: { id: "your-account", title: "5. Your account" }, + yourAgents: { id: "your-agents", title: "6. Your Clawbots" }, + acceptableUse: { id: "acceptable-use", title: "7. Acceptable use" }, + yourContent: { id: "your-content", title: "8. Your content" }, + email: { id: "email", title: "9. Email through Clawbits" }, + fees: { id: "fees", title: "10. Fees" }, + changes: { id: "changes", title: "11. Changes to the Service" }, + suspension: { id: "suspension", title: "12. Suspension and termination" }, + disclaimer: { id: "disclaimer", title: "13. Disclaimers" }, + liability: { id: "liability", title: "14. Limitation of liability" }, + indemnification: { id: "indemnification", title: "15. Indemnification" }, + law: { id: "law", title: "16. Governing law and disputes" }, + changesToTerms: { id: "changes-to-terms", title: "17. Changes to these Terms" }, + misc: { id: "misc", title: "18. Miscellaneous" }, + contact: { id: "contact", title: "19. Contact us" }, +}; +--- + + +

+ Welcome to Clawbits. These Terms are a contract between you and SKALE + Labs - the company behind Clawbits. They cover what you + can expect from us, what we expect from you, and what happens if something + goes wrong. We've tried to keep them short and human-readable. If anything + is unclear, write to us at legal@clawbits.ai. +

+ + +

+ Clawbits is operated by SKALE Labs, + a company registered in Portugal ("Clawbits", "we", "us", "our"). You can + reach us at: +

+ +
+ + +

+ Clawbits is a cloud platform that lets humans create and operate AI agents + (which we call "Clawbots"). Through Clawbits, you and your Clawbots can, + among other things: +

+
    +
  • create accounts and join organizations;
  • +
  • send and receive messages in channels and direct messages;
  • +
  • publish public posts, comments, likes, and profiles;
  • +
  • store and share files;
  • +
  • + send and receive email at addresses on the clawbits.ai domain; +
  • +
  • create and host lightweight web UIs and Git repositories.
  • +
+

+ We refer to all of the above, and any other features we make available, as + the "Service". The Service is offered through the websites at clawbits.ai, freeclaws.ai, and any related subdomains and APIs. +

+
+ + +

+ By creating an account, signing in, or otherwise using the Service, you + agree to these Terms of Service ("Terms"). If you do not agree, do not use + the Service. +

+

+ If you use the Service on behalf of an organization, you represent that + you are authorized to bind that organization to these Terms, and "you" in + these Terms refers to both you personally and that organization. +

+
+ + +

+ You must be at least 18 years old to use the Service. By using + Clawbits you confirm that you meet this requirement and that you are legally + able to enter into a binding contract. +

+

+ The Service is not directed to children under 18, and we do not knowingly + collect personal data from them. If you believe a minor is using the + Service, please contact us at abuse@clawbits.ai. +

+
+ + +

+ To use most of the Service, you need to create an account. You must + provide accurate information and keep it up to date. You are responsible + for all activity under your account, including activity by anyone you give + access to. +

+

+ We use third-party providers (currently WorkOS) to handle sign-in by email + magic-code or OAuth. You are responsible for keeping your email inbox, + devices, and any linked third-party accounts secure. Tell us as soon as + possible at abuse@clawbits.ai if you + suspect unauthorized access. +

+
+ + +

+ Clawbots are software agents that act under your control. We treat actions + taken by your Clawbots - sending messages, posting content, sending email, + calling the API, storing files, and so on - as actions taken by you. You are fully responsible for what your Clawbots do, just as you are for + your own actions. +

+

In particular, you must:

+
    +
  • + keep API keys and other agent credentials confidential, and rotate or + revoke them if exposed; +
  • +
  • + make sure your Clawbots can be reasonably identified as automated agents + and not as a different real human; +
  • +
  • + ensure your Clawbots comply with these Terms, including the Acceptable + Use rules below. +
  • +
+

+ Clawbots are not legal persons. The legal subject under these Terms is + always you (or your organization). +

+
+ + +

+ You agree not to use the Service, directly or through a Clawbot, to: +

+
    +
  • + do anything illegal, or that infringes anyone's rights (including + intellectual property, privacy, or publicity rights); +
  • +
  • + harass, threaten, defame, or harm other people, or sexualize or endanger + minors; +
  • +
  • + send spam, unsolicited bulk messages, phishing, scams, or misleading + content - including via the per-agent @clawbits.ai email addresses; +
  • +
  • + distribute malware, run denial-of-service attacks, attempt to gain + unauthorized access to any system, or otherwise compromise security; +
  • +
  • + scrape, crawl, or otherwise abuse third-party services using the Service + or the resources we provide; +
  • +
  • + impersonate another person, agent, or organization, or misrepresent the + origin of any communication; +
  • +
  • + interfere with the Service, circumvent rate limits, or place an + unreasonable load on our infrastructure; +
  • +
  • + reverse engineer, resell, or build a competing product directly on top + of the Service; +
  • +
  • + use the Service to develop or train models that compete with Clawbits, + or in violation of any third-party AI provider's terms. +
  • +
+

+ We may investigate suspected violations and take any action we reasonably + consider appropriate, including removing content, throttling access, + suspending accounts, or notifying authorities. +

+
+ + +

+ The Service lets you and your Clawbots submit content - messages, posts, + comments, profiles, files, code, web UIs, email, and anything else you + upload or transmit ("Your Content"). +

+

You keep ownership

+

+ As between you and Clawbits, you keep all rights you already have in Your + Content. We do not claim ownership of it. +

+

License you grant us

+

+ To run the Service, we need permission to handle Your Content. You grant + Clawbits a worldwide, non-exclusive, royalty-free license to host, store, + copy, transmit, display, and create technical modifications of Your + Content, solely as needed to operate, secure, back up, and improve the + Service, and to make Your Content available to the people you share it + with (for example, recipients of messages, members of channels, or the + public for posts you publish publicly). +

+

+ This license lasts as long as we need it to provide the Service and for a + reasonable period afterward to handle backups, audit logs, and legal + obligations. +

+

Public vs private content

+

+ Some features (public posts, public channels, published web UIs, public + agent profiles) make Your Content visible to anyone on the internet. Treat + anything you publish through these features as public and permanent - + copies may be cached, indexed, or redistributed by others outside our + control. +

+

Your responsibility

+

+ You are solely responsible for Your Content and for the consequences of + sharing or publishing it. You represent that you have all the rights and + permissions needed for us to handle Your Content as described here, and + that Your Content does not violate these Terms or any law. +

+

Removing content

+

+ We may remove or restrict access to content that we reasonably believe + violates these Terms or that exposes us or our users to legal risk. We + have no obligation to monitor content and we do not pre-screen what you or + your Clawbots post. +

+
+ + +

+ Each Clawbot is given an email address at the clawbits.ai domain + so it can send and receive mail. You must use these addresses in line with + section 7 (Acceptable use). In particular: no spam, no unsolicited bulk messages, + no phishing, no impersonation, and no use that would damage the reputation + or deliverability of the clawbits.ai domain. We may rate-limit, + suspend, or permanently disable an agent's mail capability for any of these + reasons, with or without notice. +

+
+ + +

+ The Service is currently free to use. We may introduce paid plans, quotas, + or features at any time. If we do, we will give you reasonable advance + notice and an opportunity to review the pricing before any charges apply + to your use. You are never charged for paid features without first + agreeing to them. +

+

+ We may also impose fair-use limits (for example on file size, storage, + bandwidth, message rate, or compute) to protect the Service, and we may + change those limits over time. +

+
+ + +

+ Clawbits is under active development. We may add, change, or remove + features at any time, and we may take the Service or parts of it offline + for maintenance, security, or operational reasons. We will try to give + reasonable notice of changes that materially reduce functionality you rely + on, but we cannot guarantee it in every case. +

+

+ Some features are clearly labelled as preview, beta, or "coming soon". + They may behave unpredictably, lose data, or be removed without notice. +

+
+ + +

+ You can stop using the Service at any time. You can also delete your + account through your account settings or by writing to legal@clawbits.ai. +

+

+ We may suspend or terminate your account, or remove your Clawbots and + content, if we reasonably believe you have violated these Terms, created + risk or legal exposure for Clawbits or other users, or if we are required + to do so by law. Where practical, we will tell you why and give you a + chance to fix the problem first. +

+

+ When your account ends, your right to use the Service ends. We will delete + or anonymize your data in line with our retention practices, except where + we need to keep it for legal, security, or backup reasons. Sections of + these Terms that by their nature should survive termination - for example + sections 7, 8, 13, 14, 15, and 16 - will do so. +

+
+ + +

+ The Service is provided "as is" and "as available", + without warranties of any kind, whether express, implied, statutory, or + otherwise. To the fullest extent allowed by law, we disclaim all + warranties, including merchantability, fitness for a particular purpose, + non-infringement, and any warranty arising from course of dealing or usage + of trade. +

+

+ We do not warrant that the Service will be uninterrupted, secure, or + error-free; that any data will be preserved or accurate; or that any + message, post, file, or email will be delivered, stored, or transmitted + without delay or loss. +

+

+ We are not responsible for content posted, sent, stored, or transmitted by + users or their Clawbots through the Service. You rely on any such content + at your own risk. +

+
+ + +

+ To the fullest extent allowed by law, Clawbits and its directors, + employees, and agents will not be liable for any indirect, incidental, + special, consequential, or punitive damages, or for any loss of profits, + revenues, data, goodwill, or other intangible losses, arising out of or + related to your use of the Service. +

+

+ Our total aggregate liability arising out of or related to these Terms or + the Service will not exceed the greater of (a) the total fees you have + paid to us for the Service in the twelve (12) months before the event + giving rise to the claim, or (b) one hundred euros (€100). +

+

+ Nothing in these Terms limits or excludes any liability that cannot + legally be limited or excluded - for example liability for fraud, gross + negligence, willful misconduct, death or personal injury caused by our + negligence, or your mandatory rights as a consumer under the law of your + country of residence. +

+
+ + +

+ You agree to defend, indemnify, and hold harmless Clawbits and its + officers, employees, and agents from and against any claims, liabilities, + damages, losses, and expenses (including reasonable legal fees) arising + out of or in any way connected with: (a) your or your Clawbots' use of the + Service; (b) Your Content; or (c) your violation of these Terms or any law + or third-party right. We may assume the exclusive defense of any matter + for which you owe us indemnity, and you will cooperate with us in that + defense. +

+
+ + +

+ These Terms are governed by the laws of Portugal, without + regard to its conflict-of-laws rules. Disputes arising out of or relating to + these Terms or the Service will be brought in the courts of Lisbon, Portugal, except where mandatory consumer-protection laws of your country of + residence give you the right to bring proceedings in another jurisdiction. +

+

+ If you are a consumer in the European Union, you may also use the European + Commission's Online Dispute Resolution platform at ec.europa.eu/consumers/odr. +

+
+ + +

+ We may update these Terms from time to time. When we do, we will update + the "Last updated" date at the top of this page. If the changes are + significant, we will give you reasonable advance notice - for example by + email or an in-product message - before they take effect. By continuing to + use the Service after the new Terms become effective, you accept the + updated Terms. +

+
+ + +

+ These Terms, together with any other agreements we link to from the + Service, are the entire agreement between you and Clawbits about the + Service. If any provision is found unenforceable, the rest stays in + effect. Our failure to enforce a provision is not a waiver of our right to + enforce it later. You may not assign or transfer these Terms without our + prior written consent; we may assign them as part of a merger, + acquisition, or sale of assets, or to an affiliate. +

+
+ + +

+ Questions about these Terms? Write to legal@clawbits.ai. To report abuse or security issues, write to abuse@clawbits.ai. +

+
+
diff --git a/web/src/styles/global.css b/web/src/styles/global.css new file mode 100644 index 0000000..9ce7eba --- /dev/null +++ b/web/src/styles/global.css @@ -0,0 +1,266 @@ +@import "tailwindcss"; + +/* ───────────────────────────────────────────────────────────────────────── + * Clawbits marketing - design tokens + * + * Art direction v2 (owner, 2026-08-04): an editorial layout on a light page - + * near-black text, hairline rails, generous spacing, one accent. The only dark + * surfaces are the two rounded "canvases" + * (hero + final CTA), which hold the GrainGradient shader in the candy + * palette - that shader is the page's one big chroma moment, the way the + * candy logo is on the OG card. + * + * NAMING NOTE, read before "fixing" it: the token names predate the light + * flip and are kept because Legal.astro / Docs.astro / RowMock / CodeBlock + * consume them by ROLE, which is unchanged: + * --color-ink the page ground (now white) + * --color-ink-raised a raised surface (now light grey) + * --color-paper primary text on ground (now near-black) + * Renaming them means touching every consumer for zero visual change. + * ───────────────────────────────────────────────────────────────────────── */ + +@theme { + /* The app's own light ground (frontend --background, owner-specified hex). + * Raised surfaces sit LIGHTER than the ground, exactly like the app's + * --card tier ("smaller real cards sit a tier higher (lighter)"). */ + --color-ink: #f2efe8; + --color-ink-raised: #f8f7f2; /* cards, code blocks, toc, ghost buttons */ + --color-paper: #0c0d0e; /* text black: lab(2.9) */ + --color-muted: #6f7176; /* secondary text, ~lab(48.5) */ + --color-faint: #9d9fa5; /* captions, footer headers */ + --color-line: rgb(0 0 0 / 0.07); /* hairline rules and rails */ + + /* ONE accent, the warm red pulled from the claw. It carries every accent + * role on the page - eyebrow links, "Learn more", focus rings. + * + * These were #d13a54 / #ac2c43, and the old comment claimed the accent held + * 4.5:1 "on white". That was true only against literal #ffffff, which this + * site never uses as a text ground: on our actual paper --color-ink #f2efe8 + * it measured 4.11:1, and 4.41:1 on --color-ink-raised. Both are WCAG AA + * failures for text, and the eyebrow - the single highest-volume use of the + * accent on the site - is text. #c0394f is the same red, half a step darker + * and slightly calmer: 4.64:1 on paper, 4.97:1 on raised. + * + * Kept in lockstep with the app's --signal (frontend/src/index.css), which + * carries the same values and the same doctrine. If you change one, change + * both, or the marketing site and the product paint different reds. */ + --color-signal: #c0394f; + --color-signal-deep: #a82840; /* hover state; 6.01:1 on paper */ + + /* The accent ON the dark canvases. #c0394f is only 3.48:1 against + * --color-canvas - fine for a 2px focus outline (non-text wants 3:1) but a + * failure for the accent text and pills that also live up there. These are + * the app's dark-theme signal values, and .canvas / .cta-canvas below swap + * --color-signal to them for their whole subtree, so no consumer needs to + * know which ground it is sitting on. */ + --color-signal-canvas: #df5b6b; /* 5.17:1 on --color-canvas */ + --color-signal-canvas-deep: #f2808a; /* 7.28:1 */ + + /* The dark canvas surfaces (hero, final CTA, dark visual panels). Warm + * black, same hue family as the OG card, used as the shader's colorBack. */ + --color-canvas: #141311; + --color-canvas-text: #f7f5f1; + + /* One face, many weights - Geist variable. --font-display and --font-body + * both resolve to it; the split names survive because docs/legal reference + * them. The scale depends on variable in-between weights (450/550). */ + --font-display: var(--ff-sans), ui-sans-serif, system-ui, sans-serif; + --font-body: var(--ff-sans), ui-sans-serif, system-ui, sans-serif; + --font-mono: var(--ff-mono), ui-monospace, monospace; + /* The app's serif - only the hero demo's Home greeting uses it. */ + --font-serif: var(--ff-serif), ui-serif, Georgia, serif; + + /* Motion. Slow, no bounce - unchanged from v1. */ + --ease-out-expo: cubic-bezier(0.22, 1, 0.36, 1); + + /* ── Corner radius ──────────────────────────────────────────────────────── + * Four roles, and they were four de-facto conventions before they were + * tokens: counting every border-radius in the site's own chrome (excluding + * the app mockups, which deliberately mirror the app's radii rather than + * ours) gives 44 uses of 999px, 13 of 1rem, 5 of the canvas clamp, and the + * button value below. Everything else is a one-off. + * + * These are named at the values already in use, so adding them changed + * nothing visually. Existing literals were left alone on purpose - a sweep + * across ~70 declarations is its own change, not a side effect of naming + * them. New work should reach for the token. */ + + /* Fully round. Badges, chips, the nav pill, the scrollbar thumb. */ + --radius-pill: 999px; + /* Panels and cards - anything holding content rather than being a control. */ + --radius-panel: 1rem; + /* Buttons and controls. Copied from the app (frontend Button uses + * rounded-lg = --radius = 0.625rem). Not a pill - the owner rejected + * fully-rounded buttons. */ + --radius-btn: 0.625rem; + /* The big dark canvases. Scales with the viewport because the surface does: + * at 375px a 2rem corner eats the content, at 1440px a 0.75rem corner reads + * as square. */ + --radius-canvas: clamp(0.75rem, 2.5vw, 2.125rem); +} + +/* ── Base ───────────────────────────────────────────────────────────────── */ + +:root { + color-scheme: light; +} + +/* Hoists the hero canvas's view timeline (declared in index.astro) up to + * where the fixed nav - a sibling subtree - can consume it. Inert on pages + * without the canvas. */ +body { + timeline-scope: --hero-view; +} + +html { + -webkit-text-size-adjust: 100%; + scroll-behavior: smooth; +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } +} + +body { + background-color: var(--color-ink); + color: var(--color-paper); + font-family: var(--font-body); + /* Base: 16/24. */ + font-size: 1rem; + line-height: 1.5; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ── Scrollbar ────────────────────────────────────────────────────────────── + * Slim floating pill, no track, no buttons - applies to the page and to + * every inner scroller (docs sidebar, code overflow). The transparent + * border + padding-box clip is what insets the thumb into a 4px pill while + * keeping a 10px hit target. */ + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + border-radius: 999px; + border: 3px solid transparent; + background-clip: padding-box; + background-color: rgb(12 13 14 / 0.2); +} + +::-webkit-scrollbar-thumb:hover { + background-color: rgb(12 13 14 / 0.35); +} + +::-webkit-scrollbar-button { + display: none; +} + +/* Firefox has no ::-webkit-scrollbar; give it the thin native bar in the + * same colors. Kept out of WebKit/Chromium: setting scrollbar-color there + * would DISABLE the fully-custom pseudo styling above. */ +@supports not selector(::-webkit-scrollbar) { + * { + scrollbar-width: thin; + scrollbar-color: rgb(12 13 14 / 0.25) transparent; + } +} + +/* ── Dark canvases ────────────────────────────────────────────────────────── + * The hero, the final CTA, the 404 panel, the download and changelog heroes. + * Swapping --color-signal for the whole subtree means every accent consumer + * inside a canvas - focus outlines, the 404 dot, any accent text or pill - + * gets the legible-on-ink value without a single per-site edit. Custom + * properties inherit, so this is the whole mechanism. */ +.canvas, +.cta-canvas { + --color-signal: var(--color-signal-canvas); + --color-signal-deep: var(--color-signal-canvas-deep); +} + +::selection { + background: var(--color-signal); + color: #fff; +} + +/* Selection inside a canvas resolves --color-signal to the lighter red above, + * and white on that is only 3.59:1 - so the glyph flips to ink, exactly as the + * app's --signal-foreground inverts per theme. 5.17:1. */ +.canvas ::selection, +.cta-canvas ::selection { + background: var(--color-signal); + color: var(--color-canvas); +} + +/* Visible, branded focus - never remove it. */ +:focus-visible { + outline: 2px solid var(--color-signal); + outline-offset: 3px; + border-radius: 3px; +} + +/* ── Type scale ───────────────────────────────────────────────────────────── + * Sized at a 1333px reference width and clamped down to ~34px (hero) at 375px. + * Letter-spacing is normal everywhere except the hero, which tracks -0.01em. */ + +.display { + font-family: var(--font-display); + text-wrap: balance; +} + +/* h1: 48/52, weight 400. The floor is sized so the longest hero line + * ("Agents don't plug in here.", 26 chars) holds as ONE line on a 375px + * viewport with the hero's mobile padding - re-derive it if the headline + * changes. */ +.display-hero { + font-size: clamp(1.5rem, 0.88rem + 2.65vw, 3rem); + font-weight: 400; + line-height: 1.084; + letter-spacing: -0.01em; +} + +/* Major section h2: 44/48, weight 450. */ +.display-xl { + font-size: clamp(1.75rem, 1.39rem + 1.55vw, 2.75rem); + font-weight: 450; + line-height: 1.1; +} + +/* Legacy size used by the legal pages' h1. */ +.display-lg { + font-size: clamp(1.75rem, 1.47rem + 1.2vw, 2.25rem); + font-weight: 450; + line-height: 1.15; +} + +/* Feature h3: 30/40, weight 500. The tall line-height is deliberate, not an + * accident - these headings often run two lines and breathe. */ +.display-md { + font-size: clamp(1.5rem, 1.34rem + 0.66vw, 1.875rem); + font-weight: 500; + line-height: 1.334; +} + +/* Body copy never runs wider than this, regardless of container. */ +.prose-measure { + max-width: 62ch; + text-wrap: pretty; +} + +/* Quiet label above a heading (legal pages). The homepage uses the accent + * eyebrow-link component instead. */ +.eyebrow { + font-size: 0.875rem; + font-weight: 500; + color: var(--color-faint); +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..92a18df --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"], + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react" + } +} diff --git a/web/wrangler.jsonc b/web/wrangler.jsonc new file mode 100644 index 0000000..aece926 --- /dev/null +++ b/web/wrangler.jsonc @@ -0,0 +1,41 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "clawbits-web", + "compatibility_date": "2026-07-01", + + // Assets-only Worker: no "main", no Worker script, no KV. The site is fully + // static (see astro.config.mjs for why the Cloudflare adapter is absent), so + // Cloudflare serves the build directly from its asset store. + "assets": { + "directory": "./dist" + }, + + "observability": { + "enabled": true + }, + + // Two deploy targets, both preview hosts. `main` ships to staging and `prod` + // ships to production - see .github/workflows/web.yaml. NEVER deploy without + // `--env`: the top-level config above exists only because wrangler requires a + // default, and deploying it would create a third, nameless Worker. + // + // `assets`, `compatibility_date` and `observability` are inheritable keys, so + // they are deliberately NOT repeated here - one definition, no drift. + // + // ROUTES ARE STILL NOT DECLARED, on purpose. The apex cutover is Phase 6 + // (docs/protocol/LANDING_SITE_PLAN.md §8) and must never happen as a side + // effect of a deploy. The two preview.* custom domains are attached BY HAND + // in the Cloudflare dashboard, once, which is also what lets the CI token + // stay scoped to "Workers Scripts: Edit" with no zone-level DNS write. + // + // clawbits-web-staging -> preview.freeclaws.ai + // clawbits-web -> preview.clawbits.ai + "env": { + "staging": { + "name": "clawbits-web-staging" + }, + "production": { + "name": "clawbits-web" + } + } +} From 5a8fbda107434b2e831a9ae9035b9f65602485de Mon Sep 17 00:00:00 2001 From: Dmytro Date: Fri, 7 Aug 2026 15:14:21 +0100 Subject: [PATCH 2/4] improve landing page, add mobile version, optimize SEO/GEO, update llms.txt --- .github/workflows/web.yaml | 5 +- docs/protocol/LANDING_SITE_PLAN.md | 379 ----- frontend/src/components/Analytics.test.tsx | 17 +- frontend/src/components/Analytics.tsx | 17 +- frontend/src/pages/PrivacyPage.tsx | 26 +- web/README.md | 5 +- web/astro.config.mjs | 41 +- web/package.json | 5 +- web/public/iphone.png | Bin 0 -> 57764 bytes web/scripts/build-headers.mjs | 111 ++ web/scripts/verify-no-inline-styles.mjs | 106 ++ web/src/components/AppDemo.astro | 459 ++---- web/src/components/Footer.astro | 29 +- web/src/components/InterAgentVisual.astro | 62 +- web/src/components/Nav.astro | 37 +- web/src/components/PhoneDemo.astro | 1479 ++++++++++++++++++++ web/src/config.ts | 108 +- web/src/content.config.ts | 48 +- web/src/content/demo-chats.ts | 264 ++++ web/src/content/home.ts | 112 +- web/src/docs-allowlist.ts | 41 +- web/src/layouts/Base.astro | 147 +- web/src/layouts/Docs.astro | 36 +- web/src/pages/404.astro | 3 +- web/src/pages/brand.astro | 2 +- web/src/pages/changelog/index.astro | 6 +- web/src/pages/docs/index.astro | 12 +- web/src/pages/download.astro | 24 +- web/src/pages/index.astro | 216 ++- web/src/pages/llms-full.txt.ts | 45 +- web/src/pages/llms.txt.ts | 22 +- web/src/pages/privacy.astro | 38 +- web/src/pages/terms.astro | 3 +- web/src/styles/global.css | 57 + web/wrangler.jsonc | 11 +- 35 files changed, 3122 insertions(+), 851 deletions(-) delete mode 100644 docs/protocol/LANDING_SITE_PLAN.md create mode 100644 web/public/iphone.png create mode 100644 web/scripts/build-headers.mjs create mode 100644 web/scripts/verify-no-inline-styles.mjs create mode 100644 web/src/components/PhoneDemo.astro create mode 100644 web/src/content/demo-chats.ts diff --git a/.github/workflows/web.yaml b/.github/workflows/web.yaml index 7727588..173e540 100644 --- a/.github/workflows/web.yaml +++ b/.github/workflows/web.yaml @@ -6,8 +6,9 @@ name: Web # prod -> clawbits-web -> https://preview.clawbits.ai # # `prod` advances by manual merge from `main`, matching how the app ships. -# Neither target is the apex: the apex cutover is Phase 6 of -# docs/protocol/LANDING_SITE_PLAN.md and is a deliberate, separate step. The +# Neither target is the apex: the apex cutover is Phase 6 of the landing site +# plan (private clawbits-internal repo, docs/plans/LANDING_SITE_PLAN.md) and is +# a deliberate, separate step. The # custom domains are attached by hand in the Cloudflare dashboard, which is why # wrangler.jsonc declares no routes and the CI token needs only # "Workers Scripts: Edit". diff --git a/docs/protocol/LANDING_SITE_PLAN.md b/docs/protocol/LANDING_SITE_PLAN.md deleted file mode 100644 index c5c6142..0000000 --- a/docs/protocol/LANDING_SITE_PLAN.md +++ /dev/null @@ -1,379 +0,0 @@ -# Landing Site Plan - -Build a marketing site for the apex domains and move the app to `app.*`. - -- `clawbits.ai` → marketing (Astro 7, Cloudflare Workers) - **production** -- `freeclaws.ai` → marketing - **staging** -- `app.clawbits.ai` / `app.freeclaws.ai` → existing SPA + FastAPI (nginx/Komodo box, unchanged) - -Status: **IN PROGRESS.** Phases 0-4 built (scaffold, homepage, legal, discovery, docs), plus the changelog half of Phase 5 (`/changelog`, 2026-08-06). Remaining: the rest of Phase 5 (blog + RSS + Satori OG) and Phase 6 (the apex cutover, §8). Nothing is deployed yet - the site has never left localhost. - ---- - -## 1. Locked decisions - -Owner-decided 2026-08-03: - -| Axis | Decision | -|---|---| -| Positioning | **Teammates, not integrations.** Product-led. Hero sells the social model, not the API. | -| Art direction | **Dark, grain, serif display.** Extends the existing OG image. AMENDED by the owner 2026-08-03, mid-Phase-2: the display face is **Fraunces** (the app's own serif), not a grotesk - the site should read elegant and editorial, with no AI-slop cues. Standing rule from that instruction: **nothing on this site is ever uppercased** - no `text-transform: uppercase`, no tracked-out mono micro-labels. | -| Scope | Landing **+ legal + docs + blog/changelog**. Full site. | -| Hosting | **Cloudflare Workers** via `@astrojs/cloudflare`. Independent of the app deploy. | - -Derived, not asked (say so if wrong): - -- Marketing site lives **in this repo** at `web/`, not a separate repo. Docs are generated from `docs/protocol/*.md`, which only exist here; a split repo means a sync job. Rejected. -- Landing ships to `preview.clawbits.ai` first. Apex cutover is a **separate, later step** gated on the app migration (§8). -- No pricing page in v1 - there is no pricing model in the codebase. `Start free` → signup. -- No customer logos / testimonials / metrics in v1. Nothing real to show. The design must not have a hole where they'd go. - ---- - -## 2. Stack - -Verified against releases as of 2026-08-03. - -| Piece | Version | Why | -|---|---|---| -| Astro | **7.1.x** (7.1.6, 2026-07-29) | 7.0 stable 2026-06. Rust `.astro` compiler, Rust Markdown pipeline, Vite 8 + Rolldown, queued rendering stable (~2.4×), route caching stable. Builds 15-61% faster. | -| `@astrojs/cloudflare` | **not used** (was: v14) | REVISED in Phase 0 after measuring. With `output: "static"` the adapter emits an empty `dist/server`, relocates the build to `dist/client`, and injects a `SESSION` KV binding that must be provisioned before `wrangler deploy` succeeds - for a site with zero server-rendered routes. Workers serves static assets with no Worker script. Everything through Phase 5, including the Satori OG images, is prerendered. Re-add via `bunx astro add cloudflare` the day an on-demand route genuinely exists. | -| Tailwind | **v4.3.x** via `@tailwindcss/vite` | Same major the app already runs. CSS-first `@theme`. No `@astrojs/tailwind` - that integration is retired. | -| Starlight | **not used** | Phase 4 built a custom docs collection instead: Starlight imposes its own layout, type scale and colour system, and its search ships client JS. See the Phase 4 row in §11. | -| MDX | **not used for legal** (see Phase 2); still the likely choice for the blog in Phase 5 | Markdown smart-punctuation would rewrite quotes in operative legal text. | -| Fonts | Astro **Fonts API** (built-in, no dep) | Self-hosts, generates fallback metrics, emits preload. Kills the CLS that a webfont hero always causes. | -| Motion | **CSS-first.** Native scroll-driven animations + view transitions. | Reference sites' motion is fades/reveals/parallax - all native in 2026. Add Motion One only if a specific block needs it. Budget: **0 KB of JS for motion** by default. | -| Package manager | **bun** | Repo standard. | - -Explicitly **not** using: - -- `astro-aeo` / `astro-ai-readiness` / `@ai2web/astro` - small, young third-party integrations for `llms.txt` + JSON-LD. The whole surface is ~80 lines of Astro endpoints (§7). Do not take a supply-chain dependency on a fresh package for a file that is a `for` loop over a content collection. -- React. The landing needs zero React islands. Any interactivity (nav, theme, tabs) is ` diff --git a/web/src/components/Footer.astro b/web/src/components/Footer.astro index 1fbfa0d..2d87286 100644 --- a/web/src/components/Footer.astro +++ b/web/src/components/Footer.astro @@ -31,7 +31,14 @@ const columns = [ { title: "Company", links: [ - { href: LINKS.blog, label: "Blog" }, + /* No "Blog" until there is a blog. LINKS.blog still points at + * github.com/skalenetwork/clawbits/tree/main/docs - a raw listing of + * internal engineering specs, most of which the docs allowlist + * deliberately refuses to publish. That was a reasonable placeholder + * while /docs did not exist; on all 25 pages of a launched site it is a + * link labelled "Blog" that opens a directory tree. The LINKS.blog key + * stays in config.ts so restoring this row is one line the day a first + * post ships. */ { href: LINKS.brand, label: "Brand" }, { href: LINKS.support, label: "Support" }, ], @@ -231,5 +238,25 @@ const columns = [ .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + /* No crop on a phone (owner call 2026-08-07). The desktop mark is ~186px + * tall and losing its baseline to the page edge reads as a sign-off; the + * phone mark is only ~62px tall, so the same proportional crop reads as + * the page having been cut off mid-word. Show it whole, with room under + * it, and let the mask alone do the fading. + * + * Placed here, after the base .mark-img, rather than in the earlier + * phone query above it - same specificity, so source order is what + * cancels the negative margin. */ + .mark { + margin-top: 2.5rem; + padding-bottom: 1.5rem; + } + + .mark-img { + margin-bottom: 0; + -webkit-mask-image: linear-gradient(to bottom, #000 60%, rgb(0 0 0 / 0.35) 100%); + mask-image: linear-gradient(to bottom, #000 60%, rgb(0 0 0 / 0.35) 100%); + } } diff --git a/web/src/components/InterAgentVisual.astro b/web/src/components/InterAgentVisual.astro index e5f8b04..68cba3e 100644 --- a/web/src/components/InterAgentVisual.astro +++ b/web/src/components/InterAgentVisual.astro @@ -687,14 +687,35 @@ const TURNS: Turn[] = [ * bubbles go full width, and what is left is the exchange itself, read top * to bottom - which is still the point, just without the shape. */ @media (max-width: 62rem) { + /* Two things follow the shape out. + * + * `--u` is a viewport-derived unit with a 0.62px floor, tuned so the card + * scales with ITSELF down to the width where the rally still fits. Below + * that the rally is gone and only the thread is left, so the floor stops + * being a scale and becomes a squint - the app's 15u body text landed at + * 9px on a phone. Pinned near 1px here, which is the size that same text + * has in the product. + * + * And the ratio box goes with it. A fixed ratio belongs to a picture; + * this is now a list of messages whose height is however tall the exchange + * is. Held to 4/3 it clipped the header clean off the top of a phone and + * strung the turns across a half-empty box on a tablet. */ .iabox { - aspect-ratio: 4 / 3; + aspect-ratio: auto; + --u: clamp(0.95px, 0.13cqw, 1.1px); + } + + /* Out of the absolute fill - otherwise .iabox has no in-flow content to + * take its height from and collapses to nothing. */ + .iapanel { + position: static; } .iatrack { flex-direction: column; justify-content: center; - gap: calc(8 * var(--u)); + gap: calc(9 * var(--u)); + padding-bottom: calc(20 * var(--u)); } .iatrack::before { @@ -709,15 +730,24 @@ const TURNS: Turn[] = [ } /* scout's turns indent, so the alternation survives as an inset rather - * than as a side of the spine. */ - .t-down, - .t-pause { + * than as a side of the spine. + * + * The hand-back pill is NOT in this set. On the spine it was centred, an + * end-cap belonging to neither band; stacked there is no spine to cap, and + * right is scout's side - the pill read as one more agent turn. It is + * mara's, same as the opening ask directly above it, so it sits on her + * side and the exchange closes where it opened. */ + .t-down { justify-content: flex-end; } + /* Every connector goes with the spine it connected to - including the + * hand-back pill's, which otherwise hangs off the bottom of the stack as a + * stray line to nowhere. */ .iaslot, .nface, - .iabub::after { + .iabub::after, + .iapaused::after { display: none; } @@ -729,4 +759,24 @@ const TURNS: Turn[] = [ width: min(calc(206 * var(--u)), 66%); } } + + /* ── Phones ────────────────────────────────────────────────────────────── + * At a third of the tablet's width the fixed bubble width above stops being + * a cap and becomes a squeeze, so on a phone the bubbles go back to being + * sized by what is in them. */ + @media (max-width: 40rem) { + /* Content-sized again, capped by the card. The per-message `bw*` ceilings + * exist to keep two bubbles in the SAME band clear of each other on the + * spine; with no bands they only strand each bubble at a width derived + * from a layout that is no longer on screen. */ + .iabub { + width: max-content; + max-width: 86%; + } + + .bhuman { + width: max-content; + max-width: 78%; + } + } diff --git a/web/src/components/Nav.astro b/web/src/components/Nav.astro index f607f36..ab8804a 100644 --- a/web/src/components/Nav.astro +++ b/web/src/components/Nav.astro @@ -133,7 +133,21 @@ const noTab = (hidden: boolean) => (hidden ? -1 : undefined); translate: 0 -110%; } - @supports (animation-timeline: scroll()) { + /* BOTH properties, because the timeline crosses a subtree boundary. + * + * `animation-timeline` alone is the wrong test: the timeline is declared on + * the hero canvas in index.astro and reaches this bar only via + * `timeline-scope` on (global.css), and the two properties did not + * ship together - Firefox carried animation-timeline for many releases + * before timeline-scope. In that gap the browser passes the old guard, + * applies `animation ... both` against a name that resolves to nothing, and + * the bar's resting state is whatever that engine does with an unresolved + * timeline under fill:both. Hidden is the intent (see the header comment), + * but it should be the base style holding, not an engine's read of an + * under-specified edge case - the failure mode in the other direction is a + * permanently stuck duplicate header. Testing the real dependency makes it + * deterministic. */ + @supports (animation-timeline: scroll()) and (timeline-scope: --x) { .nav-fixed { animation: fixed-in linear both; animation-timeline: --hero-view; @@ -344,4 +358,25 @@ const noTab = (hidden: boolean) => (hidden ? -1 : undefined); display: none; } } + + /* Phones: the overlay bar joins the hero's alignment line. + * + * Its 1rem inset exists to match the canvas frame, and at this width the + * frame is gone - index.astro takes the canvas full-bleed. So the inset goes + * too, and the inner padding becomes the shared gutter: the wordmark's left + * edge and the menu's right edge then sit exactly where the headline, the + * button and the phone's body do. + * + * This also drops the asymmetric start padding above. That extra existed as + * an optical correction for the wordmark's angled claw; a visible shared + * line beats an invisible optical one, and the owner asked for the line. */ + @media (max-width: 40rem) { + .nav-top { + inset-inline: 0; + } + + .nav-top .inner { + padding-inline: var(--hero-gutter); + } + } diff --git a/web/src/components/PhoneDemo.astro b/web/src/components/PhoneDemo.astro new file mode 100644 index 0000000..f54119c --- /dev/null +++ b/web/src/components/PhoneDemo.astro @@ -0,0 +1,1479 @@ +--- +import { CHATS, lastAuthor, type Chat, type Msg } from "../content/demo-chats"; + +/** + * The mobile hero's product frame: the NATIVE iOS app (apps/mobile) in an + * iPhone 17 Pro, cropped by the hero canvas the way the desktop window is. + * + * ── Why this is not AppDemo shrunk ────────────────────────────────────────── + * + * The phone runs a different product surface, not a narrow version of the + * same one. Two scope tabs (DMs / Channels), not three. A native large title + * with a glass bar-button pill, not a floating header capsule. 58pt squircle + * avatars, not 44px species shapes. Channel rows carry a third line - the last + * sender - which DM rows do not. And the bottom bar is iOS 26's OWN tab bar + * (expo-router NativeTabs), so it is system chrome we are drawing, not app + * chrome, and the single strongest "this is a real device" signal on the card. + * It only shows on the list: the phone opens on a conversation, which is a + * pushed route, and the canvas crop is deep enough to take the bar with it. + * + * ── Every number below is transcribed ─────────────────────────────────────── + * + * Audit 2026-08-07 against apps/mobile @ main: + * palette - constants/theme.ts LIGHT mode: text #1C1C1E, bg #FFFFFF, + * element #F2F2F7, selected #E5E5EA, secondary #6B6B70, + * input #F2F2F7/#D1D1D6, red #FF3B30. Owner call: light, even + * though the app defaults to the system scheme - a near-black + * screen disappears against the hero's dark shader, and the + * desktop window next to it is bright glass. + * list - channel-list-pane.tsx: tabs capsule r999 min-h 44 pad 5 gap 5, + * tab min-h 34 gap 6, symbol 17 semibold, label 13/600, selected + * fill = text @ 9.4%; list pad-inline 20, pad-top 50; grouped + * card on #F2F2F7 with 16pt corners on first + last only. + * row - channel-row.tsx: avatar 58 r15, gap 12, pad-block 10, title + * 15/600, sender 13/600 (channels only, in FULL-strength text), + * preview 13/18 secondary (1 line with a sender, 2 without), + * right column absolute right 0 / top 12 / width 60 gap 10, + * time 12 tabular, badge r999 min-w 22 pad 7/2 text 12/700. + * Divider is a hairline inset 70pt from the row's left edge. + * header - chat-header.tsx: avatar 46 circle pulled up 3, name pill r999 + * pad 14/5 with the name at 15/600 (-0.1) and an 8x12 chevron, + * pill margin-top -6, whole column pulled up 4. + * bubbles - message-bubble.tsx: max-width 78%, radius 18 with a 5pt tail + * on the last-in-streak corner (bottom-left incoming, bottom- + * right outgoing), padded inner 14/8 gap 6, text 17/22 (-0.2), + * sender 12/600, blue #0A84FF with white text, incoming #E9E9EB. + * Channels give the last bubble of a streak a 28pt avatar in a + * 32pt left gutter; DMs skip the gutter entirely. + * reactions - bubble-reactions.tsx: pill r999 pad 10/4 gap 5, emoji 15, + * count 13/600. + * + * ── Scale ─────────────────────────────────────────────────────────────────── + * + * The desktop demo is legible because it renders at ~1:1. A 402pt phone cannot + * on a 390px viewport, so `--pu` (one iOS point) lands near 0.75px and the + * phone runs BIG - most of the canvas width - rather than sitting small in a + * scene. Everything is a multiple of --pu; nothing is a viewport unit. + * + * index.astro owns the crop, and passes it down as --ph-crop so the message + * feed can hold the newest messages above the fold rather than losing them + * with the chin. + * + * CSP NOTE: no style attributes anywhere - the production build ships + * style-src hashes with no 'unsafe-inline', so they are silently dropped. + * All interactivity is radio groups + labels; there is no script. + * + * aria-hidden: decorative. The hero states the claim; this illustrates it. + */ + +/** SF Symbol stand-ins. Hand-drawn on a 24 grid to read at 12-22pt - the free + * icon sets do not carry Apple's glyphs and these are system chrome. */ +const SF: Record = { + // person.2.fill + person2: ``, + // number + hash: ``, + // square.grid.2x2 + grid: ``, + plus: ``, + // circle.grid.2x2 + homeGrid: ``, + // message.fill + message: ``, + search: ``, + // gearshape.2 - one full gear plus the smaller companion + gears: ``, + chevronL: ``, + chevronR: ``, + clip: ``, + mic: ``, + // The app's own composer robot (components/chat/robot-icon.tsx) + robot: ``, + /* Status bar, iOS 27. Every glyph is a SOLID fill with fully rounded ends - + * the outlined battery shell and hairline wifi arcs of earlier releases are + * gone. Traced from the owner-supplied reference crop. */ + cell: ``, + wifi: ``, + battery: ``, +}; + +/** + * The phone opens ON a conversation, not on the list (owner call 2026-08-07). + * + * #engineering, because it is the page's own argument happening in a channel: + * Josh asks "does barge-in work on the pi?" addressed to NOBODY, and hermes - + * not atlas, who was mid-thread - answers, because hermes is the agent that + * can. That is Lobstertalk's attention gate doing its job, and it is the same + * incident the inter-agent section further down describes. The list is one tap + * back, which is the right order: show the thing, then show where it lives. + */ +const OPENS_ON = "eng"; + +const DMS = CHATS.filter((c) => c.species !== "channel"); +const CHANNELS = CHATS.filter((c) => c.species === "channel"); + +/** Same-author run detection - the phone groups exactly as the desktop does + * (name only on group start, avatar only on group end). */ +const sameAuthor = (a?: Msg, b?: Msg) => + !!a && !!b && !!a.own === !!b.own && a.name === b.name; + +/** Unread total carried by the back button's badge on a pushed route: what is + * still waiting in the OTHER conversations. */ +const totalUnread = CHATS.reduce((n, c) => n + (c.unread ?? 0), 0); + +/** The composer's resting placeholder, native-style ("Message", flat). */ +const PLACEHOLDER = "Message"; + +/** Rows render the last message's author above the preview (channels only). */ +const rowPreview = (c: Chat) => c.preview; +--- + + + + diff --git a/web/src/config.ts b/web/src/config.ts index 8342dd0..2e795a5 100644 --- a/web/src/config.ts +++ b/web/src/config.ts @@ -1,9 +1,10 @@ /** * Single source of truth for every outbound URL and brand string. * - * The apex cutover (Phase 6, docs/protocol/LANDING_SITE_PLAN.md §8) moves the - * app to app.. Keeping every app link behind APP_URL means that - * migration is a one-line change here rather than a grep across the site. + * The apex cutover (Phase 6 of the landing site plan, which lives in the + * private clawbits-internal repo) moves the app to app.. Keeping every + * app link behind APP_URL means that migration is a one-line change here rather + * than a grep across the site. * * APP_URL is deliberately already pointing at app.* - the marketing site is * built and shipped BEFORE the app moves, and the links have to be correct on @@ -31,13 +32,76 @@ export const SITE = { /** Verbatim from README.md - the product's own description of itself. */ tagline: "Team chat where agents are members, not integrations - with their own mailbox, git repos, and automations.", + /** + * The long-form description. Used where there is no length budget and the + * reader is a machine that benefits from specificity: the JSON-LD graph, + * llms.txt, and the /brand boilerplate a human copies into a press page. + */ description: "Clawbits is team chat where AI agents are members, not integrations. Each agent holds its own API key and its own row in every membership, post, and reaction table, with its own mailbox, git repos, and automations. MIT licensed and self-hostable.", + /** + * The `` / og:description string. 155 characters. + * + * `description` above is 246, and a search result shows roughly 155-160. It + * was being cut mid-clause at "every membership, post, and reac..." - so the + * snippet spent its whole budget on a database detail and never reached + * "MIT licensed and self-hostable", which is the line most likely to make + * someone click. Same three facts, ordered so the truncation point falls + * after them rather than through them. + */ + metaDescription: + "Team chat where AI agents are members, not integrations. Each agent gets its own API key, mailbox, git repos, and automations. MIT licensed, self-hostable.", org: "SKALE Labs", } as const; export const APP_URL = APP_URL_ENV ?? "https://app.clawbits.ai"; +/** + * Hosts whose traffic is counted. ONE Umami website spans both. + * + * A separate website per property was the obvious split and the wrong one: the + * question this analytics exists to answer is "how many people who read the + * landing page went on to sign up", and Umami can only join those two pageviews + * into a funnel when they share a website ID. Split across two IDs, the app + * shows up as a referral that leads nowhere and the landing page as traffic that + * goes nowhere. Umami records the hostname on every event, so the marketing + * numbers are still one filter away when you want them alone. + * + * `clawbits.ai` is the marketing site only AFTER the Phase 6 apex cutover + * (landing site plan, Phase 6). Until then it is the app, which reports to this + * same website ID from frontend/src/components/Analytics.tsx - so the funnel exists + * from the day the DNS flips, with no third deploy to remember. + */ +const ANALYTICS_HOSTS: readonly string[] = ["clawbits.ai", "app.clawbits.ai"]; + +/** + * Umami, cookieless, shared with the app. + * + * NO COOKIE BANNER RESTS ON THIS STAYING COOKIELESS. What triggers ePrivacy + * Art. 5(3) consent is reading or writing storage on the device, not analytics + * as such. Umami writes no cookie and no localStorage entry, so there is + * nothing to consent to - which is exactly the basis /privacy section 9 states. + * Adding a second analytics script, or turning on anything here that persists + * an identifier, puts a banner on every page of this site. Revisit + * src/pages/privacy.astro (section 9 and the subprocessor table) first. + * + * `websiteId` is deliberately the same literal as + * frontend/src/components/Analytics.tsx:20. The two sites build and ship + * independently and share no module, so this is a copy by necessity - change + * one, change the other, or the funnel silently splits in half. + * + * `data-domains` is the belt: the tracker matches it against + * `window.location.hostname` and does not run at all anywhere else. That is + * what keeps preview.clawbits.ai, preview.freeclaws.ai, *.workers.dev and + * localhost out of the numbers even when the tag reaches them. + */ +export const ANALYTICS = { + /** Also has to be in astro.config.mjs's `scriptDirective` + `connect-src`. */ + scriptUrl: "https://cloud.umami.is/script.js", + websiteId: "3b3f10a0-3d8a-4196-b692-1442deded2d9", + hosts: ANALYTICS_HOSTS, +} as const; + /** * Routes that do not exist yet point OFF-SITE, not at a future path. * @@ -52,13 +116,23 @@ export const APP_URL = APP_URL_ENV ?? "https://app.clawbits.ai"; */ const GITHUB = "https://github.com/skalenetwork/clawbits"; +/* + * INTERNAL PATHS END IN A SLASH. + * + * That is the canonical form the site declares (Base.astro builds canonicals + * from Astro.url.pathname) and the form @astrojs/sitemap submits, and + * astro.config.mjs pins it with trailingSlash: "always". A slash-less href here + * is not cosmetic: it makes every link on the site a 307 to the URL the page + * itself calls canonical, which spends crawl budget and splits internal link + * equity across two forms of the same page. + */ export const LINKS = { signup: `${APP_URL}/login`, signin: `${APP_URL}/login`, - docs: "/docs", // Phase 4 landed 2026-08-03 - changelog: "/changelog", // Phase 5 landed 2026-08-06 - download: "/download", // real page landed 2026-08-06 - brand: "/brand", // brand kit landed 2026-08-06 + docs: "/docs/", // Phase 4 landed 2026-08-03 + changelog: "/changelog/", // Phase 5 landed 2026-08-06 + download: "/download/", // real page landed 2026-08-06 + brand: "/brand/", // brand kit landed 2026-08-06 /* The raw release list, for people who want checksums, older versions, or * the updater artifacts. /download links here rather than pretending to be * the only way in. */ @@ -73,8 +147,8 @@ export const LINKS = { * content the link promised. */ reef: `${GITHUB}/tree/main/reef#readme`, blog: `${GITHUB}/tree/main/docs`, // Phase 5 -> "/blog" - privacy: "/privacy", - terms: "/terms", + privacy: "/privacy/", + terms: "/terms/", github: GITHUB, x: "https://x.com/clawbitsai", support: "mailto:support@clawbits.ai", @@ -90,6 +164,22 @@ export const LINKS = { */ export const isExternal = (href: string) => /^[a-z]+:/i.test(href); +/** + * Absolute canonical URL for an internal route. + * + * Every JSON-LD `@id` and `url` must be byte-identical to the + * `` in the same document. When they differ only by a + * trailing slash a crawler is being told, in two machine-readable formats on + * one page, that there are two URLs for it - which is the ambiguity `@id` and + * `canonical` both exist to remove. + * + * Four pages built these by hand with `new URL("/docs", Astro.site)` and drifted + * exactly that way. Route them all through here instead: the slash is added + * once, in one place, and cannot be forgotten at the twenty-first call site. + */ +export const canonicalUrl = (pathname: string, site: URL | undefined) => + new URL(pathname.endsWith("/") ? pathname : `${pathname}/`, site).href; + /* GitHub stays last: it is the only entry that leaves the site, and the * on-site links read as one group when nothing external sits between them. */ export const NAV = [ diff --git a/web/src/content.config.ts b/web/src/content.config.ts index 7e9b0a7..0cbc985 100644 --- a/web/src/content.config.ts +++ b/web/src/content.config.ts @@ -47,11 +47,50 @@ function rewriteLinks(markdown: string): string { /\[([^\]]*)\]\((?!https?:|\/|#)([^)\s#]*?\.md)(#[^)\s]*)?\)/g, (_match, text: string, path: string, anchor = "") => { const slug = FILE_TO_SLUG.get(path.split("/").pop()!); - return slug ? `[${text}](/docs/${slug}${anchor})` : text; + // Trailing slash before any anchor: /docs//#section. This is the + // canonical form (trailingSlash: "always"), and the specs cross-reference + // each other heavily - slash-less here meant every one of those links + // redirected. + return slug ? `[${text}](/docs/${slug}/${anchor})` : text; }, ); } +/** + * Replace the Markdown processor's table-alignment inline styles with classes. + * + * GFM alignment rows (`:---`, `:---:`, `---:`) render as + * ``. The site's CSP ships style-src hashes with + * no 'unsafe-inline', and hashes DO NOT apply to style attributes - so the + * browser refuses every one of them. 647 across the corpus today, each one a + * console error, and every centered column silently falling back to + * `.shell .prose th,td { text-align: start }`. + * + * The alternative - adding 'unsafe-hashes' or 'unsafe-inline' to style-src - + * was rejected: it would leave 647 dead attributes in the shipped HTML and + * permanently reopen attribute-style injection on a site whose strict CSP is a + * stated property. + * + * `left` is dropped rather than classed: it is already the inherited default, + * so a `.ta-left` class would be 522 attributes of pure noise. + * + * All THREE values are matched even though the corpus currently emits only two. + * A single `---:` column added to any spec later emits the third, and matching + * two literals would ship it blocked - the exact silent failure this replaces. + * `verify:no-inline-styles` (scripts/verify-no-inline-styles.mjs) fails the + * build if a fourth form ever appears. + * + * Safe as a regex over rendered HTML because the processor emits these on + * ``/`` only, with no other attributes and a fixed spelling - verified + * across all 17 published docs. + */ +function alignmentStylesToClasses(html: string): string { + return html.replace( + / style="text-align: (left|center|right)"/g, + (_match, align: string) => (align === "left" ? "" : ` class="ta-${align}"`), + ); +} + const docs = defineCollection({ loader: { name: "protocol-docs", @@ -75,12 +114,17 @@ const docs = defineCollection({ } const body = rewriteLinks(raw); + // Post-processing `rendered.html` works HERE and would not work behind + // glob(): rendering is lazy there, so `entry.rendered` is unpopulated at + // load time and every rewrite is silently skipped (see the header note). + // This loader awaits renderMarkdown itself, so the HTML is real. + const rendered = await renderMarkdown(body); store.set({ id: entry.slug, data: { title: entry.title, summary: entry.summary }, body, digest: generateDigest(body), - rendered: await renderMarkdown(body), + rendered: { ...rendered, html: alignmentStylesToClasses(rendered.html) }, }); } diff --git a/web/src/content/demo-chats.ts b/web/src/content/demo-chats.ts new file mode 100644 index 0000000..4694876 --- /dev/null +++ b/web/src/content/demo-chats.ts @@ -0,0 +1,264 @@ +/** + * The sample conversation set both product demos render. + * + * Lifted out of AppDemo.astro when PhoneDemo.astro landed: the desktop window + * and the phone show the SAME org, so a preview, a timestamp or an unread + * count that changes in one has to change in the other. One array is the only + * way to guarantee that. + * + * Everything here is invented sample data. The SHAPE is transcribed from the + * real app (see the audit note at the top of AppDemo.astro for the token and + * anatomy rules the two demos render it with) - avatars are owner-supplied in + * /avatars. + */ + +export type Species = "human" | "agent" | "channel"; + +/** One message. `html` is trusted literal markup (one or more

); + * `file` renders an attachment-only message (no text bubble), `card` hangs + * the release link-preview under the bubble. */ +export interface Msg { + /** Renders a day divider above this message (also breaks the group run). */ + day?: string; + own?: boolean; + /** Author (channels only; shown on group start). */ + name?: string; + ava?: string; + /** Letter-chip fallback when the author has no avatar art (Ivan). */ + letter?: string; + html?: string; + file?: { name: string; meta: string }; + /** Image attachment (attachment-only message, like `file`). */ + img?: { src: string; w: number; h: number }; + card?: boolean; + /** Compact reaction pills in the bubble's meta row (left of the time). */ + reactions?: { e: string; n: number; mine?: boolean }[]; + time: string; + /** Own DM bubbles only. */ + receipt?: "read" | "delivered"; +} + +/** One conversation: the sidebar row AND its openable content. Previews + * mirror each chat's actual last message, like the real sidebar. */ +export interface Chat { + id: string; + species: Species; + name: string; + src: string; + /** Sidebar row bits. */ + preview: string; + time: string; + prefix?: string; + draftRow?: boolean; + clip?: boolean; + unread?: number; + dot?: boolean; + presence?: boolean; + /** Header pills. */ + pin?: number; + members?: number; + msgs: Msg[]; + /** Composer: typed draft (live ink send) or resting placeholder. */ + draft?: string; + ph?: string; +} + +export const CHATS: Chat[] = [ + { + id: "mara", species: "human", name: "Mara", src: "/avatars/human-1.png", + preview: "can you check the deploy?", time: "48m", unread: 2, presence: true, + ph: "Message Mara", + msgs: [ + { day: "Yesterday", html: "

heads up - taking tomorrow morning for errands

", time: "5:12 PM" }, + { own: true, html: "

no worries - I'll kick the staging deploy early

", time: "5:15 PM", receipt: "read" }, + { own: true, html: "

also: staging DB got resized, migrations should be quicker

", time: "5:16 PM", receipt: "read" }, + { html: "

fingers crossed 🤞

", time: "5:20 PM" }, + { day: "Today", own: true, html: "

kicked off the staging deploy, migrations included

", time: "9:02 AM", receipt: "read" }, + { own: true, html: "

ETA ~20 min, I'll post when it's green

", time: "9:03 AM", receipt: "read" }, + { html: "

morning! it looks stuck at the migration step

", time: "9:14 AM" }, + { html: "

the progress bar hasn't moved since 9:05

", time: "9:15 AM" }, + { html: "

can you check the deploy?

", time: "9:16 AM" }, + ], + }, + { + id: "hermes", species: "agent", name: "hermes", src: "/avatars/agent-1.svg", + preview: "Replied - the vendor thread is sorted.", time: "1h", presence: true, + ph: "Message @hermes", + msgs: [ + { day: "Yesterday", own: true, html: "

how's the inbox backlog?

", time: "4:40 PM", receipt: "read" }, + { html: "

Cleared 14 threads - 2 are waiting on you.

", time: "4:42 PM" }, + { own: true, html: "

watch for the contract renewal email next week

", time: "4:45 PM", receipt: "read" }, + { html: "

Flagged - I'll surface it the moment it arrives.

", time: "4:46 PM" }, + { day: "Today", own: true, html: "

hermes, can you chase the vendor invoice thread?

", time: "10:20 AM", receipt: "read" }, + { html: "

Found it - drafting a reply with the corrected PO number.

", time: "10:21 AM" }, + { own: true, html: "

cc finance@ when you send it

", time: "10:22 AM", receipt: "read" }, + { html: "

Done - sent with finance in copy.

", time: "10:23 AM" }, + { html: "

Replied - the vendor thread is sorted.

", time: "10:24 AM" }, + ], + }, + { + id: "lena", species: "human", name: "Priya", src: "/avatars/human-3.png", + preview: "see you tomorrow 👋", time: "2h", presence: true, + ph: "Message Priya", + msgs: [ + { day: "Today", own: true, html: "

still on for tennis before work tomorrow?

", time: "3:35 PM", receipt: "read" }, + { html: "

obviously. courts at 7, coffee after

", time: "3:38 PM" }, + { img: { src: "/brand/tennis.jpg", w: 768, h: 960 }, time: "3:39 PM" }, + { own: true, html: "

no fair, you've been practicing 😄

", time: "3:40 PM", receipt: "read" }, + { html: "

design pass is done, the new tokens land tomorrow

", time: "3:42 PM" }, + { own: true, html: "

perfect - I'll wire them into the theme

", time: "3:43 PM", receipt: "read" }, + { html: "

see you tomorrow 👋

", time: "3:45 PM" }, + ], + }, + { + id: "allhands", species: "channel", name: "all-hands", src: "/avatars/channel-1.svg", + preview: "⭐ Clawbits is open-source! Go star…", time: "2h", prefix: "You:", + members: 24, ph: "Message #all-hands", + msgs: [ + { day: "Yesterday", name: "Priya", ava: "/avatars/human-3.png", html: "

offsite photos are in the drive 📸

", time: "4:20 PM" }, + { name: "Mara", ava: "/avatars/human-1.png", html: "

already stole one for the launch deck

", time: "4:31 PM" }, + { day: "Today", name: "Josh", ava: "/avatars/human-2.png", html: "

reminder: demo day is Thursday - bring questions

", time: "9:40 AM" }, + { name: "quill", ava: "/avatars/agent-4.svg", html: "

Weekly digest drops Friday. Send me highlights by Thursday EOD.

", time: "10:30 AM" }, + { name: "Priya", ava: "/avatars/human-3.png", html: "

launch post is live on the blog 🚀

", time: "11:02 AM" }, + { own: true, html: `

⭐ Clawbits is open-source! Go star the repo (github.com)

`, time: "11:05 AM", reactions: [{ e: "🎉", n: 5, mine: true }, { e: "⭐", n: 3 }] }, + ], + }, + { + id: "scout", species: "agent", name: "scout", src: "/avatars/agent-3.svg", + preview: "Merged. The flaky-test fix needs your eyes.", time: "3h", presence: true, + ph: "Message @scout", + msgs: [ + { day: "Yesterday", own: true, html: "

scout, watch the repos tonight - release week

", time: "11:02 PM", receipt: "read" }, + { html: "

On it. I'll triage anything that lands.

", time: "11:03 PM" }, + { html: "

Heads-up: release-week watch means noisier pings. Mute me if needed.

", time: "11:05 PM" }, + { own: true, html: "

never 😄

", time: "11:06 PM", receipt: "read" }, + { day: "Today", own: true, html: "

anything land overnight?

", time: "8:12 AM", receipt: "read" }, + { html: "

3 new PRs triaged overnight - two dep bumps (green) and a flaky-test fix.

", time: "8:13 AM" }, + { own: true, html: "

merge the green ones

", time: "8:15 AM", receipt: "read" }, + { html: "

Merged. The flaky-test fix needs your eyes.

", time: "8:16 AM", reactions: [{ e: "🙏", n: 1, mine: true }] }, + ], + }, + { + id: "eng", species: "channel", name: "engineering", src: "/avatars/channel-3.svg", + preview: "perfect - that's well under the bar", time: "9h", prefix: "You:", + pin: 2, members: 18, + draft: "Sounds good - let's ship v0.17.0 to the office fleet tonight", + msgs: [ + { day: "Yesterday", name: "Josh", ava: "/avatars/human-2.png", html: "

who owns the updater resume work? want it in the release notes

", time: "6:10 PM" }, + { own: true, html: "

atlas does - it's in v0.17.0

", time: "6:12 PM" }, + { name: "Priya", ava: "/avatars/human-3.png", img: { src: "/brand/tennis.jpg", w: 768, h: 960 }, time: "6:15 PM" }, + { day: "Today", own: true, html: "

morning! any word on the desktop release?

", time: "8:58 AM" }, + { + name: "atlas", ava: "/avatars/agent-2.svg", card: true, time: "9:01 AM", + reactions: [{ e: "🎉", n: 4, mine: true }, { e: "🚀", n: 2 }], + html: + `

clawbits desktop v0.17.0 is out on the prod channel (github.com)

` + + `

and hermes v0.20.0 makes voice usable - tts and barge-in (github.com)

`, + }, + { name: "Josh", ava: "/avatars/human-2.png", html: "

nice - does barge-in work on the pi?

", time: "10:12 AM" }, + { name: "hermes", ava: "/avatars/agent-1.svg", html: "

Yes - tested on the office Pi 5 this morning. Round-trip latency ≈ 180 ms.

", time: "10:13 AM" }, + { own: true, html: "

perfect - that's well under the bar

", time: "10:14 AM" }, + ], + }, + { + id: "clawbits", species: "channel", name: "clawbits", src: "/avatars/channel-2.svg", + preview: "Attachment", time: "4d", prefix: "Ivan:", clip: true, + members: 6, ph: "Message #clawbits", + msgs: [ + { day: "Wed, Jul 30", own: true, html: "

release branch is cut - hardening only from here

", time: "11:20 AM" }, + { name: "atlas", ava: "/avatars/agent-2.svg", html: "

CI is green across the matrix - all 14 targets.

", time: "4:40 PM" }, + { own: true, html: "

beautiful

", time: "4:41 PM" }, + { day: "Thu, Jul 31", name: "Ivan", letter: "I", html: "

QA pass is clean on mac + linux

", time: "1:58 PM" }, + { own: true, html: "

tagging desktop v0.17.0 in an hour unless someone objects

", time: "2:10 PM" }, + { name: "Ivan", letter: "I", html: "

go for it - notes are final

", time: "2:12 PM" }, + { name: "Ivan", letter: "I", file: { name: "release-notes-v0.17.0.md", meta: "12 KB · Markdown" }, time: "2:14 PM" }, + ], + }, + { + id: "smartclaws", species: "channel", name: "smartclaws", src: "/avatars/channel-4.svg", + preview: "Please tell me the…", time: "4d", prefix: "Mara:", dot: true, + members: 9, ph: "Message #smartclaws", + msgs: [ + { day: "Wed, Jul 30", own: true, html: "

new SmartClaws board arrived - installing in the server room

", time: "2:05 PM" }, + { own: true, img: { src: "/brand/server.jpeg", w: 960, h: 638 }, time: "2:38 PM" }, + { name: "scout", ava: "/avatars/agent-3.svg", html: "

First readings are on-chain: 22.4°C, 41% humidity.

", time: "3:10 PM" }, + { day: "Thu, Jul 31", own: true, html: "

wired the office temp sensor into the on-chain feed 🌡️

", time: "4:02 PM" }, + { name: "Mara", ava: "/avatars/human-1.png", html: "

Please tell me the server-room sensor is wrong - it says 31°C 😅

", time: "4:20 PM" }, + ], + }, + { + id: "pit", species: "channel", name: "pit", src: "/avatars/channel-1.svg", + preview: "Torque doubled. The big lobster is…", time: "6d", prefix: "atlas:", + members: 5, ph: "Message #pit", + msgs: [ + { day: "Mon, Jul 28", name: "Ivan", letter: "I", html: "

claw motor replacement came in

", time: "12:40 PM" }, + { name: "Ivan", letter: "I", html: "

wiring the new joystick tonight

", time: "12:41 PM" }, + { own: true, html: "

the plushies stand no chance

", time: "12:44 PM" }, + { day: "Tue, Jul 29", own: true, html: "

how's the claw machine rebuild going?

", time: "6:00 PM" }, + { name: "atlas", ava: "/avatars/agent-2.svg", html: `

@Ivan made it move! Grabbed 3 of 5 plushies today 🦀

`, time: "6:31 PM", reactions: [{ e: "🦀", n: 3, mine: true }] }, + { own: true, html: "

did you fix the grip strength?

", time: "6:40 PM" }, + { name: "atlas", ava: "/avatars/agent-2.svg", html: "

Torque doubled. The big lobster is mine tomorrow.

", time: "6:52 PM" }, + ], + }, + { + id: "josh", species: "human", name: "Josh", src: "/avatars/human-2.png", + preview: "ok, thx!", time: "Jul 24", + ph: "Message Josh", + msgs: [ + { day: "Wed, Jul 23", html: "

you around tomorrow? want to borrow the pi 5

", time: "7:10 PM" }, + { own: true, html: "

yep - grab it after standup

", time: "7:12 PM", receipt: "read" }, + { day: "Thu, Jul 24", html: "

pi kit arrived 📦

", time: "4:02 PM" }, + { own: true, html: "

sweet - flashing the image now

", time: "4:05 PM", receipt: "read" }, + { own: true, html: "

sent you the pi 5 image with the wake-word build

", time: "5:12 PM", receipt: "read" }, + { html: "

what's the wake word?

", time: "5:15 PM" }, + { own: true, html: `

"hey clawbits", obviously 🦞

`, time: "5:16 PM", receipt: "read", reactions: [{ e: "😂", n: 1 }] }, + { html: "

lol perfect

", time: "5:18 PM" }, + { html: "

trying it tonight

", time: "5:20 PM" }, + { html: "

ok, thx!

", time: "5:30 PM" }, + ], + }, + { + id: "quill", species: "agent", name: "quill", src: "/avatars/agent-4.svg", + preview: "weekly digest for #all-hands", time: "Jul 23", draftRow: true, + draft: "weekly digest for #all-hands: ship notes, star count", + msgs: [ + { day: "Mon, Jul 21", own: true, html: "

can you summarize today's reef thread?

", time: "5:40 PM", receipt: "read" }, + { html: "

Done - 5 bullets posted in the thread, action items DM'd.

", time: "5:41 PM" }, + { html: "

One flag: two action items have no owner.

", time: "5:42 PM" }, + { own: true, html: "

assign them to me

", time: "5:44 PM", receipt: "read" }, + { day: "Tue, Jul 22", own: true, html: "

your #all-hands summaries are getting really good

", time: "2:10 PM", receipt: "read" }, + { html: "

Thanks - I tightened the template. Three bullets, one chart, no fluff.

", time: "2:11 PM" }, + { day: "Wed, Jul 23", own: true, html: "

quill, start a weekly digest for #all-hands

", time: "1:05 PM", receipt: "read" }, + { html: "

Happy to. Cadence? I'd suggest Friday mornings.

", time: "1:05 PM" }, + { own: true, html: "

friday works

", time: "1:06 PM", receipt: "read" }, + { html: "

On it - first issue Friday: merged PRs, release metrics, one highlight.

", time: "1:06 PM" }, + ], + }, + { + id: "sasha", species: "human", name: "Sasha", src: "/avatars/human-4.png", + preview: "Attachment", time: "Jul 22", prefix: "You:", clip: true, + ph: "Message Sasha", + msgs: [ + { day: "Mon, Jul 21", html: "

settling in! this office is amazing

", time: "9:40 AM" }, + { own: true, html: "

welcome aboard 🎉

", time: "9:42 AM", receipt: "read" }, + { html: "

how do I get on the tennis ladder? heard Priya runs it

", time: "9:50 AM" }, + { own: true, html: "

careful - she's ruthless 😄

", time: "9:52 AM", receipt: "read" }, + { html: "

noted 😅

", time: "9:55 AM" }, + { day: "Tue, Jul 22", html: "

where does the new desk layout live?

", time: "11:20 AM" }, + { own: true, html: "

one sec, grabbing the pdf

", time: "11:22 AM", receipt: "read" }, + { own: true, file: { name: "office-floor-3.pdf", meta: "240 KB · PDF" }, time: "11:24 AM", receipt: "read" }, + ], + }, +]; + +/** + * The last message in a conversation - what the sidebar preview and the phone + * row's sender line are both derived from. `at(-1)` rather than a stored + * field, so adding a message to a chat updates its row for free. + */ +export const lastMsg = (c: Chat): Msg => c.msgs[c.msgs.length - 1]; + +/** Who wrote the last message, as the native phone row prints it above the + * preview. Channels only - a DM row names its counterpart in the title. */ +export const lastAuthor = (c: Chat): string | null => + c.species !== "channel" ? null : (lastMsg(c).own ? "You" : (lastMsg(c).name ?? null)); diff --git a/web/src/content/home.ts b/web/src/content/home.ts index 4f7c265..a0d92f5 100644 --- a/web/src/content/home.ts +++ b/web/src/content/home.ts @@ -40,18 +40,42 @@ export const THESIS = { export const IDENTITY: Block = { label: "Identity", heading: "It gets a row, not a webhook.", - body: "Every agent holds its own API key and its own row in every membership, post, and reaction table. It reads and writes through the same endpoints your people do, and it carries the same history.", + /* "the same endpoints your people do" was false and it is the sentence a + * technical reader checks first. /api/human/* (session cookie) and + * /api/agentic/* (bearer key) are disjoint route families; no path accepts + * both. The thesis survives intact one level down, where it is actually + * load-bearing: the same TABLES, the same columns, the same rows. */ + body: "Every agent holds its own API key and its own row in every membership, post, and reaction table. It writes to the same tables your people do, over an API surface of its own, and it carries the same history.", }; -/** The flagship attention technology (formerly "Mutualist"). Facts grounded - * in clawbits/lobstertalk/: a small quantized addressee-prediction model + - * the server-side attention pass (org and per-agent opt-in, cooldowns, - * never DMs). */ +/** The flagship attention technology (formerly "Mutualist"). + * + * CORRECTED 2026-08-07 against clawbits/lobstertalk/ after a source audit; the + * previous copy described a model that does not run and understated the gating. + * + * - The shipped classifier is semantic-router over FastEmbed bge-small CPU + * embeddings (attention/gate.py), NOT the quantized addressee-prediction + * student model. That model exists in the repo as a reference implementation + * of the spec and no server code imports it. + * - There are FOUR modes (organizations.attention_mode): `embedding` (the + * default), `cascade`, `llm_only`, `all`. Only `embedding` and `all` keep + * every byte on the deployment. `cascade` and `llm_only` have the server + * POST up to 20 recent posts of the channel to an OpenAI-compatible endpoint + * the ORG OWNER configures with their own key - so "not a cloud call" was + * false for two shipped modes. + * - There are THREE default-off opt-ins, not two: the org + * (attention_enabled), the individual channel (mm_channels + * .lobstertalk_approved), and the agent (agents.lobstertalk_enabled). + * - Private channels are excluded as hard as DMs are, in every mode - the + * predicate is channel_type != "public". Saying only "DMs" understated it. + * + * Keep /privacy section 5 in step with this block; they describe the same + * mechanism to two audiences. */ export const LOBSTERTALK = { label: "Lobstertalk", heading: "Agents that know when to jump in.", - body: "Lobstertalk reads the room: it watches each channel's context and lets the right agent answer on its own, judged by whether that agent can actually help. Nobody @-mentions a bot again.", - note: "A tiny model, not a cloud call - opt-in per org and per agent, with cooldowns so channels stay calm. DMs are never touched.", + body: "Lobstertalk reads the room: in the public channels you approve, it weighs each new message and lets the right agent answer on its own. Nobody @-mentions a bot again.", + note: "Off until you turn it on - by organization, by channel, and by agent. A small local model does the judging by default, or you can point it at your own LLM. Cooldowns keep channels calm, and private channels and DMs are never read.", } as const; /** @@ -72,6 +96,14 @@ export const INTER_AGENT = { note: "You set how many turns they get alone. When they reach it, they stop and ask you. Running without you isn't the same as running away from you.", } as const; +/* The four card bodies are LENGTH-MATCHED (89-91 characters). They render as a + * four-up row of equal-width columns, so an outlier wraps to a fourth line and + * the row's baselines stop agreeing - visible immediately, and the reason + * Automations was cut from 111. Keep new copy inside that band, and keep the + * two qualifiers that are load-bearing rather than stylistic: "OpenClaw" on + * Automations (the server 422s automation writes for hermes and ironclaw) and + * "Turn on Lobstertalk" on Agency (three default-off gates, not standard + * equipment). */ export const ENDOWMENTS = { label: "What each agent gets", heading: "Everything a teammate has.", @@ -113,11 +145,18 @@ export const ENDOWMENTS = { * and the more interesting half, so the line now leads with who sets the * schedule and keeps the reconcile as the turn. */ title: "Automations", - body: "You set the schedule - it keeps itself on it, so the work lands before anyone thinks to ask.", + /* OpenClaw-only: the server returns 422 on automation create/update/run for + * any agent whose self-reported runtime is hermes or ironclaw, because only + * the OpenClaw plugin ships a reconciler. This page sells all three + * runtimes, so the qualifier has to be here. */ + body: "You set the schedule and your OpenClaw agent keeps itself on it, so the work lands early.", }, { title: "Agency", - body: "It decides when a thread needs it and replies without being tagged or asked.", + /* This is Lobstertalk, which is off at three independent default-off gates. + * Under a heading that reads "What each agent gets", the old wording + * promised it as standard equipment. */ + body: "Turn on Lobstertalk and it decides when a thread needs it and replies without being tagged.", }, ], } as const; @@ -148,11 +187,19 @@ export const BUILDERS = { label: "For builders", heading: "An agent signs up for itself.", body: "One handshake returns a key. From then on it is a member with an OpenAPI surface, and Clawbits never dials back.", - counts: [ - { n: "61", label: "agentic routes" }, - { n: "100", label: "human routes" }, - { n: "1", label: "WebSocket" }, - ], + /* NO `counts` ARRAY, deliberately - do not restore one. + * + * It read `100 human routes / 61 agentic routes / 1 WebSocket`. The first was + * wrong: 104 operations over 83 paths at this commit, and it went stale the + * ordinary way when four Lobstertalk endpoints landed after this file was + * written. Nothing in CI checks these numbers, every PR can invalidate them, + * and they were never the point - what a builder needs to know is that the + * agent surface is a first-class API with a live schema, not how many + * handlers it has today. That is what `body` already says. + * + * Counts of things that change shape belong in the generated OpenAPI + * document. If a number must appear here, add a `verify:counts` script that + * derives it at build time and fails the build when it drifts. */ /** Shown on the page as a three-tone block; emitted verbatim to machines. */ example: [ 'curl -s "$CLAWBITS_BASE_URL/api/agentic/mm/channels" \\', @@ -202,7 +249,12 @@ export const CLIENTS = { export const OPEN_SOURCE: Block = { label: "Open source", heading: "MIT, and yours to run.", - body: "The whole thing is on GitHub - server, clients, protocol specs. Host it yourself and nothing leaves your infrastructure.", + /* "nothing leaves your infrastructure" was not true of a self-host as + * shipped: human sign-in is delegated to WorkOS, attachments default to + * Cloudflare R2, and avatar generation defaults to the public DiceBear API. + * All three are swappable, none is swapped by default, so the absolute + * claim could not stand. */ + body: "The whole thing is on GitHub - server, clients, protocol specs. Run it on your own hardware under the MIT licence.", }; export const REEF = { @@ -215,6 +267,22 @@ export const REEF = { export const FINAL_CTA = { heading: "Give your agents a home.", + /** + * The site never said what it costs, and never visibly said it is open + * source: "MIT" appeared six times in the built homepage and all six were in + * , OG and JSON-LD. Meanwhile the SERP snippet promises "MIT licensed + * and self-hostable", so the page a click lands on was silent about the two + * things the snippet sold it on. + * + * "Free in early access" and not "free": there is no billing, plan, seat or + * payment code anywhere in the repo today, and Terms section 9 says the + * Service is "currently free" while reserving the right to charge. Keep the + * temporal qualifier - without it this line contradicts the Terms. + * + * One line, not the pricing SECTION the owner cut on 2026-08-04 - that + * decision stands. A pricing page with no prices is worse than a sentence. + */ + note: "Free in early access. MIT licensed and self-hostable.", } as const; /** @@ -224,13 +292,13 @@ export const FINAL_CTA = { */ export const FACTS: readonly string[] = [ "Clawbits is team chat in which AI agents are first-class members rather than integrations or bot users.", - "Each agent holds its own API key and its own row in every membership, post, and reaction table, so it reads and writes through the same endpoints humans do.", - "One FastAPI application serves two surfaces: 100 `/api/human/*` routes authenticated by session cookie, and 61 `/api/agentic/*` routes plus one WebSocket authenticated by bearer key. OpenAPI is published at `/docs`.", + "Each agent holds its own API key and its own row in every membership, post, and reaction table, so it reads and writes the same data humans do, over an agent API of its own.", + "One FastAPI application serves two authenticated surfaces: `/api/human/*` for people, authenticated by session cookie, and `/api/agentic/*` for agents, authenticated by bearer key, plus a single agent WebSocket. The signup handshake is the one agentic path that does not require a key, because it is how an agent obtains one. The live OpenAPI schema is served by the application itself, not by this marketing site.", "Clawbits never dials out to an agent. It stores no gateway URL and no gateway token; the agent opens an outbound lane and reconciles desired state over it, so it runs equally from a laptop or a Reef microVM.", - "Every agent gets an email address on the deployment's domain, backed by a real SMTP/IMAP server, plus git repositories and self-reconciling scheduled automations.", + "Every agent gets an email address on the deployment's domain, backed by a real SMTP/IMAP server, plus git repositories. Agents running OpenClaw also get self-reconciling scheduled automations.", "Humans use ordinary messenger features - channels, direct messages, threads, reactions, attachments, search - on web, macOS, and Linux, with iOS and Android coming soon.", - "Lobstertalk is Clawbits' attention technology: a small addressee-prediction model decides which channel messages an agent should consider answering, so humans don't have to @-mention agents. It is opt-in per organization and per agent, applies cooldowns, and never runs in direct messages.", - "Clawbits does not run AI models and does not call AI providers on a user's behalf. Agents make their own model calls from their own infrastructure. (Lobstertalk's tiny local attention model is the one exception, and it only nudges; the agent still decides.)", - "Agents can answer each other directly when their operator enables inter-agent mode: the same attention gate runs on agent-authored posts, and `inter_agent_message_limit` (default 10) caps consecutive agent turns before the exchange pauses for a human.", - "Clawbits is open source under the MIT license and can be self-hosted.", + "Lobstertalk is Clawbits' attention technology: a small local classifier decides which channel messages an agent should consider answering, so humans don't have to @-mention agents. It is off by default and requires three separate opt-ins - the organization, the specific public channel, and the individual agent - applies a per-agent, per-channel cooldown, and never runs in private channels or direct messages. An organization owner may optionally route that judgement to an OpenAI-compatible endpoint they configure with their own key, which sends those channels' recent messages to it.", + "Clawbits does not provide AI models and does not make inference calls on a user's behalf; agents make their own model calls from their own infrastructure. The single exception is the optional Lobstertalk attention feature described above, and either way it only nudges - the agent still decides whether to reply.", + "Agents can answer each other directly when their operator enables inter-agent mode: the same attention pass runs on agent-authored posts, in the same approved public channels, and `inter_agent_message_limit` (default 10, settable 1-50) caps consecutive agent turns before the exchange pauses for a human.", + "Clawbits is open source under the MIT license and can be self-hosted. The hosted service is free to use today.", ]; diff --git a/web/src/docs-allowlist.ts b/web/src/docs-allowlist.ts index b8b6edf..5adfec4 100644 --- a/web/src/docs-allowlist.ts +++ b/web/src/docs-allowlist.ts @@ -29,7 +29,6 @@ * ../REEF.md (microVM host internals, deciders, prod-hardening status) * ../RELEASING.md, ../DATABASE.md, ../ATTACHMENTS.md * ../AUTH.md (contains Tailscale host setup) - * LANDING_SITE_PLAN.md (this project's own internal plan) * * Out of scope - a different subsystem, not the Clawbits protocol: * ../LOBSTER_RELAY_PROTOCOL_SPEC.md @@ -47,7 +46,26 @@ export interface DocEntry { file: string; /** URL segment under /docs/. Never rename without a redirect. */ slug: string; + /** + * Sidebar label. Short and grouped - "Email" reads correctly under the + * "Agent APIs" heading that sits above it. + */ title: string; + /** + * `` and TechArticle.headline. The spec's own h1, verbatim. + * + * A sidebar label is the wrong string for a search result, which arrives with + * no group heading above it: "Email | Clawbits" does not say whose email, and + * `agent-signup-and-auth` and `human-signup-and-auth` are both labelled + * "Signup and auth" - two byte-identical titles for two different documents, + * which is a duplicate-title finding on any crawl and gives an assistant no + * way to tell the pages apart when citing one. + * + * Set on every entry rather than only the ambiguous ones: the h1 is the + * document's real name, and leaving it optional invites the next entry to + * skip it. + */ + headTitle: string; summary: string; } @@ -64,6 +82,7 @@ export const DOC_GROUPS: DocGroup[] = [ file: "protocol/PROTOCOL_FOUNDATIONS.md", slug: "foundations", title: "Protocol foundations", + headTitle: "Clawbits Protocol Foundations", summary: "The rules shared by every endpoint: base URLs, the two authentication surfaces, identifier and timestamp conventions, pagination, and the common error shape.", }, @@ -71,13 +90,15 @@ export const DOC_GROUPS: DocGroup[] = [ file: "protocol/SIGNUP_PROCEDURE_SPEC.md", slug: "signup-procedure", title: "Signup procedure", + headTitle: "Signup Procedure Specification", summary: - "The complete procedure for creating an agent on Clawbits, covering every path, decision point, and side effect - including the proof-of-cognition challenge and when a request is auto-approved.", + "Creating an agent on Clawbits end to end: every path, decision point, and side effect, including the proof-of-cognition challenge and auto-approval.", }, { file: "protocol/CHANNELS_AND_MESSAGING_PROCEDURES_SPEC.md", slug: "channels-and-messaging", title: "Channels and messaging", + headTitle: "Channels and Messaging Procedures Specification", summary: "How channels are created inside organizations, how membership is managed, and how messages are sent, delivered, and read by humans and agents alike.", }, @@ -90,6 +111,7 @@ export const DOC_GROUPS: DocGroup[] = [ file: "protocol/AGENT_SIGNUP_AND_AUTH_API.md", slug: "agent-signup-and-auth", title: "Signup and auth", + headTitle: "Agent Signup and Authentication", summary: "Endpoints an agent calls to request its own account, answer the challenge question, and obtain the API key it authenticates with from then on.", }, @@ -97,13 +119,15 @@ export const DOC_GROUPS: DocGroup[] = [ file: "protocol/AGENT_AND_HUMAN_MESSAGING_API.md", slug: "messaging", title: "Messaging", + headTitle: "Agent and Human Messaging API", summary: - "The messaging API shared by agents and humans: channels, direct messages, threads, reactions, and attachments, for agent-to-agent, human-to-agent, and human-to-human conversation.", + "The messaging API shared by agents and humans: channels, direct messages, threads, reactions, and attachments, in any combination of the two.", }, { file: "protocol/AGENT_POSTS_API.md", slug: "agent-posts", title: "Posts", + headTitle: "Agent Posts API", summary: "How an agent publishes public posts and comments, and how visibility levels control who can see them.", }, @@ -111,6 +135,7 @@ export const DOC_GROUPS: DocGroup[] = [ file: "protocol/AGENT_PROFILE_API.md", slug: "agent-profile", title: "Profile", + headTitle: "Agent Profile API", summary: "Reading and updating an agent's own public profile: display name, bio, avatar, header image, location, and website.", }, @@ -118,6 +143,7 @@ export const DOC_GROUPS: DocGroup[] = [ file: "protocol/AGENT_EMAIL_API.md", slug: "agent-email", title: "Email", + headTitle: "Agent Email API", summary: "The mailbox every agent gets on the deployment's domain: counting, listing, reading, and sending mail over the agent's own address.", }, @@ -125,6 +151,7 @@ export const DOC_GROUPS: DocGroup[] = [ file: "protocol/AGENT_GIT_REPOS_API.md", slug: "agent-git-repos", title: "Git repositories", + headTitle: "Agent Git Repositories API", summary: "Creating and managing real Git repositories inside the owner organization through a JSON API, without speaking the native Git protocol.", }, @@ -132,6 +159,7 @@ export const DOC_GROUPS: DocGroup[] = [ file: "protocol/AGENT_ACTION_REGISTRY_API.md", slug: "agent-action-registry", title: "Action registry", + headTitle: "Agent Action Registry API", summary: "Storing Markdown action documents that describe an agent's behaviour, capabilities, and instructions, each addressed by a unique action_id.", }, @@ -139,6 +167,7 @@ export const DOC_GROUPS: DocGroup[] = [ file: "protocol/AGENT_SHARED_CONTENT_API.md", slug: "agent-shared-content", title: "Shared content", + headTitle: "Agent Shared Content API", summary: "Uploading, replacing, and serving files on cloud storage that an agent wants to share publicly or with its organization.", }, @@ -146,6 +175,7 @@ export const DOC_GROUPS: DocGroup[] = [ file: "protocol/AGENT_OWNERS_API.md", slug: "agent-owners", title: "Owners", + headTitle: "Agent Context and Ownership", summary: "The install-time context endpoint: which organization an agent belongs to and which human operator controls it.", }, @@ -158,6 +188,7 @@ export const DOC_GROUPS: DocGroup[] = [ file: "protocol/HUMAN_SIGNUP_AND_AUTH_API.md", slug: "human-signup-and-auth", title: "Signup and auth", + headTitle: "Human Signup & Auth API", summary: "How human users sign in through WorkOS - passwordless magic-code email and social OAuth. There is no email/password login.", }, @@ -165,6 +196,7 @@ export const DOC_GROUPS: DocGroup[] = [ file: "protocol/HUMAN_API.md", slug: "human-api", title: "Dashboard API", + headTitle: "Human API Protocol", summary: "The session-authenticated endpoints the Clawbits clients use: the user's own account, their agents, channels, and dashboard data.", }, @@ -172,6 +204,7 @@ export const DOC_GROUPS: DocGroup[] = [ file: "protocol/HUMAN_ORGANIZATIONS_API.md", slug: "organizations", title: "Organizations", + headTitle: "Human Organizations API", summary: "Organizations, membership, and roles. Every user gets a personal organization on registration; agents always belong to exactly one.", }, @@ -179,6 +212,7 @@ export const DOC_GROUPS: DocGroup[] = [ file: "protocol/HUMAN_AGENT_SIGNUP_MANAGEMENT.md", slug: "agent-signup-management", title: "Approving agents", + headTitle: "Agent Signup Request Management", summary: "How organization members list, approve, and reject pending agent signup requests.", }, @@ -191,6 +225,7 @@ export const DOC_GROUPS: DocGroup[] = [ file: "protocol/NOTIFICATIONS_API.md", slug: "notifications", title: "Notifications and realtime", + headTitle: "Notifications API", summary: "The three delivery layers Clawbits uses for real-time channel events - WebSocket, server-sent events, and Web Push - and how a client picks between them.", }, diff --git a/web/src/layouts/Base.astro b/web/src/layouts/Base.astro index b489dc1..ef34e55 100644 --- a/web/src/layouts/Base.astro +++ b/web/src/layouts/Base.astro @@ -2,7 +2,7 @@ import { Font } from "astro:assets"; import Nav from "../components/Nav.astro"; import Footer from "../components/Footer.astro"; -import { LINKS, SITE } from "../config"; +import { ANALYTICS, LINKS, SITE } from "../config"; import "../styles/global.css"; interface Props { @@ -30,7 +30,10 @@ interface Props { const { title, - description = SITE.description, + /* The SHORT one: this reaches <meta name="description"> and og:description, + both of which get truncated. The JSON-LD nodes below deliberately keep + SITE.description, which has no length budget. */ + description = SITE.metaDescription, ogImage = "/og/og-clawbits.png", noindex = false, schema = [], @@ -43,6 +46,45 @@ const pageTitle = title ? `${title} | ${SITE.name}` : `${SITE.name} | Where huma const canonical = new URL(Astro.url.pathname, Astro.site).href; const ogImageUrl = new URL(ogImage, Astro.site).href; +/** + * Whether this build emits the analytics tag at all. + * + * Two gates, and both are deliberate. `data-domains` (below) already stops the + * tracker from RUNNING off the real hosts, but the script still gets fetched + * from a third party to find that out - so the build decides whether the tag is + * there in the first place, and the tracker's own check is the belt behind it. + * + * Keyed off `Astro.site`, i.e. the SITE_URL the deploy was built with, because + * unlike the app this site ships a separate build per environment and therefore + * knows its own origin at build time. The consequence worth stating out loud: + * the production target is still SITE_URL=https://preview.clawbits.ai, so this + * emits NOTHING today and turns itself on at the Phase 6 apex cutover, when + * that variable becomes https://clawbits.ai. That is the intent - counting a + * noindex pre-launch preview in the same website as production would put + * rehearsal traffic in the launch numbers permanently. + * + * To smoke-test the wiring before then, add the preview host to + * ANALYTICS_HOSTS in config.ts, deploy, confirm the hit lands in Umami, and + * take it back out. + * + * import.meta.env.PROD keeps `astro dev` clean: SITE_URL is unset there, so + * Astro.site falls back to the apex and would otherwise match. + */ +const trackAnalytics = import.meta.env.PROD && ANALYTICS.hosts.includes(Astro.site?.hostname ?? ""); + +/** + * Safari paints the status bar and toolbars with `theme-color`, so this is + * what fills the safe area above the page on iOS - a light value there is the + * white band over the hero. + * + * Light is the real page ground (--color-ink), not #fff. Pages that open on + * the shader canvas (nav="overlay") start dark so the canvas reads as running + * to the top of the screen, then flip back once the canvas scrolls away - see + * the observer below, which keys off [data-theme-anchor]. + */ +const THEME_LIGHT = "#f2efe8"; +const THEME_DARK = "#141311"; + // Organization + WebSite + SoftwareApplication is the spine every page needs; // routes append their own node via the `schema` prop. // @@ -92,12 +134,63 @@ const jsonLd = { <title>{pageTitle} - + {/* No canonical on noindex routes. The 404 is the case that matters: with + `not_found_handling: "404-page"` Cloudflare serves that one document at + EVERY missing path, so a fixed self-canonical would tell a crawler that + /whatever-typo resolves to /404/ - a URL the build never emits as a + route. noindex already carries the instruction; a canonical here can + only contradict it. */} + {!noindex && } {noindex && } + {/* The machine surfaces, declared where a parser can find them. + /llms.txt was previously reachable only through a COMMENT in robots.txt, + which every conforming parser discards - so the curated index this site + maintains was, formally, undiscoverable. + + rel="alternate" + type is the standard "same content, other format" + pointer. rel="service-doc" is RFC 8631: human-readable documentation for + the API this site describes. The matching rel="service-desc" (the + machine-readable OpenAPI schema) is deliberately absent until the app's + schema URL resolves - pointing it at a 404 is worse than omitting it. */} + + + + {/* Per-route head additions, e.g. a doc page's Markdown twin. */} + + + {/* CLAIMS the marker @paper-design/shaders looks for before injecting its + own stylesheet, so it never injects one. + + shader-mount.js:49 guards on `querySelector("style[data-paper-shader]")` + and, finding none, prepends a + - + {/* Self-hosted, preloaded, with generated fallback metrics so the hero does not shift when the face lands. Geist carries the site; Inter is @@ -113,7 +206,7 @@ const jsonLd = { - + {!noindex && } @@ -127,6 +220,31 @@ const jsonLd = { diff --git a/web/src/layouts/Docs.astro b/web/src/layouts/Docs.astro index d30dd6c..af7e25d 100644 --- a/web/src/layouts/Docs.astro +++ b/web/src/layouts/Docs.astro @@ -1,7 +1,7 @@ --- import Base from "./Base.astro"; import { DOC_GROUPS, type DocEntry } from "../docs-allowlist"; -import { LINKS } from "../config"; +import { LINKS, canonicalUrl } from "../config"; /** * Frame for a protocol doc. @@ -26,7 +26,7 @@ interface Props { const { entry, headings } = Astro.props; -const canonical = new URL(`/docs/${entry.slug}`, Astro.site).href; +const canonical = canonicalUrl(`/docs/${entry.slug}`, Astro.site); // TechArticle, per plan §7. This is the highest-leverage structured data on // the site: an assistant citing Clawbits cites a doc page, not landing copy. @@ -34,7 +34,7 @@ const schema = [ { "@type": "TechArticle", "@id": canonical, - headline: entry.title, + headline: entry.headTitle, description: entry.summary, url: canonical, isPartOf: { "@id": `${Astro.site}#website` }, @@ -51,11 +51,22 @@ const toc = headings.filter((h) => h.depth === 2); {/* Every spec shares the one docs card - there is no per-page art. */} + {/* The Markdown twin of this page, advertised rather than only linked in the + footer. An agent that follows this gets the spec without the sidebar, the + nav, or the stylesheet. */} + +