diff --git a/.env.example b/.env.example index 6271a7a..32b95d4 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,3 @@ -# Public configuration only. Never prefix secrets with PUBLIC_. -# Leave blank until the separate feedback Worker is configured and approved. -PUBLIC_FEEDBACK_API_URL= -PUBLIC_TURNSTILE_SITE_KEY= -# Local development automatically uses a clearly labeled loopback mock. -# Set true only for a non-indexable build used for review. +# Optional local preview setting. The static site needs no credentials. +# Set true only for a review build that must not be indexed. PUBLIC_PREVIEW=false -# Telegram/Cloudflare credentials belong to their separate services, never here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 40b9dc4..d15679f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,11 +1,13 @@ ## What changes for visitors -Describe the visible behavior and the reason for it. Link representative desktop/mobile screenshots when the interface changes. +Describe the concrete behavior and reason for it. Include desktop/mobile screenshots for visual changes. ## Validation -List commands actually run, measured results, and remaining limitations. Distinguish fixtures from authenticated service tests. +List checks actually run, measured results and remaining limitations. Do not present older QA results as verification of this revision. -## Deployment and privacy +## Publication -State whether credentials, content synchronization, third-party requests, public URLs or hosting settings are affected. Keep secrets and personal feedback out of this description. Merging or deploying requires the owner's explicit approval. +Merging into `main` triggers the GitHub Pages workflow. Confirm that the change is ready for the owner to publish, preserves `wizardloop.com` and existing URLs, and includes any relevant rollback notes. A pull request does not itself merge or deploy the change. + +State any changes to external links, visitor data handling or hosting settings. Keep credentials and personal messages out of this description. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a5bf8c..2bdec7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,10 +8,11 @@ on: permissions: contents: read env: - ASTRO_TELEMETRY_DISABLED: 1 + ASTRO_TELEMETRY_DISABLED: '1' jobs: check: runs-on: ubuntu-latest + timeout-minutes: 30 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -28,13 +29,3 @@ jobs: name: browser-quality-results path: artifacts/final/ retention-days: 7 - profile-sync-tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: '3.13' - - run: python -m pip install -r services/profile-sync/requirements.txt - - run: python -m unittest discover -s services/profile-sync -p "test_*.py" -v - - run: python -m unittest discover -s services/posts-sync -p "test_*.py" -v diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f8612e8..a06c8de 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,22 +1,22 @@ -name: Approved Pages deployment +name: Deploy to GitHub Pages on: + push: + branches: + - main workflow_dispatch: - inputs: - approval: - description: 'Type DEPLOY_WIZARDLOOP only after explicit owner approval' - required: true - type: string permissions: contents: read concurrency: group: pages-production cancel-in-progress: false env: - ASTRO_TELEMETRY_DISABLED: 1 + ASTRO_TELEMETRY_DISABLED: '1' + PUBLIC_PREVIEW: 'false' jobs: build: - if: github.ref == 'refs/heads/main' && inputs.approval == 'DEPLOY_WIZARDLOOP' + if: github.repository == 'WizardLoop/wizardloop.github.io' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest + timeout-minutes: 30 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -25,15 +25,21 @@ jobs: cache: npm - run: npm ci - run: npm run check - env: - PUBLIC_FEEDBACK_API_URL: ${{ vars.PUBLIC_FEEDBACK_API_URL }} - PUBLIC_TURNSTILE_SITE_KEY: ${{ vars.PUBLIC_TURNSTILE_SITE_KEY }} + - run: npx playwright install --with-deps chromium firefox webkit + - run: npm run test:browsers + - uses: actions/upload-artifact@v4 + if: always() + with: + name: production-quality-results + path: artifacts/final/ + retention-days: 7 - uses: actions/upload-pages-artifact@v3 with: path: dist deploy: needs: build runs-on: ubuntu-latest + timeout-minutes: 10 permissions: pages: write id-token: write @@ -41,6 +47,6 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - - name: Deploy approved static artifact + - name: Deploy validated static artifact id: deployment uses: actions/deploy-pages@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ff31f8..a6f4727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,32 +1,32 @@ # Changelog -This file records repository changes. A heading does not mean a Git tag, GitHub Release or production deployment exists. +These headings record source changes. They do not establish that a Git tag, GitHub Release or production deployment exists. -## 1.0.0-rc.1 - Unreleased +## 1.0.0-rc.2 - Unreleased -### Website +- Simplify the site to static GitHub Pages with manually maintained profile content and local media. +- Replace the feedback form with verified direct contact links while preserving `/feedback/`. +- Add an accessible email-copy action, persistent mobile-menu close control and visible current-page navigation. +- Refine the illustrated destination pages around original ink, paper, star and infinity artwork; keep the creator's existing wizard identity and motion. +- Preload the local body font to reduce initial text-layout work; record fresh Lighthouse measurements and broader three-engine responsive coverage. +- Direct Work and Posts visitors to the original Telegram channel and Projects visitors to all GitHub repositories; retain the existing page URLs. +- Remove automatic profile/post services, the feedback backend, CAPTCHA setup, related fixtures and obsolete operational instructions. +- Remove embedded-feed loading and explain the current external-link behavior in privacy, security and accessibility information. +- Publish validated `dist/` output on pushes to `main`, with a manual retry option. Require lint, typecheck, build and browser checks before deployment; keep deployment permissions in the deploy job. +- Document the one-time GitHub Actions publishing-source setting, direct content editing, verification and rollback. No DNS change or additional service credentials are required. -- Rebuild the personal portfolio with Astro static output and the selected Spectrum Circuit direction, developed with comic framing and the owner's wizard illustrations. -- Add Home, About, Selected Work, Projects, Posts, Community, Feedback, Privacy, Cookies, Security, Accessibility and a custom 404 page. -- Add responsive navigation, light and dark themes, persistent preferences, keyboard controls, motion pause and reduced-motion behavior. -- Return the hero to a still preview when a native decoder stops unexpectedly, while preserving intentional user and visibility pauses. -- Detect a frozen native-video clock even when the decoder still reports playback, and avoid false failures after delayed timer callbacks. -- Include 30 original wizard animations and six flag samples, unchanged source TGS files, transparent posters and provenance manifests. -- Add page-specific metadata, structured data, static social artwork, favicons, sitemap output and domain-preserving builds. +Current validation and known limits are recorded in [QA.md](docs/QA.md). This candidate does not claim completed legal conformance, human screen-reader review or a verified production release. -### Services and tooling +## 1.0.0-rc.1 - Earlier review candidate -- Add an isolated profile synchronization service with active-photo identity matching, video/still selection, cache, fallback and offline fixtures. -- Add consent-gated Telegram posts and a separately configured supported-update ingestion path. Original Telegram links remain available when embedded media is incomplete. -- Add a feedback Worker and local mock, server validation, Turnstile verification, private-chat setup and provider-mocked tests. -- Add source/build checks, browser coverage, accessibility checks, quality CI and an approval-gated manual Pages workflow. -- Correct the canvas media fixture, measure native loop capability independently from application code, and record actual encoded AVC metadata. -- Document setup, content editing, service limitations, asset rights, contribution, security reporting and release/rollback procedures. +- Rebuilt the personal site with Astro and the selected Spectrum Circuit direction, comic framing and the owner's wizard illustrations. +- Added responsive pages, themes, navigation, motion preferences, semantic metadata, static social artwork and domain-preserving builds. +- Included 30 original wizard animations, six flag samples, retained TGS sources, transparent posters and provenance manifests. +- Added native hero video and static fallbacks for decoders that stop or freeze, plus browser, accessibility and performance checks. +- Prototyped private profile/post synchronization and a feedback backend. Those components were removed in `1.0.0-rc.2` after the owner chose a simpler static scope. Their earlier code and review evidence remain in Git history. -### Review status - -This is an unreleased review candidate. Measured browser, accessibility, performance and offline-service results are documented in [the quality review](docs/QA.md). Authenticated Telegram/profile media tests, live feedback delivery, native Safari and human screen-reader review, production validation, merge approval and release approval remain separate steps. Complete legal/accessibility conformance is not asserted. +The earlier service tests are historical evidence, not verification of a current live service. No release is implied by this entry. ## Earlier baseline -The original four-file website remains in Git history at `658ed667c173f861cd16e6048126de12ec3648b9` from 2025-07-20. The initial audit found no Git tags or GitHub Releases. Do not assign a historical semantic version retrospectively. Phase 1 tooling used `0.1.0-preview.1` while three local concepts were evaluated; it was not a production release. +The original four-file website remains in Git history at `658ed667c173f861cd16e6048126de12ec3648b9`, dated 2025-07-20. The initial audit found no tags or GitHub Releases; recheck before creating any release. Do not assign a historical semantic version retrospectively. Phase 1 tooling used `0.1.0-preview.1` for the three local concepts, not a production release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 886060d..41ab192 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,36 +1,33 @@ # Contributing to WizardLoop -This repository is the source of one creator's personal website. Contributions should improve that website while preserving the owner's identity, verified content and control over publication. +This repository is one creator's personal website. Contributions should preserve the owner's identity, verified content and control over publication. Open focused pull requests for corrections, and discuss major visual changes or dependencies first. Report vulnerabilities privately through [SECURITY.md](SECURITY.md). -For a small correction, open a focused pull request. Discuss substantial visual changes, new services or dependencies in an issue first. Never disclose credentials, personal submissions or a vulnerability in a public issue or pull request; use [SECURITY.md](SECURITY.md). +## Local work -## Working locally +1. Read [README.md](README.md) and [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). +2. Use a dedicated branch or fork. Do not work directly on `main` or modify another repository as part of this project. +3. Install Node 22.23.4 or newer, run `npm ci`, then `npm run dev`. +4. Edit content, styles and browser interactions in their documented locations. No service credentials or mock server are needed. -1. Read the [README](README.md), relevant service guide and [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). -2. Use a dedicated branch or fork. Do not work directly on `main` or alter another repository as part of this project. -3. Install locked dependencies with `npm ci` using Node 22.23.4 or newer. -4. Run `npm run dev`. The frontend and optional `npm run dev:feedback` mock require no live credentials. -5. Make the smallest coherent change. Keep content, styles, browser interactions and private integrations in their existing locations. +Preserve `wizardloop.com`, both source CNAME files and existing public routes. Hosting or domain changes require a specific owner-reviewed proposal. Keep the site static unless the owner explicitly requests a different architecture. -Do not change `wizardloop.com`, either CNAME file, production settings or deployment behavior as incidental cleanup. Such changes need a specific proposal and owner approval. +## Content and interface -## Content, design and accessibility +- Use English by default and correct direction handling for Hebrew. +- Verify claims and links. Do not invent clients, achievements or profiles. +- Use a regular hyphen instead of U+2014 in controlled content and metadata. +- Preserve keyboard access, visible focus, semantic headings, contrast and narrow-screen layouts. Honor reduced motion and the global motion control. +- Keep useful content when JavaScript fails. Do not load external scripts or media merely to display a link to another service. +- Record asset provenance and licensing. MIT code contributions do not relicense the owner's branding or artwork. -- Use English by default and preserve correct direction handling for Hebrew content. -- Verify project links and factual claims. Do not invent clients, achievements, personal details or social profiles. -- Use a regular hyphen instead of the em dash character U+2014 in controlled content, metadata and documentation. -- Keep keyboard access, visible focus, semantic headings, readable contrast and small-screen layouts. Honor reduced motion and the global motion control. -- Keep useful static content when JavaScript or a third party fails. Avoid heavy dependencies for effects that local CSS or existing components can provide. -- Preserve media attribution. MIT code contributions do not relicense the owner's artwork or third-party assets. Record the source and license of any new font, icon, media or dependency. +## Validation and review -## Validation and pull requests +Run `npm run check`. For layout or interaction changes, run relevant browser tests against the built site; use `npm run test:browsers` for browser behavior changes. Do not add redundant tests for a simple text correction; existing content/build checks still apply. -Run `npm run check`. For layout or interaction changes, build the site and run relevant Playwright checks, including `npm run test:browsers` when browser behavior changes. Follow service-specific tests for profile, posts or feedback changes. Do not write redundant tests for a simple text correction; existing content/build checks still apply. +Describe the concrete change, validation actually performed and remaining limits. Include desktop/mobile screenshots and both themes for visual changes. Do not treat older QA reports as verification of the new revision. -A useful pull request describes the concrete problem, resulting behavior, validation and remaining limits. Include desktop/mobile screenshots for visual changes and both themes where relevant. Identify mocks and fixtures explicitly. Do not report an external integration as verified unless it was exercised with authorized credentials. +Keep lockfile updates with dependency changes. Exclude installed packages, build output, test artifacts, `.env` files, credentials and personal messages. Review the diff before submitting. -Keep lockfile changes with dependency changes. Do not commit `node_modules`, build output, test artifacts, `.env`, `.dev.vars`, sessions, private chat IDs, submitted messages or tokens. Check the diff for accidentally included local files before submitting. +**An owner-approved merge into `main` triggers publication after checks pass.** Creating a pull request does not authorize someone else to merge it. Confirm publication readiness before merging and follow [DEPLOYMENT.md](docs/DEPLOYMENT.md). Tags, releases and DNS changes remain separate owner decisions. -Owner review governs design and content acceptance. A pull request does not authorize merging, deploying, enabling services, creating releases or changing DNS. Follow [DEPLOYMENT.md](docs/DEPLOYMENT.md) for those separate operations. - -By submitting new source code, you offer it under the repository's MIT license. Only submit material you have the right to contribute, and identify any different asset license. Participation is subject to the [Code of Conduct](CODE_OF_CONDUCT.md). +New source contributions are offered under the repository's MIT license. Submit only material you may contribute and identify any different asset license. Participation follows [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). diff --git a/README.md b/README.md index 4eaa444..1af71c3 100644 --- a/README.md +++ b/README.md @@ -2,90 +2,68 @@ The source of [wizardloop.com](https://wizardloop.com), the personal website of WizardLoop: independent design, illustration, animation, software, Telegram bots and open source. -The rebuild develops the selected Spectrum Circuit concept into a comic-inspired portfolio, with original wizard illustrations, expressive vector loops and a light paper palette alongside a complete dark theme. Astro generates the static site; small browser scripts handle interaction, and separate optional services handle private integrations. +This is a static Astro website for GitHub Pages. It presents the owner's wizard artwork and connects visitors to the Telegram channel, community, GitHub repositories and direct contact details. There is no application backend, automatic profile/post synchronization, feedback form or bot setup. The website requires no credentials. -**Status: `1.0.0-rc.1`, unreleased.** This branch is for implementation and review. Fixtures and mocked tests do not establish that production integrations work. Authenticated Telegram profile/video retrieval, live feedback delivery and production deployment need separate configuration, verification and owner approval. No release or deployment is implied by the package version. +**Version: `1.0.0-rc.2`.** This identifies the current source candidate, not a published release or a verified production deployment. See [QA.md](docs/QA.md) for recorded validation and its limits. ## Run locally -Use Node.js **22.23.4 or newer**, with npm. Repository workflows use Node 24. Python 3.11 or newer is needed only for the optional synchronization services and their tests. +Use Node.js **22.23.4 or newer** and npm. GitHub workflows use Node 24. ```sh -git clone --branch design/three-local-concepts https://github.com/WizardLoop/wizardloop.github.io.git +git clone https://github.com/WizardLoop/wizardloop.github.io.git cd wizardloop.github.io npm ci npm run dev ``` -Open [http://127.0.0.1:4322](http://127.0.0.1:4322). No credentials are needed to view the website. This unreleased review lives on `design/three-local-concepts`; the default branch still contains the earlier site. For an existing checkout of the review branch, skip cloning. Work on a dedicated development branch rather than `main`. - -To exercise feedback locally, run this in a second terminal: - -```sh -npm run dev:feedback -``` - -The loopback mock listens on port 8787. The development form identifies it as a local test and sends nothing to Telegram. See [feedback setup](docs/FEEDBACK-SETUP.md) for its validation rules and limitations. - -Build and inspect the static result: +Open [http://127.0.0.1:4322](http://127.0.0.1:4322). To review a pull request before it is merged, check out that pull request's branch first. Make changes on a dedicated branch. ```sh npm run build -npm run preview -- --host 127.0.0.1 --port 4322 +npm run preview ``` -Stop the development server before starting the preview on the same port. `dist/` contains the publishable HTML, browser assets, domain file and social metadata. Neither command deploys anything. +Stop the development server before starting the built preview on the same port. `dist/` is the complete publishable website. Local commands do not deploy it. -## Configuration and content +No `.env` file is required. The only example setting, `PUBLIC_PREVIEW=true`, marks a local review build as non-indexable. Production builds explicitly disable it. Nothing prefixed `PUBLIC_` may contain a secret. -`.env.example` contains public frontend options only. Copy it to a local `.env` if configuration is needed. Keep the feedback endpoint and Turnstile sitekey empty until the separate service is approved and verified. A `PUBLIC_` prefix means the value can be included in browser files; it must never contain a secret. +## Content and assets | Edit | Location | | --- | --- | | Name, verified social URLs, email and navigation | `src/config/site.ts` | -| Page copy and route-specific metadata | `src/pages/*.astro` | -| Verified software projects | `src/content/projects.json` | -| Wizard artwork titles, descriptions and provenance | `src/content/artworks.json` | -| Curated flag animations and attribution | `src/content/flags.json` | -| Public channel post index | `src/content/posts.json` | -| Public profile snapshot and media | `public/media/profile/` | +| Page copy, links and route metadata | `src/pages/*.astro` | +| Manually maintained profile copy and image metadata | `public/media/profile/profile.json` | +| Local profile image and poster | `public/media/profile/` | +| Preserved wizard artwork source inventory and provenance | `src/content/artworks.json` | +| Preserved flag asset inventory and attribution | `src/content/flags.json` | | Colors, typography and responsive layouts | `src/styles/global.css` | -| Common header, footer and metadata | `src/layouts/Base.astro` | -| Reusable icons, art and content components | `src/components/` | -| Browser preferences, embeds and motion | `src/scripts/` | +| Shared header, footer and metadata | `src/layouts/Base.astro` | +| Reusable icons and artwork components | `src/components/` | +| Theme, navigation and motion behavior | `src/scripts/` and `public/js/preferences.js` | -Keep claims factual. Link to verified projects and describe the actual role. The wizard scenes are portfolio artwork, not software screenshots or evidence of client relationships. Record provenance and rights for new assets, write meaningful alternative text and preserve image dimensions. +The artwork and flag manifests preserve the original asset collection and its provenance. They do not generate a public gallery or a current portfolio listing; the pages choose the illustrations they display. -Controlled content must not contain the em dash character U+2014. Use a regular hyphen instead; source checks enforce this rule. Do not introduce unverified X, LinkedIn or organization identities into metadata. +The local profile is edited in the repository. Changing a Telegram or GitHub profile does not update this site automatically. The Posts and Work pages link to the original Telegram content; Projects points to the owner's GitHub repositories. These destinations remain current on their respective services without requiring a feed importer or repository API request from this website. -## Architecture +Keep factual claims and external links verified. Do not invent clients, achievements or social profiles. Record provenance and rights for new media, provide meaningful alternative text, and preserve image dimensions. The wizard illustrations are original artwork, not screenshots of software or evidence of client relationships. -- Astro generates complete HTML for Home, About, Work, Projects, Posts, Community, Feedback, Privacy, Cookies, Security, Accessibility and a custom 404. Directory output supports direct requests and refreshes on GitHub Pages. -- Local CSS and fonts provide the visual system. No application framework, CMS, database or general-purpose backend is required for the portfolio. -- The Home hero uses a native 768 x 768 H.264 MP4 loop derived from the supplied `portal-alchemy` artwork, with separate light and dark versions and the original 2.4-second duration. Other original TGS artwork loads through one shared light Canvas Lottie player. At most three visible Canvas scenes play, drawn at up to 24 frames per second while elapsed-time sampling preserves each original animation's duration. Motion controls, reduced-motion preferences and viewport/document visibility limit both playback paths. -- Static WebP delivery copies in `public/media/previews/` provide the initial and no-animation view. The build creates these quality-94 copies from unchanged lossless masters in `public/media/posters/`, preserving dimensions and the alpha channel. Original TGS sources also remain unchanged. -- Official Telegram embeds load only after visitor consent. Original post links remain available without embedding. Telegram controls embedded media support; custom emoji, stickers and animation fidelity are not guaranteed by the website. -- Profile data is read at build time from a public snapshot. Private MTProto work happens in the separate sync service, never in the browser or Pages output. -- Feedback uses an optional Cloudflare Worker with server-side validation and Turnstile verification. GitHub Pages serves only the frontend. +Controlled text, metadata and documentation must not contain the em dash character U+2014. Use a regular hyphen; source checks enforce this rule. -The build copies `public/CNAME` to `dist/CNAME`, preserving `wizardloop.com`. The original root `CNAME`, `index.html` and PaperMod-attributed stylesheet remain as historical source. Astro builds from `src/`; the legacy HTML is not its homepage input. No repository-name base path is added to this custom-domain site. +## Architecture -## Integration status and setup +Astro generates complete HTML for Home, About, Work, Projects, Posts, Community, Contact at the retained `/feedback/` URL, Privacy, Cookies, Security, Accessibility and a custom 404. Directory output supports direct navigation and refreshes on GitHub Pages. Existing routes and the domain remain intact. -| Integration | Current review boundary | Instructions | -| --- | --- | --- | -| Telegram profile | Public snapshot and local fallbacks available; authenticated active-primary/video access unverified | [Profile sync](docs/PROFILE-SYNC.md) | -| Telegram posts | Verified post index and consent-gated embeds; supported-update ingestion requires separate configuration, with no live schedule enabled | [Posts sync](docs/POSTS-SYNC.md) | -| Feedback | Local mock and provider-mocked tests; no public Worker or real private-message delivery claimed | [Feedback setup](docs/FEEDBACK-SETUP.md) | -| Brand derivatives | Local profile and original artwork generate static icons and a 1200 x 630 composition | `scripts/generate-brand.mjs` | +CSS, fonts, icons, profile assets and animation media are served locally. The Home hero uses two native 768 x 768 H.264 MP4 derivatives of the supplied `portal-alchemy` artwork, one per theme, with the original 2.4-second duration. Other TGS artwork uses the shared, lazy-loaded light Canvas Lottie player. Motion preferences, reduced motion and visibility controls limit playback; a still preview remains available when playback fails. -Run `node scripts/generate-brand.mjs` after an approved profile change; `npm run build` also runs it. The script updates the avatar badge and favicons while preserving illustration sources. Its native social-card typography uses installed Arial or a sans fallback, can vary by operating system, and does not redistribute system font files. The build also runs `scripts/generate-previews.mjs` to regenerate static delivery copies from the retained poster masters. +The build creates quality-94 WebP delivery copies from the unchanged lossless poster masters, preserving dimensions and alpha. It also generates static favicons and a 1200 x 630 social composition from the local avatar and original artwork. Run `node scripts/generate-brand.mjs` after a manual avatar change, or let `npm run build` regenerate these assets. Social-card typography uses installed Arial or a sans fallback and can vary by operating system; no system font is redistributed. -Read each service's status fields and setup guide. A usable cached output is not proof of a successful Telegram connection. No credentials, private chat IDs or sessions belong in the public snapshot. Research and external limitations are documented in [integration feasibility](docs/INTEGRATION-FEASIBILITY.md). +External destinations are ordinary links. The current site does not embed Telegram, fetch remote profile/feed data, submit visitor messages or load a CAPTCHA. Following a link opens the destination service, whose terms and privacy practices apply. See [current integration boundaries](docs/QA-INTEGRATIONS.md). -## Quality checks +`public/CNAME` is copied to `dist/CNAME` and remains `wizardloop.com`. The original root `CNAME`, `index.html` and PaperMod-attributed stylesheet are retained for recovery. Astro uses `src/` for the new site, and only `dist/` is deployed. There is no repository-name base path for this custom domain. -Read the [measured website quality review](docs/QA.md) and [integration verification](docs/QA-INTEGRATIONS.md) for observed results, preview images and outstanding acceptance limits. +## Quality checks ```sh npm run check @@ -93,57 +71,43 @@ npx playwright install chromium firefox webkit npm run test:browsers ``` -On Linux CI, install system dependencies with `npx playwright install --with-deps chromium firefox webkit`. Stop any development server on port 4322 before acceptance testing so Playwright starts the built preview. Browser tests require a successful build; `npm run check` includes one. +On Linux, install browser system dependencies with `npx playwright install --with-deps chromium firefox webkit`. Browser tests use the built preview on port 4322; stop any development server on that port first. `npm run check` includes the build. | Command | Scope | | --- | --- | -| `npm run format` | Format the final site's Astro, CSS and TypeScript source plus its preference/config scripts | -| `npm run format:check` | Check that the same scoped source follows the pinned Prettier configuration | -| `npm run lint` | Formatting, source punctuation, basic secret patterns, JavaScript syntax and project invariants | +| `npm run format` / `npm run format:check` | Format or check the final site's Astro, CSS and TypeScript source and selected config scripts | +| `npm run lint` | Formatting, punctuation, basic secret patterns, JavaScript syntax and project invariants | | `npm run typecheck` | Astro and TypeScript diagnostics | -| `npm run test:unit` | Feedback service and local mock tests | -| `npm run build` | Static preview and brand derivatives, static generation, HTML/metadata/assets/link checks | -| `npm run check` | Lint, typecheck, feedback unit tests and build | +| `npm run build` | Brand/previews, static generation, HTML/metadata/assets/link checks | +| `npm run check` | Lint, typecheck and build | | `npm test` | Chromium browser suite | -| `npm run test:browsers` | Chromium suite plus selected Firefox and WebKit smoke/interaction coverage | -| `npm run audit:performance` | Lighthouse on Home, Work, Posts and Feedback at the running built preview on port 4322 | +| `npm run test:browsers` | Chromium suite and selected Firefox/WebKit checks | +| `npm run audit:performance` | Lighthouse against the running built preview on port 4322 | -Browser coverage includes 320, 360, 390, 768, 1024 and 1440px layouts, both themes, navigation, motion, profile fallback, consent and feedback. Reports, screenshots and traces are written under ignored `artifacts/final/`. Test coverage is not a claim that every browser, assistive technology or legal accessibility requirement has been verified. +Browser evidence is written under ignored `artifacts/final/`; performance reports go to `artifacts/lighthouse/`. See [QA.md](docs/QA.md) for measured results. Tests do not establish complete accessibility or legal conformance, and older reports do not verify later source changes. -The profile service uses pinned Python dependencies in an isolated environment. After following [PROFILE-SYNC.md](docs/PROFILE-SYNC.md), run: +## Publishing -```sh -python -m unittest discover -s services/profile-sync -p 'test_*.py' -v -``` +**Merging a reviewed change into `main` triggers the Pages workflow.** The workflow runs lint, typecheck, build and browser checks before uploading `dist/` and deploying it. A failed build or test blocks publication. Pull request quality checks cannot deploy. -The posts updater uses Python's standard library and needs no package installation: +One-time prerequisite: in GitHub **Settings > Pages > Build and deployment**, the publishing source must be **GitHub Actions**. Preserve the existing `wizardloop.com` domain and HTTPS setting. The repository cannot establish or verify that account setting by itself; if it still publishes a branch, merging alone may continue serving the old root HTML. See [DEPLOYMENT.md](docs/DEPLOYMENT.md) for setup, manual retry, verification and rollback. -```sh -python -m unittest discover -s services/posts-sync -p 'test_*.py' -``` - -Its offline fixture and live configuration are documented in [POSTS-SYNC.md](docs/POSTS-SYNC.md). Identify mocks and fixtures in reports. For performance auditing, keep the built preview running on port 4322, then run `npm run audit:performance` in another terminal. Reports are written to `artifacts/lighthouse/`; set `CHROME_PATH` when Chrome cannot be discovered. Review measured results and outstanding issues before approval. This README does not assert a passing final score or complete QA. +No Cloudflare account, server, bot token, CAPTCHA key, Python installation or personal access token is required. This workflow uses GitHub's built-in token and deployment identity. A Git tag or Release is optional and requires a separate owner decision; merging does not create one. ## Local design studies -The three original concepts remain available for comparison: +The three original concepts remain separate from the production build: ```sh npm run dev:concepts ``` -Open `http://127.0.0.1:4321/concept-a/`, `/concept-b/` or `/concept-c/`. These non-indexable local studies are separate from the Astro build. `npm run test:concepts` and `npm run test:concept-interactions` exercise that earlier review material. - -## Review, deployment and releases - -Read [DEPLOYMENT.md](docs/DEPLOYMENT.md) before any operational change. Quality CI cannot deploy. The separate Pages workflow is manually triggered, restricted to `main`, and requires a confirmation input. These controls do not replace owner approval. - -Do not merge, alter Pages settings, change DNS, deploy, create a tag or publish a release until the owner approves the corresponding action. A release follows approved deployment and live verification. Repository commands do not automatically activate sync schedules, provision a Worker or configure secrets. +Open `http://127.0.0.1:4321/concept-a/`, `/concept-b/` or `/concept-c/`. These local studies are non-indexable and are not copied to `dist/`. `npm run test:concepts` and `npm run test:concept-interactions` exercise that earlier review material. ## License and contact -New website source code and tooling are [MIT licensed](LICENSE). WizardLoop's name, branding, illustrations, avatar, animation media and personal content are outside that grant. Fonts and third-party assets retain their own licenses. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) and the [asset inventory](docs/DESIGN-ASSET-INVENTORY.md). +Website source code and tooling are [MIT licensed](LICENSE). WizardLoop's name, branding, illustrations, avatar, animation media and personal content are outside that grant. Fonts and third-party assets retain their own licenses. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) and the [asset inventory](docs/DESIGN-ASSET-INVENTORY.md). -Contributions follow [CONTRIBUTING.md](CONTRIBUTING.md) and the [Code of Conduct](CODE_OF_CONDUCT.md). Report vulnerabilities privately using [SECURITY.md](SECURITY.md). +Read [CONTRIBUTING.md](CONTRIBUTING.md) before contributing and [SECURITY.md](SECURITY.md) for private vulnerability reporting. -[Telegram](https://t.me/wizardloop) · [Channel](https://t.me/wizard_loop) · [Community](https://t.me/wizardloopchat) · [GitHub](https://github.com/WizardLoop) +[Telegram](https://t.me/wizardloop) | [Channel](https://t.me/wizard_loop) | [Community](https://t.me/wizardloopchat) | [GitHub](https://github.com/WizardLoop) | [Email](mailto:wizard@wizardloop.com) diff --git a/SECURITY.md b/SECURITY.md index 506031c..0eff094 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,29 +1,21 @@ # Security -Report suspected vulnerabilities privately to [wizard@wizardloop.com](mailto:wizard@wizardloop.com) or [WizardLoop on Telegram](https://t.me/wizardloop). These are the contact details published by the owner. Do not create a public issue containing exploit details, credentials, personal feedback or private account identifiers. +Report suspected vulnerabilities privately to [wizard@wizardloop.com](mailto:wizard@wizardloop.com) or [WizardLoop on Telegram](https://t.me/wizardloop). Include the affected page, commit if known, reproduction steps, expected impact and a safe contact method. Do not publish exploit details, credentials or personal messages in an issue. -Include the affected page/component, commit/version if known, concise reproduction, expected impact and a safe way to contact you. Redact tokens, cookies, sessions, chat IDs and personal messages. Do not send real credentials as proof. +## Scope -## Scope and safe reporting +Relevant code includes the static website, browser interactions, build tools and GitHub workflows. There is no visitor-message endpoint, bot service or private application backend. External links lead to independently operated services; report provider vulnerabilities through the relevant provider's process too. -Relevant code includes the static site, build pipeline and optional profile, posts and feedback services. Bugs in Telegram, Cloudflare, GitHub or a dependency should also be reported through that provider's process where appropriate. +Use a local checkout for testing. This document does not authorize account access, accessing another person's data, unsolicited messages or denial-of-service testing. Stop if a test reveals private information and share only the minimum necessary evidence privately. There is no bug bounty or guaranteed response time. -Use a local checkout, fixtures and the mock to investigate. This document does not authorize production testing, unsolicited messages, account access, accessing another person's data, sustained traffic or denial-of-service testing. Stop if a test exposes private information and report the minimum necessary evidence privately. +## Public build boundary -There is no bug bounty or guaranteed response time. The rebuild is an unreleased candidate, and no support promise is made for a historical version. Fixes should be reviewed on the current development line; deployed-version handling depends on the actual production state. +Everything in `public/` and `dist/` is public. Never include secrets, sessions, private identifiers or personal correspondence. The website and deployment workflow require no manually configured secrets. A `PUBLIC_` environment variable is public configuration, never a secret store. -## Credential and data boundaries +Keep dependencies locked and review upgrades. Source/build pattern checks reduce accidental exposure but are not a complete secret scan or security certification. If a credential is published accidentally, notify the owner privately, revoke or rotate it at the provider and assess the exposure. Deleting a file alone does not invalidate a credential. -- Pages output is public. Never put tokens, API hashes, private chat IDs, sessions or secret keys in `public/`, content, `dist/`, issue attachments or `PUBLIC_` environment variables. -- Keep service credentials in the deployment host's secret store. Personal Telegram sessions are not supported by the profile service. -- Run real service tests only with owner approval. Local feedback uses an explicit mock and sends no Telegram message. -- Keep provider responses and visitor submissions out of CI logs and public artifacts. Use non-sensitive fixtures and generic failure messages. -- Review dependency upgrades and lockfile changes. Pattern checks are guardrails, not a complete secret scan or security certification. +## Publication and hosting -If a credential is published accidentally, notify the owner privately. The owner should revoke or rotate it at the provider, assess use and remove it from accessible artifacts as appropriate. Deleting a file or commit alone does not invalidate a leaked credential. Production credential or service changes still follow the owner's operational authorization. +Merging to `main` triggers validated publication through GitHub Pages. Pull request checks use read-only permissions; only the separate deployment job receives Pages write and identity-token permissions. See [DEPLOYMENT.md](docs/DEPLOYMENT.md) for the one-time Pages setting, verification and rollback. -## Hosting limitations - -The site includes a document-level CSP where applicable. GitHub Pages does not provide a general repository-defined response-header configuration. Do not claim that Permissions-Policy, HSTS or header-only CSP directives are set by this source alone. Audit real responses after approved deployment. A separate CDN/proxy requires an approved infrastructure decision. - -See [integration feasibility](docs/INTEGRATION-FEASIBILITY.md), [feedback setup](docs/FEEDBACK-SETUP.md), [profile sync](docs/PROFILE-SYNC.md) and [deployment](docs/DEPLOYMENT.md) for implementation boundaries and remaining live checks. +The source sets a referrer policy through document metadata. It does not currently set a Content Security Policy. GitHub Pages does not offer arbitrary repository-defined response headers; do not claim that Permissions-Policy, HSTS or other custom header protections are enforced by these files. Verify actual responses after publication. Adding a separate proxy would be a new infrastructure decision. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 1958a0c..5457726 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -32,11 +32,11 @@ The Home hero uses two native 768 x 768 H.264 MP4 derivatives of `portal-alchemy WebP assets are static previews. The quality-94 delivery copies in `public/media/previews/` retain the masters' dimensions and alpha channel; the lossless masters in `public/media/posters/` and original TGS sources are unchanged. The MP4 and WebP derivatives inherit the underlying WizardLoop media rights and are outside the source-code MIT grant. Full provenance, rendering checks and source-license links are documented in `docs/DESIGN-ASSET-INVENTORY.md`. -## Interface icons and service dependencies +## Interface icons The local interface icon set adapts familiar Feather geometry for GitHub, mail and common controls, with custom WizardLoop symbols and Telegram/community treatments. Feather is MIT licensed, Copyright (c) 2013-2023 Cole Bemis. Its full notice is preserved in `public/media/licenses/FEATHER-LICENSE.txt`; source: https://github.com/feathericons/feather/blob/main/LICENSE. No icon CDN is loaded. Social marks identify their respective services and do not imply endorsement. -The final site's self-hosted font notices are also present in `public/fonts/`. The separate profile synchronizer uses Telethon and pyaes (MIT), Pillow (MIT-CMU), rsa (Apache-2.0) and pyasn1 (BSD-2-Clause). Their pinned distributions include their original license files. These Python dependencies run separately and are not distributed in the static website build. +The final site's self-hosted font notices are also present in `public/fonts/`. The removed synchronization services and their Python dependencies are not part of this revision. Their earlier code and licensing records remain in Git history. ## Development tooling diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 0c1db5e..112f907 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,107 +1,81 @@ -# Deployment, release and rollback +# GitHub Pages publication and recovery -Scope is `WizardLoop/wizardloop.github.io` and the existing domain `wizardloop.com`. This is a procedure for a later approved operation. It does not authorize a merge, deployment, DNS change, credential update, external message, tag or release. +This guide applies only to `WizardLoop/wizardloop.github.io` and `wizardloop.com`. The site is static. No private host, bot, synchronization schedule, feedback service or additional infrastructure is needed. -The package is `1.0.0-rc.1`, unreleased. Keep development on a dedicated branch. The original baseline is commit `658ed667c173f861cd16e6048126de12ec3648b9`; retain it and the original root `CNAME`, `index.html` and `assets/css/style.css` so the earlier site remains recoverable. Preserve the PaperMod attribution. +Merging a reviewed change into `main` is the owner's publication action: it triggers the checked-in Pages workflow. Creating or updating a pull request does not publish it. The current package version is `1.0.0-rc.2`; that label does not prove a release or production deployment exists. -## Build and review +## One-time repository setting -Astro uses `site: 'https://wizardloop.com'`, static output, directory-format pages and trailing slashes. Build from the repository root with Node 22.23.4 or newer; workflows use Node 24. +Before merging the first Astro publication change: -```sh -npm ci -npm run check -npx playwright install --with-deps chromium firefox webkit -npm run test:browsers -``` - -`npm run check` includes brand generation, static generation and validation of `dist/`. Stop any development server at port 4322 before browser tests so the runner does not reuse it instead of the built preview. Python service tests and authorized live-provider acceptance are separate checks in their guides. - -The deployable artifact is **only `dist/`**. It contains static HTML, media, metadata, icons, sitemap output, the custom 404 and CNAME. It must not contain services, credentials, sessions, local artifacts, source maps or experimental concepts. `public/CNAME` and `dist/CNAME` must contain exactly `wizardloop.com`; preserve the root CNAME too. Do not add `/wizardloop.github.io` as an Astro base path. - -Set `PUBLIC_PREVIEW=true` before building a review version to mark pages non-indexable. Production must have preview mode disabled. This variable does not create a protected hosting environment; local review remains the default. - -Before publication: - -1. Review the final diff, screenshots, measured accessibility/performance results and limitations. Separate mocked/fixture results from real provider checks. -2. Verify content and rights, direct page loads, links, theme/motion behavior, profile fallback and JavaScript-free metadata. Inspect social artwork directly and check the artifact for sensitive material. -3. Read current GitHub Pages source, domain and HTTPS settings through an authorized account. The initial audit confirmed Pages exists, but its detailed settings were unavailable through the read-only interface. -4. Determine whether Pages publishes directly from a branch. A merge into that branch can trigger production even when the new workflow is manual. Resolve the source and timing with the owner before approving or executing a merge. -5. Identify the exact commit/artifact, configuration and rollback target. Obtain explicit approval for merge and deployment. A selected design, passing tests or typed workflow token is not that approval. +1. Open repository **Settings > Pages > Build and deployment** and select **GitHub Actions** as the source. +2. Preserve the existing custom domain `wizardloop.com` and HTTPS configuration. This change does not require editing DNS. +3. Check **Settings > Actions > General** permits the actions used by the repository. +4. Check the `github-pages` environment permits deployment from `main`. If the owner has configured required reviewers, deployments wait for that review; the workflow does not remove this protection. +5. Retain the previous published commit/artifact and note the current Pages/domain settings for recovery. -Any needed Pages-source, domain, HTTPS, environment-protection or DNS change is a separate concrete infrastructure change for approval. This rebuild does not require a DNS change. +These are setup instructions, not a claim that the account settings have been inspected or changed. Source code and CNAME alone cannot switch the Pages publishing source. If the source remains a branch, the old root `index.html` may keep being published instead of the Astro `dist/` artifact. Ensure the source is correct before relying on merge-to-publish behavior. -## What the owner should prepare in GitHub +GitHub documents [custom workflow requirements](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages) and [choosing the publishing source](https://docs.github.com/en/pages/getting-started-with-github-pages/configuring-a-publishing-source-for-your-github-pages-site). Astro supports [static publication to GitHub Pages](https://docs.astro.build/en/guides/deploy/github/). -Prepare these values and review the current settings before the final approval. Do not change the production source or merge a publishing branch during preparation. +No Actions secrets or repository variables are required. Do not add bot tokens, personal access tokens or private account data to the build. GitHub supplies its built-in token and short-lived deployment identity. -| Location | Required preparation | -| --- | --- | -| Settings > Pages | Record the current publishing source. The prepared Astro workflow expects **GitHub Actions** at publication time. If Pages currently publishes a branch, resolve that transition before merging. Preserve the existing `wizardloop.com` custom domain and verify HTTPS. | -| Settings > Environments > github-pages | Review branch restrictions and any required reviewer. Limit production deployment to the reviewed `main` branch; the manual confirmation field is not an access-control boundary. | -| Settings > Actions > General | Allow the repository's checked-in GitHub Actions. The workflow declares its permissions; a personal access token is not required for Pages deployment. | -| Settings > Secrets and variables > Actions > Variables | If feedback will be enabled, prepare `PUBLIC_FEEDBACK_API_URL` including `/feedback`, and `PUBLIC_TURNSTILE_SITE_KEY`. Both are public values. Leave them empty until the separate Worker is configured and verified. | -| Pull request checks | Require the current `check` and `profile-sync-tests` jobs to succeed on the commit being reviewed. Check the attached media capability evidence as well as the pass/fail totals. | -| Recovery record | Retain the previously deployed artifact, its commit and current Pages/domain settings before the approved change. | +## Build and review locally -The Pages source and environment behavior follow [GitHub's publishing-source guidance](https://docs.github.com/en/pages/getting-started-with-github-pages/configuring-a-publishing-source-for-your-github-pages-site) and [custom-workflow documentation](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages). These are preparation instructions, not a claim that the account settings have been inspected or changed. +Use Node 22.23.4 or newer; workflows use Node 24. -For feedback, prepare a Cloudflare Worker endpoint, a Managed Turnstile widget for `wizardloop.com`, a dedicated Telegram bot and a verified private numeric Chat ID using [the setup guide](FEEDBACK-SETUP.md). Keep `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`, `TURNSTILE_SECRET_KEY` and `RATE_LIMIT_SALT` in Worker secrets, outside GitHub Pages. Never send those values in a PR or commit them. - -Automatic profile/posts synchronization additionally needs a separate private host, its service credentials and a reviewed method for delivering validated snapshots into the site build. The prepared schedules do not publish new snapshots to Pages. With no integrations activated, the static site retains its existing snapshots, Telegram links and an unavailable feedback form; approving publication does not make those integrations live. - -## Manual Pages workflow - -`.github/workflows/ci.yml` runs quality checks on development-branch pushes, pull requests or manual dispatch with read-only repository permissions. Pushes to `main` do not trigger this workflow. It cannot publish Pages. Its actual run result is evidence; the presence of the file does not mean CI passed. +```sh +npm ci +npm run check +npx playwright install --with-deps chromium firefox webkit +npm run test:browsers +``` -`.github/workflows/deploy.yml`, named **Approved Pages deployment**, runs only by manual `workflow_dispatch`. Its build requires `main` and confirmation input `DEPLOY_WIZARDLOOP`. It installs locked dependencies, runs `npm run check` and uploads `dist/`. The deployment job alone receives `pages: write` and `id-token: write`, targeting the `github-pages` environment. New production jobs do not cancel an already-running deployment. +Stop any development server on port 4322 before browser tests so Playwright uses the built preview. The publishable artifact is **only `dist/`**. It must include complete static HTML, local assets, icons, social metadata, sitemap, custom 404 and CNAME. It must not contain local test artifacts, credentials, experimental concepts or private files. -After owner approval, merge the reviewed commit through the approved process and manually dispatch this workflow from `main` with that confirmation input. The token is a procedural guard, not proof of authorization. Inspect environment protection/reviewers separately; their presence is not assumed. Configuring them is an infrastructure change needing approval. +The root `CNAME`, `public/CNAME` and generated `dist/CNAME` must contain `wizardloop.com`. Astro's `site` remains `https://wizardloop.com`, with directory output and trailing slashes. Do not add `/wizardloop.github.io` as a base path. -Only these public frontend values belong in the repository/environment variables used by this workflow: +For a local non-indexable review build, set `PUBLIC_PREVIEW=true`. The production workflow explicitly sets it to `false`. Review the final diff, screenshots, factual content, rights, keyboard behavior and measured QA before the owner merges. -| Variable | Meaning | -| --- | --- | -| `PUBLIC_FEEDBACK_API_URL` | Approved HTTPS Worker endpoint, empty while unavailable | -| `PUBLIC_TURNSTILE_SITE_KEY` | Public Managed Turnstile sitekey, empty while unavailable | +## What runs after merging -Do not supply Telegram sessions, bot tokens, API hashes, private Chat IDs or the Turnstile secret to the Pages build. No profile/posts credentials or ingestion schedule are activated by deployment. +`.github/workflows/ci.yml` runs on development-branch pushes, pull requests and manual dispatch. It has read-only repository permissions and cannot deploy. Require its `check` result before merging through repository rules if desired; these rules are account settings and are not created by this source change. -## Separate integrations +`.github/workflows/deploy.yml`, named **Deploy to GitHub Pages**, runs on pushes to `main` and supports manual dispatch. Its build is restricted to this repository and `main`. -These operations need their own configuration, verification and approval: +1. Check out the selected commit and install locked npm dependencies. +2. Run `npm run check` for lint, typecheck and the complete static build. +3. Install the three Playwright browsers and run `npm run test:browsers` against that build. +4. Retain browser diagnostics as an Actions artifact and upload only `dist/` as the Pages artifact after successful checks. +5. Deploy that artifact through the `github-pages` environment. Only the deploy job receives `pages: write` and `id-token: write`. -- [Profile sync](PROFILE-SYNC.md): verify the bot-visible primary and public biography, produce a private staged snapshot, regenerate brand assets and validate a complete static build. A fixture is not a successful live sync. -- [Posts sync](POSTS-SYNC.md): use the supported update-ingestion procedure and private state in that guide. Verify access and edit behavior before scheduling. A bot cannot be assumed to read arbitrary historical channel messages. -- [Feedback](FEEDBACK-SETUP.md): review the Worker, verify its numeric private Chat ID, deploy only with approval and perform a separately authorized delivery test. Leave the public endpoint unavailable until verified. +A failing build or browser test prevents deployment. An already running production workflow is not canceled by a newer run. Environment protection rules, disabled Actions or an incorrect Pages source can still block publication; inspect the run result instead of assuming the merge succeeded publicly. -No repository command automatically provisions a Worker or recurring sync. Never put secrets into CLI arguments, public URLs, logs or tracked configuration. Some secret-management commands publish service versions; follow the relevant guide first. +For a transient failure, open **Actions > Deploy to GitHub Pages** and rerun the failed workflow. To rebuild the latest `main`, choose **Run workflow** with `main` selected. A manual run on another branch is skipped by the guard. Do not use a rerun as a substitute for fixing a reproducible validation failure. -## Verify production +## Verify the published result -Record the deployed commit, workflow run, time, artifact identity and observed results. Verify the real site after the approved deployment: +After the workflow reports a successful deployment: -1. Confirm valid HTTPS, the existing domain, no unexpected redirects and the actual Pages/certificate settings. -2. Request every page directly, including nested routes, icons, social image, sitemap and 404 behavior. Check navigation and narrow mobile layout. -3. Inspect HTML without JavaScript for titles, descriptions, canonical URLs, social metadata and structured data. Confirm production does not have preview `noindex`. -4. Exercise theme/motion preferences and profile media/fallbacks. Check third-party media only after consent. A loaded iframe does not establish full Telegram fidelity. -5. If feedback was activated separately, confirm the approved non-sensitive test reaches the intended chat. Test failures without storing submission text in reports. -6. Inspect actual response headers. Document headers unavailable on Pages instead of claiming source files enforce them. +- Open `https://wizardloop.com` and confirm valid HTTPS and the intended revision. +- Load `/about/`, `/work/`, `/projects/`, `/posts/`, `/community/` and `/feedback/` directly. Check the retained policy URLs and custom 404 behavior. +- Confirm Work and Posts open the intended Telegram channel, Projects links to all repositories, and contact links reach the verified Telegram account and email address. +- Check desktop/mobile navigation, both themes, motion pause and reduced motion. External destinations should not be fetched merely by opening the website. +- Fetch social artwork, icons, sitemap and HTML metadata without JavaScript. Production pages must not carry preview `noindex`. +- Record the commit, successful workflow URL and observations. Account-level headers and certificate settings must be verified from the real response; source files cannot prove them. -If a material failure occurs, stop further rollout and use the approved rollback path. Do not release while verification is incomplete. +See [QA.md](QA.md) for local test evidence and known browser/assistive-technology limits. ## Rollback -Before the first production change, retain the previous deployed artifact or a verified copy, the baseline commit, current Pages settings and domain value. Protect the recovery material from routine artifact expiry. Git history does not record every hosting setting. +Retain a known-good deployment and its commit before a change. For an Astro regression, prepare a revert or recovery pull request that restores the known-good source and lockfile while preserving the domain. Validate it, then let the owner merge it. The same `main` workflow publishes the recovery. Do not force-push `main`, delete history or overwrite tags. -For a later Astro deployment, restore the last known-good reviewed commit through a revert/recovery pull request, retain `wizardloop.com`, rebuild with its lockfile and rerun relevant checks. Obtain rollback authorization, then publish the reviewed artifact through the manual workflow. Do not force-push `main`, overwrite a tag or delete history. +Keep the validated Pages workflow in the recovery change. Reverting the pull request that introduced automatic publication would also remove its `push` trigger; in that case a merge alone will not publish the recovery. Retain the workflow or use its reviewed manual dispatch path, and verify the resulting deployment. -The pre-rebuild baseline has no npm build. Running the new workflow against that old commit is not a valid recovery procedure. Package its original `index.html`, `assets/css/style.css` and `CNAME` from `658ed667c173f861cd16e6048126de12ec3648b9`, or use the preserved legacy artifact, and select an explicitly reviewed Pages publication method appropriate to the active source setting. Preserve relative paths, notices and the existing domain. Do not change DNS to perform recovery. +The pre-rebuild baseline is `658ed667c173f861cd16e6048126de12ec3648b9`. It has no npm build, so running this Astro workflow against that old commit is not a valid recovery procedure. Its original `index.html`, `assets/css/style.css` and `CNAME` are retained in the repository and Git history. If that legacy site is needed, prepare an explicitly reviewed artifact/publication workflow appropriate to the active Pages source. Preserve its relative paths, PaperMod notice and `wizardloop.com`; DNS need not change. -Worker versions and private sync processes are independent. Stopping a schedule or restoring a Worker needs the relevant operational authorization. Reverting static files does not undo a message already sent or invalidate a leaked credential. +Git history does not record every hosting setting. Restore account settings only through an owner-approved operation, using the recovery record. ## Tags and releases -After approved deployment and successful live verification, obtain approval for the final semantic version, tag and GitHub Release. Recheck remote tags/releases immediately before selecting the version; never overwrite a tag. The initial audit found none, but that is historical evidence. - -Update the package and lockfile consistently, complete `CHANGELOG.md`, and tag the exact reviewed/deployed commit. Release notes should cover visible changes, validation, remaining integration limits, licensing and rollback. A release-candidate version does not mean `1.0.0` has been released. +A successful deployment does not create a Git tag or GitHub Release. If the owner requests a release, verify the live result, recheck existing tags/releases, choose the semantic version, update the package and lockfile together, and tag the exact reviewed commit. Never overwrite an existing tag. Release notes should state actual validation, known limits, licensing and recovery instructions. diff --git a/docs/FEEDBACK-SETUP.md b/docs/FEEDBACK-SETUP.md deleted file mode 100644 index 4c7a769..0000000 --- a/docs/FEEDBACK-SETUP.md +++ /dev/null @@ -1,133 +0,0 @@ -# Feedback service setup and review - -The static website remains on GitHub Pages. `services/feedback/worker.mjs` is a separate, dependency-free Cloudflare Worker with JSDoc types. It receives feedback, verifies Cloudflare Turnstile on the server, and sends a private message through the Telegram Bot API. - -**Status:** implemented and tested against mocked providers. No Worker has been deployed, no live credentials have been used, and no Telegram delivery has been tested. Repository automation must not deploy this service. Merging, deployment and a real delivery test require the owner's explicit approval. - -## Local tests and mock - -From the repository root, with Node 22 or later: - -```sh -npm --prefix services/feedback test -npm --prefix services/feedback run dev:mock -``` - -The mock listens only on `http://127.0.0.1:8787/feedback`. It accepts frontend origins `http://127.0.0.1:4321`, `http://localhost:4321` and the same hostnames on port 4322. The local server explicitly sets `MODE=mock`; production requires `MODE=production`. - -A mock request must use `turnstileToken: "local-mock-token"`. A successful mock result contains `mock: true` and the message `Local test complete. No message was sent to Telegram.` The frontend must display this distinction. This is not a working CAPTCHA or proof of real delivery. The mock makes no outbound requests, retains no submitted messages, and permits five requests per origin per minute in process memory. - -Mock mode requires both a loopback request target and a loopback Origin. Uploading the same mock configuration onto a public Worker cannot enable this local bypass. Production refuses missing credentials, unverified Chat IDs, test Turnstile keys, missing rate-limit bindings and non-HTTPS requests. The package has no dependencies; the lockfile records that fact. - -## Frontend contract - -Set only the deployed endpoint URL and public Turnstile sitekey in public frontend configuration. Do not put a secret key, bot token, numeric Chat ID, or rate-limit salt in any variable prefixed `PUBLIC_` or in the Pages build. - -`POST /feedback` with `Content-Type: application/json`: - -```json -{ - "name": "A visitor", - "message": "A message for WizardLoop.", - "email": "", - "website": "", - "turnstileToken": "a-fresh-widget-token", - "sourcePath": "/feedback/" -} -``` - -| Field | Rule | -| --- | --- | -| `name` | Required, trimmed, 1 to 80 UTF-16 code units, no line breaks | -| `message` | Required, trimmed, 1 to 2500 UTF-16 code units; line breaks permitted | -| `email` | Optional, maximum 254 code units; reasonable email syntax, no whitespace or header injection | -| `website` | Optional honeypot, must be exactly empty when supplied | -| `turnstileToken` | Required, maximum 2048 code units; each real submission requires fresh verification | -| `sourcePath` | Optional, defaults to `/feedback/`; restricted local path, no query, fragment or external URL | - -Extra fields are rejected. The stream is limited to 16 KiB even without a Content-Length header; body reading has a five-second deadline. Unsupported content types, invalid UTF-8, malformed JSON and control characters are rejected. - -Use Turnstile Managed mode with `action: "feedback"`. Reset the widget after every submission attempt, including network failures, validation errors and delivery errors, because a token may have been consumed. Send requests with `credentials: "omit"`. Never put feedback fields in query strings, analytics events, error reports or client logs. Use the accessible status region for the returned generic message and keep the user's text in the form on failure. - -| HTTP status | Meaning | Frontend behavior | -| --- | --- | --- | -| 200, `ok: true`, `mock: false` | Telegram returned success | Show success and clear the form | -| 200, `ok: true`, `mock: true` | Local demonstration only | State explicitly that nothing was sent | -| 400 / 408 / 413 / 415 / 422 | Invalid request, timeout, size, content type or CAPTCHA | Keep entered values, display error, reset verification | -| 403 | Rejected origin or preflight | Display a general unavailable message | -| 429 | Local submission rate exceeded | Honor `Retry-After`, keep content, reset verification | -| 503 | Configuration, verification or delivery unavailable | Keep content, offer manual retry with new verification | -| 503 with `mayHaveBeenDelivered: true` | Telegram request outcome is uncertain | Say delivery could not be confirmed and ask the visitor to wait before retrying | - -The worker retries a transient Siteverify failure once using the same idempotency key. It rejects unsuccessful or replayed verification and requires the returned hostname to equal the request Origin's hostname and the returned action to equal `feedback`. This follows [Cloudflare's server-side validation contract](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/). There is no client-only verification shortcut. - -## Create and verify the private Telegram destination - -Use a dedicated bot created through [Telegram's BotFather](https://t.me/BotFather). Keep its token in a password manager. Do not paste the token into a browser URL, command argument, screenshot, public issue or chat with this assistant. - -Run the local helper in a private interactive terminal: - -```sh -npm --prefix services/feedback run setup:chat -``` - -It prompts for the token without echoing it or accepting command-line arguments. It calls `getMe` and `getWebhookInfo`, refuses a bot with an active webhook, then prints a random one-time phrase. Open the verified bot from the `@wizardloop` account, press Start and send the phrase manually. After Enter, the helper reads at most 100 updates and requires exactly one private message containing that phrase from `@wizardloop`, with matching sender and chat IDs. It confirms the private chat using `getChat`. - -The numeric Chat ID is printed only in the local terminal so the owner can copy it into the secret manager. The token is never printed. No files are written, no update offsets are acknowledged, no webhook is changed and no message is sent by the helper. Clear the terminal if it is shared. Use a fresh dedicated bot to avoid existing webhook integrations and update backlogs. If the private username changes, review and update the expected username in this helper intentionally. - -Set `TELEGRAM_CHAT_ID_VERIFIED=true` only after this verification. The flag records an operator check, not a cryptographic proof. Starting a conversation permits a bot to reply, but blocked bots and revoked tokens can still fail later. Actual send capability requires one separately approved live test. The API takes a numeric private Chat ID, not the owner's personal username; see the official [Telegram sendMessage and getUpdates documentation](https://core.telegram.org/bots/api). - -## Configuration and secrets - -Review `services/feedback/wrangler.jsonc`. All routes, workers.dev serving and preview URLs are disabled initially. No account ID, DNS change or public endpoint is included. - -| Name | Storage | Value | -| --- | --- | --- | -| `MODE` | Worker variable | `production` | -| `ALLOWED_ORIGINS` | Worker variable | Exact comma-separated HTTPS origins, initially `https://wizardloop.com` | -| `TURNSTILE_HOSTNAMES` | Worker variable | Exact comma-separated verified hostnames, initially `wizardloop.com` | -| `TELEGRAM_CHAT_ID_VERIFIED` | Worker variable | `true` only after private-chat verification | -| `TURNSTILE_SECRET_KEY` | Worker secret | Secret from the Managed Turnstile widget | -| `TELEGRAM_BOT_TOKEN` | Worker secret | Dedicated bot's token | -| `TELEGRAM_CHAT_ID` | Worker secret | Verified positive numeric private Chat ID | -| `RATE_LIMIT_SALT` | Worker secret | Independently generated high-entropy random value of at least 32 bytes | -| `RATE_LIMITER` | Worker binding | Cloudflare Rate Limiting binding | - -Generate the salt with a trusted password manager. Never commit `.dev.vars`, `.env` files, tokens, session files or personal Chat IDs. The directory's `.gitignore` excludes them. `.env.example` contains empty placeholders only. Local real-credential testing is deliberately not enabled by the mock server. - -The Turnstile widget must allow the production hostname. Do not add localhost to a production widget. Public test keys belong only in a dedicated test setup; this production worker intentionally rejects the documented dummy secret patterns. See [Cloudflare testing guidance](https://developers.cloudflare.com/turnstile/troubleshooting/testing/). - -For the rate-limit binding, select an unused positive `namespace_id` within the owner's Cloudflare account; `1001` is a configuration example, not a provisioned account resource. The configured threshold is five attempts per minute. Missing or failed rate-limit infrastructure fails closed. The key is a daily HMAC of the Cloudflare-provided IP with a secret salt; neither the raw IP nor the key is sent to Telegram or application logs. No tracking cookie is created. - -Cloudflare's rate-limit binding is approximate and local to each Cloudflare location. It is not a strict worldwide quota. This anonymous form has no account identifier, so a transient IP-derived key is used with the known tradeoff that people sharing a connection can share a limit. A strict global limit would require additional state such as a Durable Object. See [Workers Rate Limiting](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/). - -## Approved deployment procedure - -Do not execute this section until the owner separately approves production changes. The main site's CNAME and GitHub Pages deployment remain separate. - -1. Review the tests and configuration, choose the endpoint without changing `wizardloop.com` DNS, and obtain approval for the exact external Worker endpoint and cost settings. -2. Use a current, pinned Wrangler 4 release supporting `ratelimits` (at least 4.36.0). Install it as local development tooling in this service and retain its lockfile update for review. It is unnecessary for the dependency-free local mock and unit tests. -3. Configure the secrets through Cloudflare's secret manager. **`wrangler secret put` immediately deploys a Worker version.** On an existing Worker, `wrangler versions secret put NAME` creates a version without deploying it. Do not treat uploading secrets as a harmless read-only setup action. Cloudflare's [secret management documentation](https://developers.cloudflare.com/workers/configuration/secrets/) explains the difference. -4. Choose the approved route or explicitly enable the approved workers.dev endpoint. Keep `MODE=production`, exact origin and hostname lists, and the configured rate-limit binding. Do not enable wildcard CORS or allow localhost in the public production configuration. -5. Review an uploaded version and deploy only through the approved release process. The Worker code does not require a bundling step or Node compatibility flags. -6. Set the static website's public feedback endpoint and public sitekey. Update its CSP `connect-src` with that exact endpoint and permit only the required Turnstile hosts in the appropriate directives. -7. With explicit permission, submit one clearly labeled non-sensitive test from the real production website and confirm receipt in the intended private chat. Test invalid CAPTCHA, bad Origin and a rate-limit response as well. Record the date and observed results without visitor messages, bot tokens or Chat IDs. -8. Keep the form unavailable or clearly local-only until delivery is verified. Do not claim that Telegram sending works based only on these mocks. - -For rollback, restore the previous reviewed Worker version through Cloudflare and disable the public form endpoint in the static configuration if needed. Rotating a bot token or secret is an external change requiring the same explicit operational approval. No deployment workflow is supplied here. - -## Data flow and known limits - -The submitted name, message and optional email pass through Cloudflare to the owner's private Telegram conversation. The message contains the verified site origin plus a restricted source path and a UTC receipt time. HTML metacharacters are escaped, URL previews are disabled, and `protect_content` is requested. These settings do not guarantee that a recipient or provider cannot retain content. - -There is no database, queue, mail provider or application log containing submissions. The worker does not send IP addresses to Telegram and omits optional `remoteip` from Siteverify. Cloudflare still handles connection metadata and performs security processing, and Telegram stores the resulting message under its own service rules. The owner must choose an actual message-retention and deletion practice before publishing the privacy policy. This implementation does not enforce deletion from the Telegram conversation. - -No automatic retry of `sendMessage` occurs: a timeout may happen after delivery, and the API call has no application idempotency key here. The user can retry with a new CAPTCHA after a clear warning. A queue and durable delivery ledger would be needed for stronger delivery guarantees. Message limits keep the resulting formatted text below Telegram's 4096-character limit after parsing. - -CORS and Origin checks limit browser callers but are not authentication for arbitrary clients; Turnstile and rate limiting remain required. The honeypot is supplemental. All API responses carry `no-store`, no-referrer, `nosniff`, restrictive CSP and frame protection. These are this Worker's response headers, not a claim that GitHub Pages serves those same headers. Cloudflare observability is disabled in this initial configuration; platform-level security records may still exist. - -## Automated evidence - -The offline test suite covers successful escaped delivery, required fields, injection attempts, body byte limits, malformed JSON, exact Origin checks, preflight restrictions, missing configuration, disabled and failed rate-limit bindings, daily key rotation, invalid and replayed CAPTCHA, hostname/action mismatch, bounded verification retries, provider timeout, manual retry after Telegram failure, ambiguous delivery, security headers, local-only mock restrictions and mock rate limits. - -Tests use fake credentials and mocked network transports. They are not evidence of a deployed Cloudflare binding, real Turnstile operation, legal compliance or a successful Telegram message. Preserve the final test output with the repository's QA report. diff --git a/docs/INTEGRATION-FEASIBILITY.md b/docs/INTEGRATION-FEASIBILITY.md index 27ffbf0..d23b8ab 100644 --- a/docs/INTEGRATION-FEASIBILITY.md +++ b/docs/INTEGRATION-FEASIBILITY.md @@ -1,5 +1,7 @@ # Integration feasibility - Phase 1 +> Historical Phase 1 record. The owner chose a static, link-based site on 2026-10-28. Integration proposals and operational assumptions below are superseded; retain this report for dated observations and asset provenance. See [README](../README.md), [deployment](DEPLOYMENT.md) and [current boundaries](QA-INTEGRATIONS.md) for the maintained architecture. + Reviewed on 2026-10-27 (Asia/Jerusalem). This is research for local design review. No remote settings, deployments, credentials, webhooks, sessions, releases or production content were changed. Integration proposals below are not implemented or end-to-end tested. ## Findings that affect design approval diff --git a/docs/POSTS-SYNC.md b/docs/POSTS-SYNC.md deleted file mode 100644 index e454f15..0000000 --- a/docs/POSTS-SYNC.md +++ /dev/null @@ -1,177 +0,0 @@ -# Telegram posts synchronization - -This optional Python 3.11+ service maintains the native site's post index using supported Telegram Bot API events. It has no Python package dependencies. It does not scrape `t.me/s`, download Telegram media, run inside the browser, publish a site, or send messages. - -The implementation and offline tests are available. No bot credentials, authenticated requests, live channel updates, scheduler, or deployment have been configured or verified. The current site's manually verified snapshot remains the live input until an operator explicitly runs the service and reviews its output. - -## Supported contract and limits - -- A dedicated bot receives `channel_post` and `edited_channel_post` updates. This service requires it to be an administrator of the verified public `@wizard_loop` channel. Grant only the minimum rights Telegram permits for membership, not publishing, deleting, inviting, or editing rights unless separately needed. -- The existing verified IDs in `src/content/posts.json` form a deliberately incomplete manual history. New and edited events update that index. The service cannot backfill arbitrary historical posts through Bot API, and Bot MTProto `messages.getHistory` is not available to bots. -- Telegram retains undelivered updates for at most 24 hours. Poll regularly and alert on failures. An outage beyond the retention window can create a permanent gap. The index always declares `historyComplete: false`; `lastSyncedAt` means the last successfully persisted poll, not proof of complete history. -- The reviewed API has no general channel-post deletion event. Maintain an owner-reviewed exclusion file for removed posts. This service does not infer deletion from a failed widget or a temporary network error. -- Album members share `media_group_id` and accumulate across polls in the private normalized cache. The index emits one item with `messageIds`, uses the smallest message ID as its official embed reference, and preserves individual IDs for source links. It cannot know whether an album is complete until its events arrive; a first poll may briefly show a single member. Telegram controls the widget's album presentation. -- TGS, WebM stickers, animations, video, custom emoji and newer rich message types are identified, not flattened into still images. The official widget remains the media renderer. Some content requires Telegram's app; no full playback or formatting fidelity is claimed. The native excerpt is plain text only, while the official embed retains Telegram's own formatting when supported. -- A verified `getChat` response with `has_protected_content: true` produces an empty public index with `status: protected`. No updates are polled or acknowledged while this authoritative flag is present. A message-level protection flag also excludes that message. Protection changes unavailable because of a network failure cannot be detected until a successful check. - -Authoritative references: [receiving updates and retention](https://core.telegram.org/bots/api#getting-updates), [getUpdates cursor semantics](https://core.telegram.org/bots/api#getupdates), [channel messages and media groups](https://core.telegram.org/bots/api#message), [bot channel delivery](https://core.telegram.org/bots/faq#what-messages-will-my-bot-get), [individual official Post Widgets](https://core.telegram.org/widgets/post), and [users-only history method](https://core.telegram.org/method/messages.getHistory). Reviewed 2026-10-27. - -## Run the offline checks - -From the repository root, with Python 3.11 or newer: - -```sh -python -m unittest discover -s services/posts-sync -p 'test_*.py' -python services/posts-sync/posts_sync.py --fixture services/posts-sync/fixtures/updates.json --output services/posts-sync/test-output/posts.json --state-dir services/posts-sync/test-output/private -``` - -The fixture contains synthetic channel events clearly labeled as fixtures. It uses the real manual snapshot only as its seed. Its output is marked `status: fixture`, and the CLI refuses the real `src/content/posts.json` as a fixture output. It never creates a Bot API client or uses credentials. The generated `test-output` directory is ignored by Git and must never be published. - -## Configure a separate operator service - -1. Create a dedicated bot using Telegram's official BotFather flow. Add it as a channel administrator with minimum permissions. Do not reuse a feedback bot or a bot with another updates consumer. -2. Set `POSTS_TELEGRAM_BOT_TOKEN` in the host's protected environment or secret manager. Do not paste tokens into shell history, source code, public artifacts, CI command arguments, or logs. -3. Set `POSTS_SYNC_STATE_DIR` to a persistent private directory outside this repository and outside any web document root, accessible only to the service account. On Windows, configure its ACL explicitly; Python's POSIX mode flag does not replace Windows ACLs. The service refuses live state anywhere under this repository. -4. Back up the private directory. It contains the update cursor, bot/channel identity binding, blocked IDs, normalized public-message metadata and, after a protection change, `last-public-index.json` for recovery. That backup is the previous public snapshot, now retained privately. The directory contains no bot token, Telegram file IDs, raw API response, private-chat text, author identity, or session credentials. Avoid sharing it because it contains operational metadata and recovery content no longer included in the public index. -5. Run manually with a separately staged index, inspect it, then copy the reviewed result into the site's content input before the site's normal build/review process. A live run consumes this bot's events. Only one consumer may own it. - -Example after securely setting the two environment variables: - -```sh -python services/posts-sync/posts_sync.py --live --seed src/content/posts.json --output services/posts-sync/test-output/posts.json -``` - -`--state-dir /absolute/private/directory` can be used instead of `POSTS_SYNC_STATE_DIR`. The path is not a secret but must be private. After live verification and explicit integration approval, omit `--output` to update `src/content/posts.json` directly before a build. Keep the normalized private state and use the last generated index as the next `--seed` when running in a separate checkout or service directory. - -The service verifies the bot, resolves the exact public channel, and checks administrator membership. An existing webhook is a hard error. It never calls `deleteWebhook`, `setWebhook`, `sendMessage`, or any destructive method. Inspect and consciously change any competing integration outside this service before retrying. - -## Scheduling, persistence, and failure behavior - -A persistent host or small scheduled job every 1-5 minutes is appropriate after live verification. This repository does not create that schedule or deploy the service. Keep one concurrent instance, durable private storage, a secret-backed environment, generic failure alerts, and a timeout long enough for bounded network retries. Ephemeral CI without durable state is not a suitable default. - -Each poll requests at most 100 updates with a 20-second long-poll timeout. Requests have a 35-second socket timeout and at most three attempts. HTTP 429 respects a short server delay; a delay over 30 seconds stops the run so the scheduler can retry later. Each run drains at most 20 batches. Another run continues any backlog. Unwanted update types and other chats are skipped, without persisting their contents. - -The public index is replaced atomically first, then the private cursor is replaced atomically. Only a subsequent request acknowledges the new offset to Telegram. If writing the index fails, the cursor does not advance. If writing the cursor fails after a valid index write, replay is safe and deterministic. A process crash between the writes can leave the valid index ahead of its cursor; rerun from the intact state and index. This is idempotent recovery, not a multi-file filesystem transaction. If the state is lost, restore its backup: simply resetting the cursor does not recover expired updates or previous album membership. - -Ordinary network, configuration, and parsing failures leave the last valid index on disk. If a later batch fails after earlier batches succeeded, those earlier batches remain safely saved. There is no rollback to older, less complete content. A crashed process can leave a `.lock`; first verify no sync process is running, then remove that specific stale lock manually. Never run concurrent writers or share one bot across consumers. - -An authoritative channel-protection change is handled differently from an outage. The service retains the previous index privately, keeps the normalized cache/cursor, and writes `posts: []` with `status: protected` and a `checkedAt` timestamp. It does not present this as a successful content poll or advance `lastSyncedAt`. Repeated protected checks preserve the private recovery copy. After a later verified unprotected response, that seed and the cached messages can be restored through normal polling, with owner exclusions still applied. This does not recover updates that expired while protection was active. A failed write stops preparation without acknowledging updates; an already deployed static snapshot is only removed after a separately approved build/deployment. - -The private cache is bounded to 10,000 messages and each JSON file/response to 4 MiB. Exceeding a bound stops processing before acknowledging that batch; plan an archive strategy before growing beyond it. No messages are silently dropped to fit a limit. - -## Manual removal and public JSON contract - -For confirmed deleted or intentionally excluded post IDs, create a private JSON array, for example `[123, 124]`, and pass `--exclude-ids /private/removed-posts.json` on every run. IDs are examples, not claims about existing channel posts. Apply the same exclusion list consistently; removing a cached ID from the list allows it to appear again. Excluding one member currently removes an overlapping preserved seed album, and a cached album is rebuilt from its remaining unexcluded members. Review the result. - -The output retains the existing fields `channel`, `source`, `verifiedAt`, and `posts` with `id`, `title`, `kind`, `url`, and `note`. Additions: - -| Field | Meaning | -| --- | --- | -| `status` | `incremental` for a saved live poll, `protected` with no public posts for a verified protected channel, or `fixture` for synthetic output | -| `lastSyncedAt` | UTC time of the last saved batch | -| `checkedAt` | UTC time of an authoritative protected-channel check, when applicable | -| `historyComplete` | Always `false` | -| `posts[].origin` | `manual-verified`, `telegram-updates`, or `fixture` | -| `posts[].date` / `editedAt` | Telegram's original publication / edit timestamp when known | -| `posts[].messageIds` | Known member IDs, including the representative ID | -| `posts[].media` | Detected media kinds, not a playback capability guarantee | -| `posts[].excerpt` | Plain text preview, limited to 280 characters | - -Titles use the first real message line or a factual media label when no text exists. Public URLs are constructed only for the known channel. Telegram file IDs, expiring CDN URLs, chat IDs, bot IDs and credentials never enter the public JSON. Public text normalizes the prohibited punctuation to an ASCII hyphen. Render all strings as escaped text; never inject `excerpt` or `title` through raw HTML. Keep official widgets consent-gated and unloaded until requested, with original source links always accessible. - -## Required live acceptance before claiming connection - -Verify one new channel post, an edit, an album delivered across updates, an animated sticker/custom emoji post, a temporary failure and successful recovery, and an owner-excluded deleted post. Check the generated index, source links, official embeds, consent behavior, mobile layout and motion controls. Confirm no token or operational identity appears in logs or built files. These authenticated and real-media checks remain unperformed because credentials were not supplied. Only then update the site's connection wording and approve any automated build or deployment integration separately. - -## Prepared persistent-host scheduling - -The `services/posts-sync/deploy/` directory contains inactive Linux/systemd templates. They are files for operator review, not an installed service. No timer has been enabled and no host provisioned. A persistent host is recommended instead of GitHub Actions cache: the cursor, album membership and public history must survive every run, and a cache miss cannot reconstruct expired events. A hosted workflow must never start a fresh `getUpdates` consumer with a missing cursor. - -The prepared schedule has two independent paths: - -| Schedule | Account and behavior | -| --- | --- | -| Five minutes after the previous posts run ends | `wizardloop-sync` polls supported events into durable public JSON and a separate private cursor. | -| Six hours, at 00:15, 06:15, 12:15 and 18:15 UTC with a short jitter | A profile sync runs first as `wizardloop-sync`; then `wizardloop-build` creates a validated local static archive. | - -The builder has no credential environment or credential mount. Its service denies access to the private cursor and root-only secret directory. It receives only an explicit file allowlist: posts JSON plus profile JSON, JPEGs, and MP4 when the manifest declares video. A shared filesystem lock gives the builder a consistent copy while preventing concurrent snapshot writes. The builder releases the lock before dependency installation or static generation, so builds do not delay ordinary polling. A long sync can delay another sync; monitor duration and failures. - -The builder archives an operator-approved local Git commit, copies the public snapshot into that temporary source tree, runs `npm ci --ignore-scripts` and `npm run check`, then writes `static.zip` and `provenance.json` to `/var/lib/wizardloop-build/artifacts/-/`. The existing build regenerates profile-derived brand assets and checks the static output. Provenance records the source commit, snapshot times, profile source/status and SHA-256 of the archive. It explicitly declares `published: false`. Browser/media acceptance remains a separate release check. No GitHub dispatch, upload, push, merge or deployment happens. - -### Install on a separately approved Linux host - -These are future operator instructions. Do not execute the activation section until live integration and scheduling are explicitly approved. Use a maintained Linux distribution with systemd supporting `LoadCredential`, Python 3.11+, Node satisfying `package.json`, npm, Git, FFmpeg, and fonts needed by the brand generator. Ensure `/usr/bin/npm` points to the intended npm installation, or adapt both the script and reviewed unit paths. Test Linux font rendering because it can differ from the locally measured Windows output. - -1. Create system accounts and private directories. These names are deployment examples used by the templates, not existing infrastructure: - -```sh -sudo groupadd --system wizardloop-content -sudo useradd --system --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin --gid wizardloop-content wizardloop-sync -sudo useradd --system --no-create-home --home-dir /var/lib/wizardloop-build --shell /usr/sbin/nologin --gid wizardloop-content wizardloop-build -sudo install -d -o root -g root -m 0755 /opt/wizardloop -sudo install -d -o wizardloop-sync -g wizardloop-content -m 0750 /var/lib/wizardloop-content -sudo install -d -o wizardloop-sync -g wizardloop-content -m 2750 /var/lib/wizardloop-content/public /var/lib/wizardloop-content/public/profile -sudo install -d -o wizardloop-sync -g wizardloop-content -m 0700 /var/lib/wizardloop-content/private -sudo install -o wizardloop-sync -g wizardloop-content -m 0640 /dev/null /var/lib/wizardloop-content/snapshot.lock -sudo install -d -o wizardloop-build -g wizardloop-content -m 0750 /var/lib/wizardloop-build -sudo install -d -o root -g root -m 0755 /etc/wizardloop -sudo install -d -o root -g root -m 0700 /etc/wizardloop/secrets -``` - -2. Install the explicitly approved repository revision at `/opt/wizardloop/repository`, owned by root and read-only to both service accounts. Use only `WizardLoop/wizardloop.github.io`; pin the reviewed commit from the approved main branch. This is an operator-controlled checkout, not a service that automatically pulls new code. The builder records its exact commit and uses a narrowly scoped Git `safe.directory` setting for that root-owned path. - -```sh -sudo git clone https://github.com/WizardLoop/wizardloop.github.io.git /opt/wizardloop/repository -sudo git -C /opt/wizardloop/repository checkout --detach APPROVED_MAIN_COMMIT -sudo python3 -m venv /opt/wizardloop/profile-venv -sudo /opt/wizardloop/profile-venv/bin/python -m pip install -r /opt/wizardloop/repository/services/profile-sync/requirements.txt -``` - -Replace `APPROVED_MAIN_COMMIT` with the explicitly reviewed full commit hash. Do not use the literal placeholder. Both checkout and environment must remain non-writable by service accounts; dependency updates are separate operator-reviewed changes. - -3. Provision four root-owned, mode `0600` secret files from a secret manager: `/etc/wizardloop/secrets/posts-token`, `profile-api-id`, `profile-api-hash`, and `profile-token`. Use separate bots for posts and profile visibility. Do not print or place values in command history. `LoadCredential` provides each sync unit only its required files. The adapter passes them only to that sync subprocess. See the profile setup document for obtaining the API credentials; no personal Telegram session is used. - -4. Create `/etc/wizardloop/removed-posts.json` as a root-owned, group-readable JSON array, initially `[]`, with group `wizardloop-content` and mode `0640`. This file is always supplied to the scheduled poller. Keep confirmed removals there across every run. - -5. Copy the reviewed service/timer files and validate them on the target system. Copying these files does not enable timers: - -```sh -sudo install -o root -g root -m 0644 /opt/wizardloop/repository/services/posts-sync/deploy/wizardloop-posts.service /etc/systemd/system/ -sudo install -o root -g root -m 0644 /opt/wizardloop/repository/services/posts-sync/deploy/wizardloop-posts.timer /etc/systemd/system/ -sudo install -o root -g root -m 0644 /opt/wizardloop/repository/services/posts-sync/deploy/wizardloop-profile.service /etc/systemd/system/ -sudo install -o root -g root -m 0644 /opt/wizardloop/repository/services/posts-sync/deploy/wizardloop-build.service /etc/systemd/system/ -sudo install -o root -g root -m 0644 /opt/wizardloop/repository/services/posts-sync/deploy/wizardloop-build.timer /etc/systemd/system/ -sudo systemd-analyze verify /etc/systemd/system/wizardloop-posts.service /etc/systemd/system/wizardloop-posts.timer /etc/systemd/system/wizardloop-profile.service /etc/systemd/system/wizardloop-build.service /etc/systemd/system/wizardloop-build.timer -sudo systemctl daemon-reload -``` - -The global `/etc/wizardloop/ENABLE_CONTENT_SYNC` gate is intentionally absent after installation. All services refuse to run without it. Unit parsing, OS permissions, lock behavior, Node dependencies and actual execution must be checked on the intended Linux host; this Windows development environment has not executed systemd. - -### Separate approval: live acceptance and scheduling - -Only after explicit approval, create the gate and run the first poll manually. It must succeed before building, because the builder rejects a fixture or an unconnected manual snapshot as a live input. It accepts an authoritative `protected` status only when the public posts array is empty, allowing a reviewed build to remove stale embeds: - -```sh -sudo install -o root -g root -m 0644 /dev/null /etc/wizardloop/ENABLE_CONTENT_SYNC -sudo systemctl start wizardloop-posts.service -sudo systemctl start wizardloop-build.service -``` - -The build service starts its profile prerequisite. Inspect generic journal events, manifests, source links, media, the generated archive and provenance; perform the acceptance list above. Exit success can still mean profile fallback or retention, so inspect `profileSource` and `profileStatus`. If approved, enable the timers: - -```sh -sudo systemctl enable --now wizardloop-posts.timer wizardloop-build.timer -sudo systemctl list-timers wizardloop-posts.timer wizardloop-build.timer -``` - -Set host monitoring to alert on failed units, stale successful-poll timestamps, growing disk use and stale locks well before 24 hours. The templates do not install an alert destination. Keep encrypted private-state backups and a bounded artifact-retention policy. Never copy the entire `/var/lib/wizardloop-content` directory into a public artifact. Rotate bot secrets by replacing only the corresponding root-owned file; stop both timers while moving between hosts to avoid competing consumers. - -To stop future scheduling, disable both timers and remove the single gate file. Allow a currently running job to finish before making state changes. A forced process termination may leave the service's inner `.lock` or `.sync.lock`, which needs the manual recovery described earlier. Do not delete a lock while another process owns it. - -### Review and later delivery to GitHub Pages - -Prepared archives remain private on the host. An authorized operator can copy only `static.zip` and `provenance.json` to a review location and independently verify the SHA-256. The archive contains only the validated `dist` tree, including the existing CNAME, with no cursor, source checkout, dependencies or credentials. Local archive preparation is not a Pages deployment. - -A future approved delivery path can use a GitHub App with the minimum repository Actions permission to dispatch a separate artifact-import/build workflow from the approved main branch. That workflow must verify an immutable bundle hash/provenance and upload only the static artifact; a distinct protected Pages job must require the owner's deployment approval. It should consume a public-content snapshot from this service, never poll `getUpdates` or restore cursor state from an optional Actions cache. No such GitHub App, workflow dispatch credential, remote upload endpoint or automated delivery is implemented or active here because those require choosing and approving the host and delivery trust boundary. The fully prepared local bundle remains usable through the normal approved review/deployment process without those integrations. - -Primary systemd references: [timer behavior](https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html) and [credentials and execution isolation](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html). Offline adapter tests verify secret-free build environments, exact public-file copying, fixture/path rejection, stale-video removal, safe archive extraction and inactive unit gates. They do not claim Linux host acceptance or live scheduling passed. diff --git a/docs/PROFILE-SYNC.md b/docs/PROFILE-SYNC.md deleted file mode 100644 index 731e6c7..0000000 --- a/docs/PROFILE-SYNC.md +++ /dev/null @@ -1,127 +0,0 @@ -# Profile synchronization - -This service is implemented for local review. No Telegram credentials have been supplied, no bot login has been attempted, and authenticated profile or video downloads remain untested. Offline fixtures exercise selection, fallback, output and safety behavior without claiming that a Telegram account has been synchronized. - -## Public output contract - -Run the service from the repository root. Its default output directory is the repository's `public/media/profile`, anchored to the script location rather than the current working directory. The site may read its JSON during a static build; the browser never receives Telegram credentials or talks to MTProto. - -| File | Purpose | -| --- | --- | -| `avatar.jpg` | Validated current profile still, GitHub fallback or local mark. | -| `profile.mp4` | Best accessible full Telegram profile animation. Absent when unavailable. | -| `poster.jpg` | Frame selected with Telegram's timestamp when FFmpeg succeeds; otherwise the profile still. | -| `profile.json` | Allowlisted public metadata and stable site-relative asset URLs. | - -The manifest fields are `source`, `avatar`, `video`, `poster`, `bio`, `bioSource`, `syncedAt`, `checkedAt`, `status`, `posterSource`, `width`, `height` and `mediaNote`. `video` is either `/media/profile/profile.mp4` or `null`. `source` is `telegram`, `github` or `local`; `status` is `current`, `fallback`, `retained` or `fixture`. `syncedAt` remains unchanged when retaining a previous snapshot after a source outage. `checkedAt` tracks the latest attempt. A fixture status and note must never be presented as a successful live sync. - -The service does not create favicons, Apple icons or social compositions. After a changed snapshot, run the site's asset-generation and static-build pipeline so those assets and Schema.org image references are derived from the same snapshot. Keep their public URLs stable. Publish a complete validated build only through the separately approved deployment process. - -## Selection and privacy rules - -The bot resolves the known `wizardloop` username and reads `users.getFullUser`. It uses `User.photo.photo_id` as the active identity. A matching `full_user.profile_photo` is preferred; otherwise up to five pages of `photos.getUserPhotos` are searched for that exact ID. Upload dates, list order and the highest photo ID never determine the primary. An unresolved identity causes retention or fallback, not a guess. - -Contact-specific `personal` photos and the separately identified personal/fallback profile fields are rejected. A confirmed primary removal or privacy change prevents retaining the old Telegram image. The bot should be dedicated to this purpose and should not be added as a contact to expand profile visibility. The owner must verify the bot-visible biography and image are intended for public publication before enabling a live schedule. - -Only `video_sizes` type `u` is accepted as full profile MPEG4. Type `p` is a small preview and is never silently substituted. When more than one full rendition is exposed, the largest pixel area and then file size wins. This is the best rendition Telegram makes available, not a claim to retrieve an unavailable pre-upload original. Original MP4 bytes are preserved; the service does not transcode or reduce their quality. Oversized or invalid media is rejected instead of choosing an inferior rendition. - -The current photo identity is checked again after download. File reference failures trigger a fresh lookup before a retry. If the full animation is inaccessible but the current still is valid, the manifest explicitly describes the still-only result. `video_start_ts` controls the optional poster extraction. Missing FFmpeg or failed extraction leaves a valid still poster and an honest `posterSource`. - -Telegram's supported `about` field is the first biography source. If absent, the public GitHub biography is read independently of GitHub image downloading. The editable local biography is last. Biography text is limited to 500 characters, control characters are removed and U+2014 is converted to a normal hyphen. Render this field as escaped text, never as HTML. - -## Isolated setup - -Use Python 3.11 or newer. Create a dedicated environment rather than installing dependencies into a system Python: - -```sh -python -m venv services/profile-sync/.venv -``` - -Linux/macOS: - -```sh -services/profile-sync/.venv/bin/python -m pip install -r services/profile-sync/requirements.txt -services/profile-sync/.venv/bin/python -m unittest discover -s services/profile-sync -p 'test_*.py' -v -``` - -Windows PowerShell: - -```powershell -services/profile-sync/.venv/Scripts/python.exe -m pip install -r services/profile-sync/requirements.txt -services/profile-sync/.venv/Scripts/python.exe -m unittest discover -s services/profile-sync -p 'test_*.py' -v -``` - -Telethon 1.45.0 and Pillow 13.0.0 were verified against their official PyPI releases on 2026-10-27. Resolved transitive dependencies are pinned in the same requirements file. Recheck releases and advisories when updating; do not let an unreviewed upgrade silently change Telegram schema handling. - -## Credentials - -Supply these only through the host's secret store or a private process environment: - -| Variable | Meaning | -| --- | --- | -| `TELEGRAM_API_ID` | API application ID issued by Telegram. | -| `TELEGRAM_API_HASH` | Its private API application hash. | -| `TELEGRAM_BOT_TOKEN` | Dedicated bot token issued by BotFather. | -| `PROFILE_LOCAL_BIO` | Optional editable public fallback biography. | -| `FFMPEG_BINARY` | Optional path to an installed, maintained FFmpeg binary. Otherwise `PATH` is searched. | - -The service uses bot authorization with a fresh `MemorySession`. It never prompts for a phone number, exports sessions or writes a `.session` file. Personal sessions are deliberately unsupported. Setting `TELEGRAM_SESSION`, `TELEGRAM_STRING_SESSION` or `TELEGRAM_PHONE` produces a configuration error. Do not pass secrets as command-line arguments, commit a private environment file or store credentials inside the public output directory. No API responses, access hashes, user IDs or session data are serialized into the manifest. - -The application ID/hash can be obtained through [Telegram API development tools](https://my.telegram.org). Create the dedicated bot through the official [BotFather setup](https://core.telegram.org/bots/features#botfather), then provision its token privately. This service does not need a destination chat ID and never sends a Telegram message. - -## Offline fixture and manual runs - -The fixture command makes no network requests and requires a separate output directory. The real repository profile directory is rejected even when the command is invoked from another working directory. It generates a synthetic local mark, labels its result `fixture`, and never invents an animated profile: - -```sh -python services/profile-sync/profile_sync.py --fixture services/profile-sync/fixtures/active-still.json --output services/profile-sync/test-output -``` - -Use the dedicated environment's Python executable in place of `python` when it is not activated. With private credentials provisioned, the manual sync command is: - -```sh -python services/profile-sync/profile_sync.py --force -``` - -An optional `--local-avatar path/to/approved-avatar.jpg` overrides the generated local mark. Without `--force`, a valid recent manifest avoids repeat requests for six hours. Missing Telegram credentials allow the public GitHub fallback or a valid existing snapshot; that is not a successful Telegram integration. Safe log events report only source, status, whether video exists and poster provenance. Raw exceptions, biographies and credentials are not logged. - -Exit `0` means a usable snapshot or cache was produced, including fallback or retention. Exit `1` means the operation failed. Exit `2` means setup or an existing output lock needs attention. Inspect `status` rather than treating an exit code alone as proof that Telegram succeeded. - -## Separate host and proposed schedule - -Nothing is deployed or scheduled by these files. A future authorized setup can run as a short-lived process on a small server or private CI runner: - -1. Install Python, the pinned environment and optionally FFmpeg on the separate host. -2. Provision bot secrets outside the checkout and public document root. Use a dedicated operating-system account with only the necessary output access. -3. Run the command against a private staging copy of the site's `public/media/profile` directory. Keep the previous valid snapshot between runs. -4. Validate the JSON and local assets, run the site's derivative-asset pipeline, then build and test the full static site. -5. Submit the generated public asset changes for review. Publishing requires the user's separate merge/deployment approval. - -A conservative future schedule is once every six hours, with a manual trigger. Use a scheduler concurrency limit of one, a finite overall job timeout, and notifications for sustained failure. Do not place secrets in a scheduler command string. No remote workflow, cron job, credential or production setting has been created here. - -## Failure and output behavior - -- Ordinary Telegram network/API failure preserves a valid previous Telegram snapshot. A first run falls back to GitHub and then the local mark. Existing GitHub fallback data can refresh on later runs. -- A confirmed missing/personal primary prevents stale Telegram retention. If visibility changes are uncertain because the service cannot connect, the last known valid image remains until a successful check or manual owner removal. -- Requests have bounded retries, timeouts and backoff. Telegram flood waits over 30 seconds stop the attempt. Image bytes are limited to 12 MiB and video bytes to 64 MiB; decoded images are limited to 16 million pixels. Downloads are held in bounded memory and validated before publication. -- GitHub requests use fixed public identity and allowlisted HTTPS hosts. Redirects are revalidated. No GitHub token or browser scraping is needed. -- JPEG normalization removes source metadata without reducing pixel dimensions. MP4 container checks reject truncated or non-MP4 payloads; they are not a substitute for real cross-browser playback testing. -- A lock prevents concurrent writers. Each file replacement is atomic, the manifest is committed last, and ordinary write failures restore prior files. This is not a multi-file filesystem transaction or a live-hosting atomic deployment. Build from the output only after the process exits and publish the complete static build as a unit. -- A killed process can leave `.sync.lock`. Confirm no sync process is running before removing that one lock file. Never publish lock or `.pending` files. No automatic stale-lock deletion can accidentally release a live worker's lock. - -## Verification and remaining acceptance tests - -Offline tests cover active ID selection against newer history, personal/fallback rejection, missing and full video variants, original rendition ranking, still-only output, independent biography fallback, cache behavior, outage retention, GitHub/local fallback, privacy removal, safe download hosts, write rollback, timestamped poster command arguments and non-secret logs. Telethon constructor signatures are checked when the pinned package is installed. - -Measured local result on 2026-10-27: 31 tests passed with zero failures, errors or skips under Telethon 1.45.0 and Pillow 13.0.0. The tests include independent biography fallback when an avatar download fails and rejection of fixture writes to the real profile directory from another working directory. The separate fixture command completed with `status: fixture`, `video: null` and valid 640 x 640 JPEG assets. Python compilation and the U+2014 source-content check also passed. The ignored `services/profile-sync/test-output/test-results.json` records the local test count and dependency versions. - -Authenticated acceptance still requires an authorized bot and owner review: confirm bot visibility of the active primary; compare an older photo deliberately selected as primary against a newer history entry; verify a real `u` video download and intended poster frame; test an actual file-reference refresh; confirm profile removal/privacy behavior; and play the resulting MP4 in Chrome, Firefox and WebKit. The UI must use `muted`, `loop`, `playsinline`, stable dimensions, a pause control and a still under reduced motion. No report in this repository claims these authenticated checks passed. - -## Primary references - -- [Telegram UserProfilePhoto](https://core.telegram.org/constructor/userProfilePhoto), [userFull](https://core.telegram.org/constructor/userFull) and [photos.getUserPhotos](https://core.telegram.org/method/photos.getUserPhotos). -- [Telegram video size types](https://core.telegram.org/api/files#videosize-type-values), [videoSize](https://core.telegram.org/constructor/videoSize) and [InputPhotoFileLocation](https://core.telegram.org/constructor/inputPhotoFileLocation). -- [Bot authorization](https://core.telegram.org/method/auth.importBotAuthorization) and [users.getFullUser](https://core.telegram.org/method/users.getFullUser). -- [Official Telethon client documentation](https://docs.telethon.dev/en/stable/modules/client.html), [Telethon on PyPI](https://pypi.org/project/Telethon/) and [Pillow on PyPI](https://pypi.org/project/Pillow/). - -New service code is MIT under the repository license. Installed package metadata identifies Telethon and pyaes as MIT, Pillow as MIT-CMU, rsa as Apache-2.0, and pyasn1 as BSD-2-Clause. Third-party libraries retain those licenses. Profile media and owner branding are not relicensed by this service. diff --git a/docs/QA-INTEGRATIONS.md b/docs/QA-INTEGRATIONS.md index 9cf4226..eb2a425 100644 --- a/docs/QA-INTEGRATIONS.md +++ b/docs/QA-INTEGRATIONS.md @@ -1,43 +1,20 @@ -# Integration verification +# Current external-service boundaries -Reviewed locally on **2026-10-27** for the unreleased `1.0.0-rc.1` build. These results do not establish production readiness, authenticated Telegram synchronization or real feedback delivery. See [QA.md](QA.md) for the final website/browser and performance review. +Scope updated on **2026-10-28** for the static `1.0.0-rc.2` source candidate. This document describes the intended and implemented architecture; current measured validation belongs in [QA.md](QA.md). It does not claim that this candidate has been deployed. -## Offline services +## Static behavior -| Component | Observed result | What the result covers | -| --- | --- | --- | -| Profile synchronization | **31 passed, 0 failures, 0 errors, 0 skips** | Active-primary identity matching, video/still selection logic, independent bio fallback, caching, privacy removal, safe output paths, bounded failures and last-good preservation. Telethon constructor checks ran with Telethon 1.45.0 and Pillow 13.0.0. | -| Posts synchronization | **45 passed** | 33 ingestion/state tests plus 12 deployment-adapter tests. Includes albums across batches, edits, cursor ordering, exclusions, protected-channel removal, private recovery, build isolation and inactive deployment gates. | -| Feedback service | **22 passed** | Provider-mocked validation, Turnstile verification, restrictive origins, rate limiting, safe Telegram formatting, timeouts, uncertain delivery and the loopback-only HTTP mock. No message was sent to Telegram. | +- Profile copy and media are local repository assets, updated manually. No Telegram or GitHub profile request runs in the browser or build. +- Work and Posts contain ordinary links to Telegram. There is no embedded feed, remote widget loader, scheduled importer or post-sync service. +- Projects links to the owner's GitHub repositories. It does not need an API token or a browser-side repository request. +- Contact at the retained `/feedback/` route provides verified Telegram and email links. It has no form, CAPTCHA, submission endpoint or message-delivery bot. +- Fonts, icons, illustrations, video and motion-player code are served with the static website. No external content consent is needed to load a third-party widget because the current site does not embed one. +- Theme and motion preferences remain local browser preferences. The relevant policy pages explain these and hosting/external-destination behavior. -Profile evidence is recorded in the ignored `services/profile-sync/test-output/test-results.json`. The posts and feedback counts are the observed local test-run results; their reproducible commands are: +A visitor who follows an external link leaves this website and uses that service directly. This distinction does not imply that GitHub Pages or a destination service collects no technical information. -```sh -python -m unittest discover -s services/profile-sync -p 'test_*.py' -v -python -m unittest discover -s services/posts-sync -p 'test_*.py' -npm run test:unit -``` +## Operational requirements -Install the profile service's pinned requirements in an isolated environment first. Independent regression evidence in `artifacts/sync-service-review.json` confirms that an accessible GitHub bio survives avatar failure, a fixture cannot overwrite the real profile directory from another working directory, and an authoritative protected-channel response produces an empty public index. Private cursor/history remain available for recovery; removing an already deployed snapshot still requires an approved deployment. +Only GitHub Pages and the repository build workflow are needed. There are no bot credentials, account sessions, private numeric chat IDs, remote schedulers or separate application hosting requirements. Pages must use the GitHub Actions publishing source as described in [DEPLOYMENT.md](DEPLOYMENT.md). -## Real public Telegram embeds - -The local Posts page was tested in Chromium against the real public provider, without bot credentials or send actions. The initial observation is timestamped **2026-10-27T03:18:49.901Z** in `artifacts/final/telegram-live-results.json`. - -- No Telegram requests occurred before consent. Official post frames for [51](https://t.me/wizard_loop/51), [52](https://t.me/wizard_loop/52), [53](https://t.me/wizard_loop/53) and [54](https://t.me/wizard_loop/54) loaded after consent. -- The inspected images loaded. Video metadata was available for posts 53 and 54. The motion follow-up made post 54 visible and explicitly invoked muted playback: its video advanced from 0 to **1.326075 seconds**, with `paused: false` and `readyState: 4`. This verifies that video's playback, not automatic playback of every embed. -- Pausing site motion removed all embedded frames. Revoking consent stored `no`. Telegram set its own cookies after consent; this supports the external-content disclosure. -- The provider returned **HTTP 500** for one `cdn4.telesco.pe/file/sticker.tgs` request. Two MP4 requests were recorded as aborted. The initial run recorded no console errors, but those external network failures remain part of the result. -- Custom-emoji/sticker canvas markup was present. In the visible follow-up, post 52's sampled canvas hashes did not change over the observation interval. **Animated TGS and custom-emoji fidelity are not proven.** Original Telegram links remain necessary; a loaded frame or canvas is not proof of complete media playback. - -Evidence: `artifacts/final/telegram-live-motion-results.json`, `telegram-live-visible-52.png`, `telegram-live-visible-54.png` and the two `telegram-official-post-*.png` screenshots. These ignored local artifacts describe that run, not a permanent provider guarantee. - -## Local feedback UI - -`artifacts/final/feedback-ui-results.json` records required-field focus, rejection before verification, keyboard submission to the actual loopback HTTP mock, uncertain-delivery messaging, retry with new local verification, and simulated rate-limit recovery. It explicitly records `realTelegramDelivery: false`, `realCAPTCHA: false` and `localHttpMockVerified: true`. - -## Remaining acceptance - -Authenticated primary-profile/video access, real MP4/poster selection and cross-browser playback need configured MTProto credentials and owner verification. Live posts ingestion, edits and scheduling have not been activated. Real feedback requires an approved Worker endpoint, Turnstile credentials, verified numeric private Chat ID and a separately authorized delivery test. Read [Profile sync](PROFILE-SYNC.md), [Posts sync](POSTS-SYNC.md) and [Feedback setup](FEEDBACK-SETUP.md) before these steps. - -No complete human screen-reader audit or legal/accessibility certification has been performed. No production deployment, merge, tag or release is represented by this report. +Earlier provider tests and offline service counts applied to removed components. Their reports remain in Git history and must not be cited as evidence for this revision. Native media behavior, accessibility, link checks and deployment verification still require the tests described in the current QA and deployment guides. No complete legal or accessibility conformance is asserted. diff --git a/docs/QA.md b/docs/QA.md index f8966a1..8647dfd 100644 --- a/docs/QA.md +++ b/docs/QA.md @@ -1,114 +1,55 @@ -# Website quality review +# Quality review -Local review on **2026-10-27**, Windows, for unreleased `1.0.0-rc.1`. The selected Spectrum Circuit direction now uses WizardLoop's supplied wizard artwork, comic framing, local social icons and complete light/dark layouts. This report separates observed results from integrations that still need credentials or operational approval. +Report prepared on **2026-10-28** for static candidate **1.0.0-rc.2**, branch `simplify/static-contact`, based on `main` commit `3316bc7` after PR #3 merged. This candidate has not been merged or deployed; no DNS change was made. The Pages publishing source remains unverified; see [DEPLOYMENT.md](DEPLOYMENT.md). -## Review locally +## Current scope -```sh -npm ci -npm run dev -``` +Local profile/media and direct links replace the previous integrations. Work/Posts lead to Telegram, Projects to all repositories, and Contact retains `/feedback/`. There is no backend, CAPTCHA, embedded feed, synchronization or required credential. Earlier service tests do not validate this revision. -Open [the website](http://127.0.0.1:4322), [the artwork collection](http://127.0.0.1:4322/work/), [posts](http://127.0.0.1:4322/posts/) or [feedback](http://127.0.0.1:4322/feedback/). Use the header controls to change theme and pause motion. The original three design studies run separately with `npm run dev:concepts` on port 4321; they are excluded from the final static build. +UX changes include email copying where supported, a sticky mobile-menu close control, current-page labeling and an infinity SVG. Native hero/artwork playback retains still previews and motion controls. -For a production-like local review, stop the development server, run `npm run build`, then `npm run preview -- --host 127.0.0.1 --port 4322`. Neither path publishes the site. +## Completed source and build checks -### Actual browser captures +- `npm run check` passed: source checks inspected **231 files**, Astro reported **0 errors, 0 warnings and 0 hints across 24 files**, and build checks validated **12 routes**. +- The dependency audit reported **0 known vulnerabilities**, not a security certification. +- Read-only checks confirmed matching CNAME values, `dist/`-only publication and the main/repository guard. The build scan found no tested secret patterns, removed service runtime, external HTML resources, forms or iframes. +- Documentation links, version consistency and service removal were checked. Account settings and live production remain unverified. -| Theme | Desktop, 1440 x 1000 | Mobile, 390 x 844 | -| --- | --- | --- | -| Light | [View desktop](previews/home-light-desktop.png) | [View mobile](previews/home-light-mobile.png) | -| Dark | [View desktop](previews/home-dark-desktop.png) | [View mobile](previews/home-dark-mobile.png) | +## Browser verification -These PNGs were captured from the built site with Playwright, with motion paused for reproducibility. Full-page route captures remain under `artifacts/final/`. `artifacts/final/wizardloop-home-animation.webm` records actual native hero animation; `hero-live-animation.png` captures a playing frame. The running local preview is the interactive example. +The final focused run passed **96 of 96 tests**. Combined with unaffected checks from the initial run, the latest result for each of **171 unique browser checks is passing**, with no unresolved failures or skips: Chromium 74, Firefox 48, WebKit 49. This is a combined local result, not a green full-suite rerun. [browser-summary.json](qa/browser-summary.json) retains both run histories. The GitHub check must independently validate the complete suite on the pushed commit. -## Browser acceptance +The initial run recorded **131 passed and 4 failed out of 135 tests**. Three failures incorrectly expected an avatar on Home; the corrected test checks About and passed in every engine. The fourth was a Firefox menu input timeout. Its trace eventually showed the destination loaded; the unchanged interaction then passed on the final build. No action deadline or functional assertion was relaxed to resolve that failure. -### PR CI follow-up, 2026-10-28 +The completed layout matrix covers 12 routes in Chromium, Firefox and WebKit at 320, 360, 390, 768, 1024 and 1440px in both themes: **432 viewport cases**. Twenty-four axe checks passed across six main pages, both themes and mobile/desktop. Other passing checks cover keyboard access, preference persistence, external-request absence, direct URLs, static SEO/social assets, local profile fallback and native motion. Clipboard UI checks use deterministic success/denial/no-API fixtures and verify focus and stable layout at 320px; they do not send messages or inspect the user's clipboard. -The [first PR quality run](https://github.com/WizardLoop/wizardloop.github.io/actions/runs/40461052552) on Ubuntu 24.04 recorded **137 browser tests passed and 2 failed**. Formatting, source checks, typechecking, build, feedback unit tests and both Python test suites passed. The failures were confined to native-media tests in Linux WebKit: +Each matrix test contains 12 viewport steps and four full-page captures. Its overall deadline is 120 seconds to accommodate capture work; individual action deadlines remain unchanged. The full suite contains 171 cases on Windows and 170 on Linux because one diagnostic specifically targets Windows WebKit. That platform can stop native H.264 looping; tests verify the visible still fallback rather than claiming uninterrupted playback where the independent media probe fails. -- The hero advanced through its first clip, then its clock stopped while `paused` remained `false`. Listening only for `pause` and `error` missed that state. A lightweight progress monitor now returns to the labeled still preview after four seconds of observed nonprogress. It stops while motion is disabled, the hero is offscreen, the document is hidden, or the page is suspended. A long timer scheduling gap resets observation rather than misclassifying a busy main thread. -- The canvas-stream fixture painted before capture started and never produced another frame. Its unbounded `play()` wait timed out. The fixture now paints after capture starts, keeps its source and player visible, uses an explicit start gesture, and asserts playback and clock progress with bounded waits. The later Linux run verified actual canvas-stream playback in WebKit as well as Chromium and Firefox. +## Performance -The native-media test now first measures the same MP4 in a bare video element without site code, attaching its timeline to the report. If that independent probe sustains playback, the full loop, theme and pause/resume assertions remain required. If it demonstrably stops or stalls, the test explicitly verifies the real site's still fallback and records the unsupported native loop in a platform annotation. It does not report fallback as successful uninterrupted playback. A separate frozen-clock fixture verifies the new monitor and preserves an intentional pause longer than its deadline. +Lighthouse 13.5.0, local mobile simulation, sequential audits without concurrent acceptance browsers. All seven final samples and earlier diagnostic readings are retained in [lighthouse-summary.json](qa/lighthouse-summary.json). -The frozen-clock regression uses controlled timers to verify both an intentional five-second pause and a delayed-callback gap before requiring sustained nonprogress to trigger fallback. The [subsequent Linux run](https://github.com/WizardLoop/wizardloop.github.io/actions/runs/40651474776) passed all media checks, with **141 passed and one unrelated Firefox failure**: the long-feed fixture timed out waiting for `networkidle`. That fixture now waits for its actual six-card UI state; the checks for 6/12/13 items, focus, consent and retained embed identity remain intact. +| Page | Samples | Performance | Observed range | LCP | TBT | CLS | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| Home | 3 | 98 median | 94-98 | 2.24 s median | 51 ms median | 0 | +| Work | 1 | 100 | 100 | 1.51 s | 0 ms | 0 | +| Projects | 1 | 100 | 100 | 1.53 s | 0 ms | 0 | +| Posts | 1 | 100 | 100 | 1.52 s | 0 ms | 0 | +| Contact | 1 | 100 | 100 | 1.53 s | 0 ms | 0 | -The AVC manifest also now records the encoded codec `avc1.424029`, independently of the encoder's requested `avc1.42001F`; the original MP4 bytes are unchanged. Review the latest PR check run for the remote result of these corrections. The local results below describe the earlier review and remain historical evidence. +Accessibility, Best Practices and SEO scored **100 in all seven final samples**. The four destination pages have one final sample each, not a repeatability distribution. These are lab measurements, not field Core Web Vitals or a production guarantee. -### Earlier local browser review, 2026-10-27 +Earlier Home readings were 63, 35 and 37; changing page order produced a diagnostic 91. A browser trace identified expensive initial layout and delayed body-font requests before animation startup. Preloading the approximately 25 KB local Manrope font produced the final 98, 94 and 98 readings. The existing artwork and animation remain intact. No audit-specific behavior, user-agent detection or benchmark delay is used. -The complete serial run recorded **129 passed, 6 failed and 1 skipped** across 136 cases. Follow-up checks corrected no-JavaScript/HTML-attribute test assumptions, exposed the Windows native looping failure described below, and verified its graceful fallback after the application fix. The final affected-behavior run recorded **15 passed, 0 failed and 1 explicitly unsupported check** across 16 cases. +## Visual evidence -The latest result for each case is **138 passed, 0 unresolved failures and 2 skipped** across 140 unique engine/test combinations: Chromium 69 passed, Firefox 35 passed, and Windows WebKit 34 passed with 2 explicit limitations. This combines the complete run and focused rechecks; it is **not** a claim that a fresh complete local run was green. [The machine-readable summary](qa/browser-summary.json) preserves all six run histories and their failures. Full reports remain in `artifacts/final/`. +These real Playwright captures were refreshed from the final build with motion paused for reproducibility. Review both themes and mobile layouts: -The two skipped checks are uninterrupted native looping in this Windows WebKit installation and its unsupported `canvas.captureStream()` profile-video fixture. A separate real-media check passed for the Windows native-loop fallback. Chromium and Firefox passed sustained hero playback across a full loop boundary, theme changes and intentional pause/resume. +- Home: [light desktop](previews/home-light-desktop.png), [dark desktop](previews/home-dark-desktop.png), [light mobile](previews/home-light-mobile.png), [dark mobile](previews/home-dark-mobile.png). +- Work: [light desktop](previews/work-light-desktop.png), [dark mobile](previews/work-dark-mobile.png). +- Contact: [light desktop](previews/contact-light-desktop.png), [dark mobile](previews/contact-dark-mobile.png). +- More destinations: [Projects desktop](previews/projects-light-desktop.png), [Posts mobile](previews/posts-dark-mobile.png). -Coverage includes all 12 routes, direct loads, local assets and browser errors; Chromium layouts at 320, 360, 390, 768, 1024 and 1440px in both themes; and selected Firefox/WebKit interaction coverage. The complete six-width matrix is not claimed for every engine. +## Remaining limits -Interaction checks cover keyboard navigation, visible focus, skip links, modal focus containment/return, Escape, fixed header, saved preferences, manual motion pause, reduced motion, tooltip dismissal, real native hero playback, Canvas motion, gallery reveal/focus, profile fallbacks, JavaScript-free content and metadata, consent-gated posts, pagination and feedback failures. Provider-mocked tests are identified as such. - -The final accessibility scan set uses axe-core on Home, About, Work, Posts and Feedback at desktop/mobile sizes and in both themes. Automated checks do not replace a human screen-reader audit or establish legal conformance. - -### Windows WebKit media behavior - -The installed Windows WebKit uses its native Media Foundation backend. Its reported `videoWidth`/`videoHeight` can reflect destination dimensions, and native media requests can bypass Playwright route interception. This was checked against [the exact pinned WebKit source](https://github.com/WebKit/WebKit/blob/56453fdfe0b0ca6258c23e6453b34f70b885d13f/Source/WebCore/platform/graphics/win/MediaPlayerPrivateMediaFoundation.cpp#L1769): `BeginCreateObjectFromURL` loads the URL directly, destination dimensions configure output, and the resulting frame size becomes `naturalSize`. - -The MP4 container and Chromium/Firefox decoding verify 768 x 768 source dimensions. Windows WebKit reports its native display size instead; failed-media tests use a genuinely missing server URL because interception is bypassed. - -An uninterrupted probe also reproduced a native looping failure: both the real site and a bare video element advanced through the first 2.4-second clip, then became paused near the loop boundary without a JavaScript `pause()` call. Identical later video-frame captures confirmed that playback had stopped. This occurred before any screenshot, so an earlier screenshot-interference hypothesis was rejected. The native source sets a paused flag when a session ends, consistent with the observation, but the exact upstream cause is not established. Evidence is retained in `artifacts/final/webkit-capture-side-effect-probe.json`. - -The site now returns to its still preview after an unexpected native pause while playback is allowed, instead of repeatedly forcing a restart. User pauses, theme transitions, offscreen/hidden content and stale queued pause events remain separate cases. A dedicated Windows WebKit check verifies the real fallback; successful native looping on this installation is explicitly unsupported, not reported as passing. Actual Safari on Apple hardware remains unverified. - -## Measured performance - -Lighthouse **13.5.0**, mobile simulation, built local preview, three sequential runs with no concurrent acceptance browsers. [All sample summaries](qa/lighthouse-summary.json) are committed. Full HTML/JSON reports remain in `artifacts/lighthouse-review/`, `artifacts/lighthouse-review-2/` and `artifacts/lighthouse-review-3/`. - -| Page | Performance median | Observed range | Accessibility | Best Practices | SEO | Median LCP | Median TBT | Median CLS | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | -| Home | 92 | 59-97 | 100 | 100 | 100 | 2.58 s | 133 ms | 0.00005 | -| Work | 95 | 94-96 | 100 | 100 | 100 | 1.49 s | 241 ms | 0 | -| Posts | 100 | 66-100 | 100 | 100 | 100 | 1.52 s | 49 ms | 0 | -| Feedback | 99 | 98-100 | 100 | 100 | 100 | 1.67 s | 23 ms | 0.03183 | - -Accessibility, Best Practices and SEO scored 100 in every one of these samples. Performance medians meet the 90+ target, but **not every run does**. Home and Posts had substantially slower initial-render outliers in the first run. Local machine and rendering variability remain material; these are lab results, not field Core Web Vitals or a guarantee of production performance. Median metrics are calculated individually rather than taken from one selected run. - -These samples include the original performance changes. The first precedes the paused-menu CSS fix; later tooltip hover/Escape fixes do not alter the initial, unfocused rendering path. The subsequent native-pause fallback was event-driven. The PR follow-up adds a 500ms progress sample only during permitted native playback; these Lighthouse measurements predate that monitor and are not a fresh benchmark of it. No audit-specific user-agent detection, animation suppression or benchmark delay is used. - -### Improvements supported by measurements - -- The original in-page SVG animation implementation measured Home performance 43 and about 16.4 seconds of total blocking time. The native hero now plays a 768 x 768 H.264 loop from the original animation, with a still poster, no audio and no video request under initial reduced motion. Separate theme files are approximately 645 KB and 635 KB. -- The 36 quality-94 WebP delivery images total 1,309,102 bytes, compared with 3,117,278 bytes for the preserved lossless masters, a reduction of about 58%. Source TGS files, dimensions and alpha channels are preserved. -- Work renders nine initial cards and reveals the remainder on request. Hidden cards do not request their previews during normal initial loading, while a no-JavaScript visitor can access all 30. -- Canvas playback is limited to three visible scenes and 24 drawn frames per second, while elapsed-time sampling preserves the original animation duration. Offscreen/hidden-document playback pauses. - -## Source, build and service checks - -Formatting, Astro/TypeScript checking and static generation passed. The last typecheck reported **0 errors, 0 warnings and 0 hints**. Static checks cover unique titles, HTML metadata without JavaScript, local links/assets, social images, domain preservation, exclusion of experiments/private services and basic secret patterns. Source checks enforce the ban on U+2014. The npm dependency audit reported **0 vulnerabilities** at review time; this is not a security certification. - -Offline service tests passed: **31 profile tests, 45 posts tests and 22 feedback tests**. Read [integration verification](QA-INTEGRATIONS.md) for scope and provider observations. A read-only security review found no additional concrete issue in the feedback validation, private-chat setup, public snapshot boundary, isolated synchronization adapters or workflow permissions. - -Quality CI is defined with read-only repository permissions and cannot publish Pages. Its actual remote result must be inspected separately. A manual deployment workflow exists but has not been run. - -## External limitations and remaining approval - -- Authenticated Telegram primary-profile/video retrieval is unverified. The public still snapshot is not claimed to be an authenticated original or the active primary video. Last-good assets and GitHub/local fallbacks remain available. -- Real public Telegram widgets loaded only after consent. A video in post 54 played in the observed run. A provider sticker request returned HTTP 500, and animated TGS/custom-emoji fidelity was not established. Original post links remain available and these limits are disclosed. -- Feedback validation and UI states were exercised with a loopback mock and mocked providers. There is no approved public Worker, configured Turnstile service or verified real private-message delivery. The public form remains unavailable until configured. -- Sync schedules and Linux/systemd deployment adapters are prepared but inactive. Their intended hosting environment and real provider credentials still need verification. -- A complete human screen-reader audit and native browser-chrome zoom inspection have not been performed. Responsive reflow and automated accessibility results do not establish complete WCAG 2.2 AA, Israeli Standard 5568 or legal compliance. -- GitHub Pages response-header controls and detailed hosting settings must be checked before an approved production operation. Source configuration alone does not establish active CSP, Permissions Policy, HTTPS settings or protected environments. - -No merge, production deployment, DNS change, Git tag or release is authorized by these results. Review the concrete changes and [deployment procedure](DEPLOYMENT.md) before approving any of those actions. - -## Reproduce - -```sh -npm run check -npx playwright install chromium firefox webkit -npm run test:browsers -npm run audit:performance -``` - -Browser tests use one worker locally and two in CI. Stop a development server before acceptance testing so the runner uses the static build. Run Lighthouse separately from browser tests while the built preview is listening on port 4322; set `LIGHTHOUSE_OUTPUT` to preserve separate samples. Optional service test commands and their pinned dependencies are in [the README](../README.md) and [integration verification](QA-INTEGRATIONS.md). +No complete human screen-reader audit, native Safari/device review or legal accessibility/privacy assessment is claimed. Playwright WebKit is not equivalent to every Safari installation. The verified public Telegram destinations returned HTTP 200 during review, but external availability and actual message/email delivery are outside the static site's control. The candidate still needs owner review, correct Pages configuration and post-publication verification. No release is implied by passing local checks. diff --git a/docs/REPOSITORY-AUDIT.md b/docs/REPOSITORY-AUDIT.md index 58fd875..b805775 100644 --- a/docs/REPOSITORY-AUDIT.md +++ b/docs/REPOSITORY-AUDIT.md @@ -1,5 +1,7 @@ # Repository audit - Phase 1 +> Historical Phase 1 record. The owner chose a static, link-based site on 2026-10-28. Integration proposals and operational assumptions below are superseded; retain this report for dated observations and asset provenance. See [README](../README.md), [deployment](DEPLOYMENT.md) and [current boundaries](QA-INTEGRATIONS.md) for the maintained architecture. + Reviewed on 2026-10-27 (Asia/Jerusalem). Scope: `WizardLoop/wizardloop.github.io` only. All original tracked files were read. Remote inspection used read-only GitHub connector calls and public HTTP observations. No repository settings, DNS, Pages source, production deployment, tags or releases were changed. ## Baseline and repository identity diff --git a/docs/previews/contact-dark-mobile.png b/docs/previews/contact-dark-mobile.png new file mode 100644 index 0000000..2338694 Binary files /dev/null and b/docs/previews/contact-dark-mobile.png differ diff --git a/docs/previews/contact-light-desktop.png b/docs/previews/contact-light-desktop.png new file mode 100644 index 0000000..498ac03 Binary files /dev/null and b/docs/previews/contact-light-desktop.png differ diff --git a/docs/previews/home-dark-desktop.png b/docs/previews/home-dark-desktop.png index 33a8604..8526fa7 100644 Binary files a/docs/previews/home-dark-desktop.png and b/docs/previews/home-dark-desktop.png differ diff --git a/docs/previews/home-dark-mobile.png b/docs/previews/home-dark-mobile.png index 43d4eb4..ee15489 100644 Binary files a/docs/previews/home-dark-mobile.png and b/docs/previews/home-dark-mobile.png differ diff --git a/docs/previews/home-light-desktop.png b/docs/previews/home-light-desktop.png index e69c3f5..4ec2d29 100644 Binary files a/docs/previews/home-light-desktop.png and b/docs/previews/home-light-desktop.png differ diff --git a/docs/previews/home-light-mobile.png b/docs/previews/home-light-mobile.png index 53d9106..0bf9768 100644 Binary files a/docs/previews/home-light-mobile.png and b/docs/previews/home-light-mobile.png differ diff --git a/docs/previews/posts-dark-mobile.png b/docs/previews/posts-dark-mobile.png new file mode 100644 index 0000000..8e480ef Binary files /dev/null and b/docs/previews/posts-dark-mobile.png differ diff --git a/docs/previews/projects-light-desktop.png b/docs/previews/projects-light-desktop.png new file mode 100644 index 0000000..6f3618c Binary files /dev/null and b/docs/previews/projects-light-desktop.png differ diff --git a/docs/previews/work-dark-mobile.png b/docs/previews/work-dark-mobile.png new file mode 100644 index 0000000..3c55e5f Binary files /dev/null and b/docs/previews/work-dark-mobile.png differ diff --git a/docs/previews/work-light-desktop.png b/docs/previews/work-light-desktop.png new file mode 100644 index 0000000..0f81253 Binary files /dev/null and b/docs/previews/work-light-desktop.png differ diff --git a/docs/qa/browser-summary.json b/docs/qa/browser-summary.json index a53979b..5c07482 100644 --- a/docs/qa/browser-summary.json +++ b/docs/qa/browser-summary.json @@ -1,190 +1,83 @@ { - "resultBasis": "Latest result for each unique engine/test from the full run and focused rechecks. Test-harness corrections preceded a generic unexpected-native-pause fallback fix. The last run checks every affected hero behavior on the rebuilt production bundle; unaffected results come from the full run. The original full run had failures; all histories are retained below. This is a combined result, not a green full-suite rerun.", - "startedAt": "2026-10-27T04:56:37.766Z", - "completedAt": "2026-10-27T05:59:03.870Z", - "generatedAt": "2026-10-27T05:59:13.453Z", - "environment": "Local static Astro production build at http://127.0.0.1:4322", - "workers": 1, - "executedChecksIncludingRechecks": 166, - "total": 140, - "passed": 138, - "failed": 0, - "skipped": 2, + "resultBasis": "Latest result for each unique engine/test from the initial static-site run and focused checks of the final build. The initial run had failures, retained below. This is a combined result, not a green full-suite rerun. Remote CI verifies the complete suite separately.", + "generatedAt": "2026-10-28T10:35:42.181Z", + "environment": "Windows, local built Astro site at http://127.0.0.1:4322, one Playwright worker", + "total": 171, + "passed": 171, + "unresolvedFailures": [], "engines": { "chromium": { - "total": 69, - "passed": 69, + "total": 74, + "passed": 74, "failed": 0, "skipped": 0 }, "firefox": { - "total": 35, - "passed": 35, + "total": 48, + "passed": 48, "failed": 0, "skipped": 0 }, "webkit": { - "total": 36, - "passed": 34, + "total": 49, + "passed": 49, "failed": 0, - "skipped": 2 + "skipped": 0 } }, - "skippedTests": [ + "platformNotes": [ { "engine": "webkit", - "title": "native hero video plays, pauses, resumes and follows the selected theme @motion", - "reason": "Windows WebKit Media Foundation stops native looping after one cycle, reproduced with a minimal video element. Its dedicated real-media test verifies the graceful still fallback; uninterrupted looping is not verified on this platform." + "type": "platform", + "description": "webkit on win32: the bare local MP4 decoded and advanced, then paused across its first loop. This case verifies the real application still fallback; uninterrupted native looping is unsupported in this measured environment." }, { "engine": "webkit", - "title": "video pause/resume and reduced motion using a local canvas-stream fixture @motion", - "reason": "This browser does not support the local canvas media fixture; real Telegram video remains an integration test." - } - ], - "failedTests": [], - "unresolvedFailures": [], - "platformNotes": [ - { - "engine": "webkit", + "type": "platform", "description": "Windows WebKit Media Foundation stops this real native MP4 after its first loop. This test verifies graceful fallback, not successful uninterrupted looping. The same stop was reproduced on a minimal video without application code." } ], "runs": [ { - "report": "artifacts/final/playwright-final-results.json", - "startedAt": "2026-10-27T04:56:37.766Z", - "durationSeconds": 1294, - "total": 136, - "passed": 129, - "failed": 6, - "skipped": 1, - "failedTests": [ + "report": "artifacts/final/static-initial-results.json", + "startedAt": "2026-10-28T09:32:08.320Z", + "durationSeconds": 1517, + "total": 135, + "passed": 131, + "failures": [ { "engine": "chromium", - "title": "feedback cannot submit personal fields through a URL when JavaScript is unavailable @privacy" + "title": "the profile is a dimensioned local still with no provider or synchronization dependency @profile", + "status": "failed", + "error": "Error: expect(locator).toHaveAttribute(expected) failed\n\nLocator: locator('img[data-profile-image]')\nExpected: \"/media/profile/avatar.jpg\"\nTimeout: 10000ms\nError: element(s) not found\n\nCall log:\n - Expect \"toHaveAttribute\" locator('img[data-profile-image]') with timeout 10000ms\n - waiting for locator('img[data-profile-image]')\n" }, { "engine": "firefox", - "title": "native hero video plays, pauses, resumes and follows the selected theme @motion" + "title": "mobile menu has native modal focus, an explicit Tab loop, Escape and scroll restoration @keyboard", + "status": "failed", + "error": "TimeoutError: locator.click: Timeout 10000ms exceeded.\nCall log:\n - waiting for locator('#mobile-nav').locator('a[href=\"/posts/\"]')\n - locator resolved to …\n - attempting click action\n - waiting for element to be visible, enabled and stable\n - element is visible, enabled and stable\n - scrolling into view if needed\n - done scrolling\n - performing click action\n" }, { "engine": "firefox", - "title": "feedback cannot submit personal fields through a URL when JavaScript is unavailable @privacy" - }, - { - "engine": "webkit", - "title": "native hero video plays, pauses, resumes and follows the selected theme @motion" - }, - { - "engine": "webkit", - "title": "failed native hero media retains its poster across theme retries @motion" + "title": "the profile is a dimensioned local still with no provider or synchronization dependency @profile", + "status": "failed", + "error": "Error: expect(locator).toHaveAttribute(expected) failed\n\nLocator: locator('img[data-profile-image]')\nExpected: \"/media/profile/avatar.jpg\"\nTimeout: 10000ms\nError: element(s) not found\n\nCall log:\n - Expect \"toHaveAttribute\" locator('img[data-profile-image]') with timeout 10000ms\n - waiting for locator('img[data-profile-image]')\n" }, { "engine": "webkit", - "title": "feedback cannot submit personal fields through a URL when JavaScript is unavailable @privacy" + "title": "the profile is a dimensioned local still with no provider or synchronization dependency @profile", + "status": "failed", + "error": "Error: expect(locator).toHaveAttribute(expected) failed\n\nLocator: locator('img[data-profile-image]')\nExpected: \"/media/profile/avatar.jpg\"\nTimeout: 10000ms\nError: element(s) not found\n\nCall log:\n - Expect \"toHaveAttribute\" locator('img[data-profile-image]') with timeout 10000ms\n - waiting for locator('img[data-profile-image]')\n" } ] }, { - "report": "artifacts/final/playwright-noscript-recheck-results.json", - "startedAt": "2026-10-27T05:23:32.855Z", - "durationSeconds": 139, - "total": 3, - "passed": 2, - "failed": 1, - "skipped": 0, - "failedTests": [ - { - "engine": "firefox", - "title": "feedback cannot submit personal fields through a URL when JavaScript is unavailable @privacy" - } - ] - }, - { - "report": "artifacts/final/playwright-final-recheck-results.json", - "startedAt": "2026-10-27T05:28:56.966Z", - "durationSeconds": 153, - "total": 9, - "passed": 8, - "failed": 1, - "skipped": 0, - "failedTests": [ - { - "engine": "webkit", - "title": "native hero video plays, pauses, resumes and follows the selected theme @motion" - } - ] - }, - { - "report": "artifacts/final/playwright-webkit-media-recheck-results.json", - "startedAt": "2026-10-27T05:36:03.024Z", - "durationSeconds": 16, - "total": 1, - "passed": 0, - "failed": 1, - "skipped": 0, - "failedTests": [ - { - "engine": "webkit", - "title": "native hero video plays, pauses, resumes and follows the selected theme @motion" - } - ] - }, - { - "report": "artifacts/final/playwright-webkit-playback-final-results.json", - "startedAt": "2026-10-27T05:37:21.739Z", - "durationSeconds": 32, - "total": 1, - "passed": 0, - "failed": 1, - "skipped": 0, - "failedTests": [ - { - "engine": "webkit", - "title": "native hero video plays, pauses, resumes and follows the selected theme @motion" - } - ] - }, - { - "report": "artifacts/final/playwright-native-fallback-review-results.json", - "startedAt": "2026-10-27T05:54:55.485Z", - "durationSeconds": 248, - "total": 16, - "passed": 15, - "failed": 0, - "skipped": 1, - "failedTests": [] + "report": "artifacts/final/static-final-results.json", + "startedAt": "2026-10-28T10:20:43.519Z", + "durationSeconds": 850, + "total": 96, + "passed": 96, + "failures": [] } - ], - "testHarnessCorrections": [ - "Playwright text matching skips NOSCRIPT. The no-JavaScript notice is checked with an explicit CSS locator for visible notice text and contact links.", - "The tested Firefox engine does not expose the playsInline JavaScript property. The test checks the required playsinline HTML attribute while retaining native playback, pause, timing, theme and fallback checks.", - "Reading fieldset.disabled with toHaveJSProperty in this Firefox no-JavaScript context repeatedly failed in browser/Juggler code. The test checks the native disabled HTML attribute and retains disabled behavior assertions on every input and button.", - "Windows WebKit Media Foundation reports a cached display surface as naturalSize and loads media outside Playwright route interception. Manifest declares 768x768; Chromium/Firefox decode checks verify encoded dimensions. The error fixture now uses genuine missing local URLs and DOM source-state assertions. Successful native looping is explicitly skipped on Windows WebKit after the real engine stop was reproduced; a separate actual-media test verifies its graceful fallback.", - "A follow-up assertion incorrectly required a square video DOM box. The design fills a rectangular stage using object-fit: contain; the corrected test checks matching stage bounds and contain behavior without changing application code." - ], - "evidence": { - "rawReport": "artifacts/final/playwright-final-results.json", - "focusedRecheck": "artifacts/final/playwright-final-recheck-results.json", - "finalWebKitRecheck": "artifacts/final/playwright-webkit-playback-final-results.json", - "finalAffectedRecheck": "artifacts/final/playwright-native-fallback-review-results.json", - "nativeEngineDiagnostic": "artifacts/final/webkit-capture-side-effect-probe.json", - "htmlReport": "artifacts/final/playwright-final-report/index.html", - "testArtifacts": "artifacts/final/final-test-results", - "screenshots": [ - "docs/previews/home-light-desktop.png", - "docs/previews/home-dark-desktop.png", - "docs/previews/home-light-mobile.png", - "docs/previews/home-dark-mobile.png" - ], - "animationRecording": "artifacts/final/wizardloop-home-animation.webm", - "localFeedback": "artifacts/final/feedback-ui-results.json", - "liveTelegram": "artifacts/final/telegram-live-results.json", - "liveTelegramMotion": "artifacts/final/telegram-live-motion-results.json" - }, - "realFeedbackDeliveryTested": false, - "realTurnstileVerificationTested": false, - "humanScreenReaderAuditPerformed": false, - "deploymentPerformed": false + ] } diff --git a/docs/qa/lighthouse-summary.json b/docs/qa/lighthouse-summary.json index 84b1850..1350868 100644 --- a/docs/qa/lighthouse-summary.json +++ b/docs/qa/lighthouse-summary.json @@ -1,1159 +1,1341 @@ { - "date": "2026-10-27", - "method": "Three sequential local Lighthouse 13.5.0 mobile simulated runs. First run precedes only a paused-mobile-menu CSS fix; later tooltip hover/Escape fixes do not alter initial unfocused rendering. All samples retained, including slow outliers. Not field Core Web Vitals.", + "schemaVersion": 2, + "date": "2026-10-28", + "candidate": "Simplified static site after the Manrope font preload was added.", + "method": { + "lighthouseVersion": "13.5.0", + "environment": "Local built Astro preview on Windows with Headless Chromium.", + "url": "http://127.0.0.1:4322/", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/149.0.0.0 Safari/537.36", + "formFactor": "mobile", + "throttlingMethod": "simulate", + "screenEmulation": { + "mobile": true, + "width": 412, + "height": 823, + "deviceScaleFactor": 1.75, + "disabled": false + }, + "throttling": { + "rttMs": 150, + "throughputKbps": 1638.4, + "requestLatencyMs": 562.5, + "downloadThroughputKbps": 1474.5600000000002, + "uploadThroughputKbps": 675, + "cpuSlowdownMultiplier": 4 + }, + "scoreScale": "0 to 100", + "note": "Synthetic lab measurements, not production traffic or field Core Web Vitals. Local host load, caching and timing can affect results. Baseline and diagnostic samples are excluded from final aggregates. These runs do not establish that the font preload alone caused every measured difference." + }, + "sampleCount": 7, + "measurementWindow": { + "firstFetchTime": "2026-10-28T10:17:33.409Z", + "lastFetchTime": "2026-10-28T10:19:34.917Z" + }, "summary": [ { "page": "home", - "performance": { - "median": 92, - "range": [ - 59, - 97 - ] + "path": "/", + "sampleCount": 3, + "scores": { + "performance": { + "median": 98, + "range": [ + 94, + 98 + ], + "samples": [ + 98, + 94, + 98 + ] + }, + "accessibility": { + "median": 100, + "range": [ + 100, + 100 + ], + "samples": [ + 100, + 100, + 100 + ] + }, + "best-practices": { + "median": 100, + "range": [ + 100, + 100 + ], + "samples": [ + 100, + 100, + 100 + ] + }, + "seo": { + "median": 100, + "range": [ + 100, + 100 + ], + "samples": [ + 100, + 100, + 100 + ] + } + }, + "metrics": { + "first-contentful-paint": { + "unit": "ms", + "median": 1464, + "range": [ + 1360.261, + 1654 + ], + "samples": [ + 1464, + 1360.261, + 1654 + ] + }, + "largest-contentful-paint": { + "unit": "ms", + "median": 2235, + "range": [ + 2235, + 2345.348 + ], + "samples": [ + 2235, + 2345.348, + 2235 + ] + }, + "total-blocking-time": { + "unit": "ms", + "median": 51, + "range": [ + 1, + 83.41300000000001 + ], + "samples": [ + 51, + 83.41300000000001, + 1 + ] + }, + "cumulative-layout-shift": { + "unit": "unitless", + "median": 0, + "range": [ + 0, + 0 + ], + "samples": [ + 0, + 0, + 0 + ] + }, + "speed-index": { + "unit": "ms", + "median": 1654, + "range": [ + 1464, + 4932.387445850005 + ], + "samples": [ + 1464, + 4932.387445850005, + 1654 + ] + }, + "total-byte-weight": { + "unit": "bytes", + "median": 889953, + "range": [ + 889953, + 889953 + ], + "samples": [ + 889953, + 889953, + 889953 + ] + } + }, + "interpretation": "Three final samples retained, including the lower score." + }, + { + "page": "work", + "path": "/work/", + "sampleCount": 1, + "scores": { + "performance": { + "median": 100, + "range": [ + 100, + 100 + ], + "samples": [ + 100 + ] + }, + "accessibility": { + "median": 100, + "range": [ + 100, + 100 + ], + "samples": [ + 100 + ] + }, + "best-practices": { + "median": 100, + "range": [ + 100, + 100 + ], + "samples": [ + 100 + ] + }, + "seo": { + "median": 100, + "range": [ + 100, + 100 + ], + "samples": [ + 100 + ] + } + }, + "metrics": { + "first-contentful-paint": { + "unit": "ms", + "median": 1362.426, + "range": [ + 1362.426, + 1362.426 + ], + "samples": [ + 1362.426 + ] + }, + "largest-contentful-paint": { + "unit": "ms", + "median": 1512.4260000000002, + "range": [ + 1512.4260000000002, + 1512.4260000000002 + ], + "samples": [ + 1512.4260000000002 + ] + }, + "total-blocking-time": { + "unit": "ms", + "median": 0, + "range": [ + 0, + 0 + ], + "samples": [ + 0 + ] + }, + "cumulative-layout-shift": { + "unit": "unitless", + "median": 0, + "range": [ + 0, + 0 + ], + "samples": [ + 0 + ] + }, + "speed-index": { + "unit": "ms", + "median": 1362.426, + "range": [ + 1362.426, + 1362.426 + ], + "samples": [ + 1362.426 + ] + }, + "total-byte-weight": { + "unit": "bytes", + "median": 105364, + "range": [ + 105364, + 105364 + ], + "samples": [ + 105364 + ] + } + }, + "interpretation": "One final sample; the equal minimum and maximum do not establish run-to-run stability." + }, + { + "page": "projects", + "path": "/projects/", + "sampleCount": 1, + "scores": { + "performance": { + "median": 100, + "range": [ + 100, + 100 + ], + "samples": [ + 100 + ] + }, + "accessibility": { + "median": 100, + "range": [ + 100, + 100 + ], + "samples": [ + 100 + ] + }, + "best-practices": { + "median": 100, + "range": [ + 100, + 100 + ], + "samples": [ + 100 + ] + }, + "seo": { + "median": 100, + "range": [ + 100, + 100 + ], + "samples": [ + 100 + ] + } + }, + "metrics": { + "first-contentful-paint": { + "unit": "ms", + "median": 1375.7973000000002, + "range": [ + 1375.7973000000002, + 1375.7973000000002 + ], + "samples": [ + 1375.7973000000002 + ] + }, + "largest-contentful-paint": { + "unit": "ms", + "median": 1525.7972999999997, + "range": [ + 1525.7972999999997, + 1525.7972999999997 + ], + "samples": [ + 1525.7972999999997 + ] + }, + "total-blocking-time": { + "unit": "ms", + "median": 0, + "range": [ + 0, + 0 + ], + "samples": [ + 0 + ] + }, + "cumulative-layout-shift": { + "unit": "unitless", + "median": 0, + "range": [ + 0, + 0 + ], + "samples": [ + 0 + ] + }, + "speed-index": { + "unit": "ms", + "median": 1375.7973000000002, + "range": [ + 1375.7973000000002, + 1375.7973000000002 + ], + "samples": [ + 1375.7973000000002 + ] + }, + "total-byte-weight": { + "unit": "bytes", + "median": 105177, + "range": [ + 105177, + 105177 + ], + "samples": [ + 105177 + ] + } + }, + "interpretation": "One final sample; the equal minimum and maximum do not establish run-to-run stability." + }, + { + "page": "posts", + "path": "/posts/", + "sampleCount": 1, + "scores": { + "performance": { + "median": 100, + "range": [ + 100, + 100 + ], + "samples": [ + 100 + ] + }, + "accessibility": { + "median": 100, + "range": [ + 100, + 100 + ], + "samples": [ + 100 + ] + }, + "best-practices": { + "median": 100, + "range": [ + 100, + 100 + ], + "samples": [ + 100 + ] + }, + "seo": { + "median": 100, + "range": [ + 100, + 100 + ], + "samples": [ + 100 + ] + } + }, + "metrics": { + "first-contentful-paint": { + "unit": "ms", + "median": 1366.3227000000002, + "range": [ + 1366.3227000000002, + 1366.3227000000002 + ], + "samples": [ + 1366.3227000000002 + ] + }, + "largest-contentful-paint": { + "unit": "ms", + "median": 1516.3227000000002, + "range": [ + 1516.3227000000002, + 1516.3227000000002 + ], + "samples": [ + 1516.3227000000002 + ] + }, + "total-blocking-time": { + "unit": "ms", + "median": 0, + "range": [ + 0, + 0 + ], + "samples": [ + 0 + ] + }, + "cumulative-layout-shift": { + "unit": "unitless", + "median": 0, + "range": [ + 0, + 0 + ], + "samples": [ + 0 + ] + }, + "speed-index": { + "unit": "ms", + "median": 1366.3227000000002, + "range": [ + 1366.3227000000002, + 1366.3227000000002 + ], + "samples": [ + 1366.3227000000002 + ] + }, + "total-byte-weight": { + "unit": "bytes", + "median": 105100, + "range": [ + 105100, + 105100 + ], + "samples": [ + 105100 + ] + } + }, + "interpretation": "One final sample; the equal minimum and maximum do not establish run-to-run stability." + }, + { + "page": "feedback", + "path": "/feedback/", + "sampleCount": 1, + "scores": { + "performance": { + "median": 100, + "range": [ + 100, + 100 + ], + "samples": [ + 100 + ] + }, + "accessibility": { + "median": 100, + "range": [ + 100, + 100 + ], + "samples": [ + 100 + ] + }, + "best-practices": { + "median": 100, + "range": [ + 100, + 100 + ], + "samples": [ + 100 + ] + }, + "seo": { + "median": 100, + "range": [ + 100, + 100 + ], + "samples": [ + 100 + ] + } + }, + "metrics": { + "first-contentful-paint": { + "unit": "ms", + "median": 1377.4293, + "range": [ + 1377.4293, + 1377.4293 + ], + "samples": [ + 1377.4293 + ] + }, + "largest-contentful-paint": { + "unit": "ms", + "median": 1527.4293, + "range": [ + 1527.4293, + 1527.4293 + ], + "samples": [ + 1527.4293 + ] + }, + "total-blocking-time": { + "unit": "ms", + "median": 0, + "range": [ + 0, + 0 + ], + "samples": [ + 0 + ] + }, + "cumulative-layout-shift": { + "unit": "unitless", + "median": 0, + "range": [ + 0, + 0 + ], + "samples": [ + 0 + ] + }, + "speed-index": { + "unit": "ms", + "median": 1377.4293, + "range": [ + 1377.4293, + 1377.4293 + ], + "samples": [ + 1377.4293 + ] + }, + "total-byte-weight": { + "unit": "bytes", + "median": 105308, + "range": [ + 105308, + 105308 + ], + "samples": [ + 105308 + ] + } + }, + "interpretation": "One final sample; the equal minimum and maximum do not establish run-to-run stability." + } + ], + "samples": [ + { + "sourceSummary": "artifacts/lighthouse-final/summary.json", + "page": "home", + "version": "13.5.0", + "fetchTime": "2026-10-28T10:17:33.409Z", + "formFactor": "mobile", + "throttling": "simulate", + "scores": { + "performance": 98, + "accessibility": 100, + "best-practices": 100, + "seo": 100 + }, + "metrics": { + "first-contentful-paint": { + "numericValue": 1464, + "displayValue": "1.5 s" + }, + "largest-contentful-paint": { + "numericValue": 2235, + "displayValue": "2.2 s" + }, + "total-blocking-time": { + "numericValue": 51, + "displayValue": "50 ms" + }, + "cumulative-layout-shift": { + "numericValue": 0, + "displayValue": "0" + }, + "speed-index": { + "numericValue": 1464, + "displayValue": "1.5 s" + }, + "total-byte-weight": { + "numericValue": 889953, + "displayValue": "Total size was 869 KiB" + } }, - "allScores": [ + "failedAudits": [ + { + "id": "first-contentful-paint", + "score": 0.96, + "title": "First Contentful Paint" + }, + { + "id": "largest-contentful-paint", + "score": 0.94, + "title": "Largest Contentful Paint" + }, + { + "id": "max-potential-fid", + "score": 0.11, + "title": "Max Potential First Input Delay" + }, + { + "id": "interactive", + "score": 0.99, + "title": "Time to Interactive" + }, { - "performance": 59, - "accessibility": 100, - "best-practices": 100, - "seo": 100 + "id": "mainthread-work-breakdown", + "score": 0, + "title": "Minimize main-thread work" }, { - "performance": 92, - "accessibility": 100, - "best-practices": 100, - "seo": 100 + "id": "image-delivery-insight", + "score": 0, + "title": "Improve image delivery" }, { - "performance": 97, - "accessibility": 100, - "best-practices": 100, - "seo": 100 + "id": "network-dependency-tree-insight", + "score": 0, + "title": "Network dependency tree" + }, + { + "id": "render-blocking-insight", + "score": 0.5, + "title": "Render-blocking requests" } ], - "medianMetrics": { - "first-contentful-paint": 2580.495, - "largest-contentful-paint": 2580.495, - "total-blocking-time": 133.49999999999977, - "cumulative-layout-shift": 0.00004679164839040017, - "speed-index": 2851.945609193184, - "total-byte-weight": 1007959 - } + "error": null }, { + "sourceSummary": "artifacts/lighthouse-final/summary.json", "page": "work", - "performance": { - "median": 95, - "range": [ - 94, - 96 - ] + "version": "13.5.0", + "fetchTime": "2026-10-28T10:17:53.179Z", + "formFactor": "mobile", + "throttling": "simulate", + "scores": { + "performance": 100, + "accessibility": 100, + "best-practices": 100, + "seo": 100 }, - "allScores": [ + "metrics": { + "first-contentful-paint": { + "numericValue": 1362.426, + "displayValue": "1.4 s" + }, + "largest-contentful-paint": { + "numericValue": 1512.4260000000002, + "displayValue": "1.5 s" + }, + "total-blocking-time": { + "numericValue": 0, + "displayValue": "0 ms" + }, + "cumulative-layout-shift": { + "numericValue": 0, + "displayValue": "0" + }, + "speed-index": { + "numericValue": 1362.426, + "displayValue": "1.4 s" + }, + "total-byte-weight": { + "numericValue": 105364, + "displayValue": "Total size was 103 KiB" + } + }, + "failedAudits": [ { - "performance": 95, - "accessibility": 100, - "best-practices": 100, - "seo": 100 + "id": "first-contentful-paint", + "score": 0.97, + "title": "First Contentful Paint" }, { - "performance": 96, - "accessibility": 100, - "best-practices": 100, - "seo": 100 + "id": "network-dependency-tree-insight", + "score": 0, + "title": "Network dependency tree" }, { - "performance": 94, - "accessibility": 100, - "best-practices": 100, - "seo": 100 + "id": "render-blocking-insight", + "score": 0, + "title": "Render-blocking requests" } ], - "medianMetrics": { - "first-contentful-paint": 1391.1795, - "largest-contentful-paint": 1485.1615000000002, - "total-blocking-time": 240.5, - "cumulative-layout-shift": 0, - "speed-index": 1752.5404285351192, - "total-byte-weight": 792451 - } + "error": null }, { - "page": "posts", - "performance": { - "median": 100, - "range": [ - 66, - 100 - ] + "sourceSummary": "artifacts/lighthouse-final/summary.json", + "page": "projects", + "version": "13.5.0", + "fetchTime": "2026-10-28T10:18:05.597Z", + "formFactor": "mobile", + "throttling": "simulate", + "scores": { + "performance": 100, + "accessibility": 100, + "best-practices": 100, + "seo": 100 }, - "allScores": [ + "metrics": { + "first-contentful-paint": { + "numericValue": 1375.7973000000002, + "displayValue": "1.4 s" + }, + "largest-contentful-paint": { + "numericValue": 1525.7972999999997, + "displayValue": "1.5 s" + }, + "total-blocking-time": { + "numericValue": 0, + "displayValue": "0 ms" + }, + "cumulative-layout-shift": { + "numericValue": 0, + "displayValue": "0" + }, + "speed-index": { + "numericValue": 1375.7973000000002, + "displayValue": "1.4 s" + }, + "total-byte-weight": { + "numericValue": 105177, + "displayValue": "Total size was 103 KiB" + } + }, + "failedAudits": [ { - "performance": 66, - "accessibility": 100, - "best-practices": 100, - "seo": 100 + "id": "first-contentful-paint", + "score": 0.97, + "title": "First Contentful Paint" }, { - "performance": 100, - "accessibility": 100, - "best-practices": 100, - "seo": 100 + "id": "network-dependency-tree-insight", + "score": 0, + "title": "Network dependency tree" }, { - "performance": 100, - "accessibility": 100, - "best-practices": 100, - "seo": 100 + "id": "render-blocking-insight", + "score": 0, + "title": "Render-blocking requests" } ], - "medianMetrics": { - "first-contentful-paint": 1460.402, - "largest-contentful-paint": 1518.65895, - "total-blocking-time": 49, - "cumulative-layout-shift": 0, - "speed-index": 1460.402, - "total-byte-weight": 107059 - } + "error": null }, { - "page": "feedback", - "performance": { - "median": 99, - "range": [ - 98, - 100 - ] + "sourceSummary": "artifacts/lighthouse-final/summary.json", + "page": "posts", + "version": "13.5.0", + "fetchTime": "2026-10-28T10:18:20.528Z", + "formFactor": "mobile", + "throttling": "simulate", + "scores": { + "performance": 100, + "accessibility": 100, + "best-practices": 100, + "seo": 100 }, - "allScores": [ + "metrics": { + "first-contentful-paint": { + "numericValue": 1366.3227000000002, + "displayValue": "1.4 s" + }, + "largest-contentful-paint": { + "numericValue": 1516.3227000000002, + "displayValue": "1.5 s" + }, + "total-blocking-time": { + "numericValue": 0, + "displayValue": "0 ms" + }, + "cumulative-layout-shift": { + "numericValue": 0, + "displayValue": "0" + }, + "speed-index": { + "numericValue": 1366.3227000000002, + "displayValue": "1.4 s" + }, + "total-byte-weight": { + "numericValue": 105100, + "displayValue": "Total size was 103 KiB" + } + }, + "failedAudits": [ { - "performance": 98, - "accessibility": 100, - "best-practices": 100, - "seo": 100 + "id": "first-contentful-paint", + "score": 0.97, + "title": "First Contentful Paint" }, { - "performance": 99, - "accessibility": 100, - "best-practices": 100, - "seo": 100 + "id": "network-dependency-tree-insight", + "score": 0, + "title": "Network dependency tree" }, { - "performance": 100, - "accessibility": 100, - "best-practices": 100, - "seo": 100 + "id": "render-blocking-insight", + "score": 0, + "title": "Render-blocking requests" } ], - "medianMetrics": { - "first-contentful-paint": 1440.5637499999998, - "largest-contentful-paint": 1665.0765, - "total-blocking-time": 23, - "cumulative-layout-shift": 0.03183100726587574, - "speed-index": 1440.5637499999998, - "total-byte-weight": 110594 - } - } - ], - "runs": [ + "error": null + }, { - "directory": "artifacts/lighthouse-review", - "results": [ - { - "page": "home", - "version": "13.5.0", - "fetchTime": "2026-10-27T04:45:36.290Z", - "formFactor": "mobile", - "throttling": "simulate", - "scores": { - "performance": 59, - "accessibility": 100, - "best-practices": 100, - "seo": 100 - }, - "metrics": { - "first-contentful-paint": { - "numericValue": 5970.079, - "displayValue": "6.0 s" - }, - "largest-contentful-paint": { - "numericValue": 5970.079, - "displayValue": "6.0 s" - }, - "total-blocking-time": { - "numericValue": 211.5, - "displayValue": "210 ms" - }, - "cumulative-layout-shift": { - "numericValue": 0.00004679164839040017, - "displayValue": "0" - }, - "speed-index": { - "numericValue": 6296.614534747019, - "displayValue": "6.3 s" - }, - "total-byte-weight": { - "numericValue": 1007941, - "displayValue": "Total size was 984 KiB" - } - }, - "failedAudits": [ - { - "id": "first-contentful-paint", - "score": 0.04, - "title": "First Contentful Paint" - }, - { - "id": "largest-contentful-paint", - "score": 0.13, - "title": "Largest Contentful Paint" - }, - { - "id": "speed-index", - "score": 0.42, - "title": "Speed Index" - }, - { - "id": "total-blocking-time", - "score": 0.88, - "title": "Total Blocking Time" - }, - { - "id": "max-potential-fid", - "score": 0.81, - "title": "Max Potential First Input Delay" - }, - { - "id": "interactive", - "score": 0.59, - "title": "Time to Interactive" - }, - { - "id": "mainthread-work-breakdown", - "score": 0, - "title": "Minimize main-thread work" - }, - { - "id": "image-delivery-insight", - "score": 0.5, - "title": "Improve image delivery" - }, - { - "id": "lcp-breakdown-insight", - "score": 0, - "title": "LCP breakdown" - }, - { - "id": "network-dependency-tree-insight", - "score": 0, - "title": "Network dependency tree" - }, - { - "id": "render-blocking-insight", - "score": 0.5, - "title": "Render-blocking requests" - } - ], - "error": null + "sourceSummary": "artifacts/lighthouse-final/summary.json", + "page": "feedback", + "version": "13.5.0", + "fetchTime": "2026-10-28T10:18:33.696Z", + "formFactor": "mobile", + "throttling": "simulate", + "scores": { + "performance": 100, + "accessibility": 100, + "best-practices": 100, + "seo": 100 + }, + "metrics": { + "first-contentful-paint": { + "numericValue": 1377.4293, + "displayValue": "1.4 s" + }, + "largest-contentful-paint": { + "numericValue": 1527.4293, + "displayValue": "1.5 s" + }, + "total-blocking-time": { + "numericValue": 0, + "displayValue": "0 ms" }, + "cumulative-layout-shift": { + "numericValue": 0, + "displayValue": "0" + }, + "speed-index": { + "numericValue": 1377.4293, + "displayValue": "1.4 s" + }, + "total-byte-weight": { + "numericValue": 105308, + "displayValue": "Total size was 103 KiB" + } + }, + "failedAudits": [ { - "page": "work", - "version": "13.5.0", - "fetchTime": "2026-10-27T04:46:05.753Z", - "formFactor": "mobile", - "throttling": "simulate", - "scores": { - "performance": 95, - "accessibility": 100, - "best-practices": 100, - "seo": 100 - }, - "metrics": { - "first-contentful-paint": { - "numericValue": 1485.1615000000002, - "displayValue": "1.5 s" - }, - "largest-contentful-paint": { - "numericValue": 1485.1615000000002, - "displayValue": "1.5 s" - }, - "total-blocking-time": { - "numericValue": 240.5, - "displayValue": "240 ms" - }, - "cumulative-layout-shift": { - "numericValue": 0, - "displayValue": "0" - }, - "speed-index": { - "numericValue": 2062.3494252056853, - "displayValue": "2.1 s" - }, - "total-byte-weight": { - "numericValue": 792433, - "displayValue": "Total size was 774 KiB" - } - }, - "failedAudits": [ - { - "id": "first-contentful-paint", - "score": 0.96, - "title": "First Contentful Paint" - }, - { - "id": "speed-index", - "score": 0.99, - "title": "Speed Index" - }, - { - "id": "total-blocking-time", - "score": 0.85, - "title": "Total Blocking Time" - }, - { - "id": "max-potential-fid", - "score": 0.02, - "title": "Max Potential First Input Delay" - }, - { - "id": "interactive", - "score": 0.9, - "title": "Time to Interactive" - }, - { - "id": "mainthread-work-breakdown", - "score": 0, - "title": "Minimize main-thread work" - }, - { - "id": "bootup-time", - "score": 0, - "title": "Reduce JavaScript execution time" - }, - { - "id": "unused-javascript", - "score": 0.5, - "title": "Reduce unused JavaScript" - }, - { - "id": "image-delivery-insight", - "score": 0.5, - "title": "Improve image delivery" - }, - { - "id": "network-dependency-tree-insight", - "score": 0, - "title": "Network dependency tree" - }, - { - "id": "render-blocking-insight", - "score": 0.5, - "title": "Render-blocking requests" - } - ], - "error": null + "id": "first-contentful-paint", + "score": 0.97, + "title": "First Contentful Paint" }, { - "page": "posts", - "version": "13.5.0", - "fetchTime": "2026-10-27T04:46:34.949Z", - "formFactor": "mobile", - "throttling": "simulate", - "scores": { - "performance": 66, - "accessibility": 100, - "best-practices": 100, - "seo": 100 - }, - "metrics": { - "first-contentful-paint": { - "numericValue": 5214.282, - "displayValue": "5.2 s" - }, - "largest-contentful-paint": { - "numericValue": 5332.376, - "displayValue": "5.3 s" - }, - "total-blocking-time": { - "numericValue": 72.5, - "displayValue": "70 ms" - }, - "cumulative-layout-shift": { - "numericValue": 0, - "displayValue": "0" - }, - "speed-index": { - "numericValue": 5387.681377698365, - "displayValue": "5.4 s" - }, - "total-byte-weight": { - "numericValue": 107041, - "displayValue": "Total size was 105 KiB" - } - }, - "failedAudits": [ - { - "id": "first-contentful-paint", - "score": 0.08, - "title": "First Contentful Paint" - }, - { - "id": "largest-contentful-paint", - "score": 0.21, - "title": "Largest Contentful Paint" - }, - { - "id": "speed-index", - "score": 0.56, - "title": "Speed Index" - }, - { - "id": "total-blocking-time", - "score": 0.99, - "title": "Total Blocking Time" - }, - { - "id": "max-potential-fid", - "score": 0.84, - "title": "Max Potential First Input Delay" - }, - { - "id": "interactive", - "score": 0.72, - "title": "Time to Interactive" - }, - { - "id": "mainthread-work-breakdown", - "score": 0, - "title": "Minimize main-thread work" - }, - { - "id": "network-dependency-tree-insight", - "score": 0, - "title": "Network dependency tree" - }, - { - "id": "render-blocking-insight", - "score": 0.5, - "title": "Render-blocking requests" - } - ], - "error": null + "id": "network-dependency-tree-insight", + "score": 0, + "title": "Network dependency tree" }, { - "page": "feedback", - "version": "13.5.0", - "fetchTime": "2026-10-27T04:46:59.521Z", - "formFactor": "mobile", - "throttling": "simulate", - "scores": { - "performance": 98, - "accessibility": 100, - "best-practices": 100, - "seo": 100 - }, - "metrics": { - "first-contentful-paint": { - "numericValue": 1783.5052500000002, - "displayValue": "1.8 s" - }, - "largest-contentful-paint": { - "numericValue": 1889.007, - "displayValue": "1.9 s" - }, - "total-blocking-time": { - "numericValue": 92, - "displayValue": "90 ms" - }, - "cumulative-layout-shift": { - "numericValue": 0.03183100726587574, - "displayValue": "0.032" - }, - "speed-index": { - "numericValue": 1783.5052500000002, - "displayValue": "1.8 s" - }, - "total-byte-weight": { - "numericValue": 110576, - "displayValue": "Total size was 108 KiB" - } - }, - "failedAudits": [ - { - "id": "first-contentful-paint", - "score": 0.9, - "title": "First Contentful Paint" - }, - { - "id": "largest-contentful-paint", - "score": 0.98, - "title": "Largest Contentful Paint" - }, - { - "id": "total-blocking-time", - "score": 0.98, - "title": "Total Blocking Time" - }, - { - "id": "max-potential-fid", - "score": 0.86, - "title": "Max Potential First Input Delay" - }, - { - "id": "interactive", - "score": 0.99, - "title": "Time to Interactive" - }, - { - "id": "mainthread-work-breakdown", - "score": 0, - "title": "Minimize main-thread work" - }, - { - "id": "network-dependency-tree-insight", - "score": 0, - "title": "Network dependency tree" - }, - { - "id": "render-blocking-insight", - "score": 0.5, - "title": "Render-blocking requests" - } - ], - "error": null + "id": "render-blocking-insight", + "score": 0, + "title": "Render-blocking requests" } - ] + ], + "error": null }, { - "directory": "artifacts/lighthouse-review-2", - "results": [ + "sourceSummary": "artifacts/lighthouse-final-2/summary.json", + "page": "home", + "version": "13.5.0", + "fetchTime": "2026-10-28T10:18:59.847Z", + "formFactor": "mobile", + "throttling": "simulate", + "scores": { + "performance": 94, + "accessibility": 100, + "best-practices": 100, + "seo": 100 + }, + "metrics": { + "first-contentful-paint": { + "numericValue": 1360.261, + "displayValue": "1.4 s" + }, + "largest-contentful-paint": { + "numericValue": 2345.348, + "displayValue": "2.3 s" + }, + "total-blocking-time": { + "numericValue": 83.41300000000001, + "displayValue": "80 ms" + }, + "cumulative-layout-shift": { + "numericValue": 0, + "displayValue": "0" + }, + "speed-index": { + "numericValue": 4932.387445850005, + "displayValue": "4.9 s" + }, + "total-byte-weight": { + "numericValue": 889953, + "displayValue": "Total size was 869 KiB" + } + }, + "failedAudits": [ { - "page": "home", - "version": "13.5.0", - "fetchTime": "2026-10-27T04:50:12.090Z", - "formFactor": "mobile", - "throttling": "simulate", - "scores": { - "performance": 92, - "accessibility": 100, - "best-practices": 100, - "seo": 100 - }, - "metrics": { - "first-contentful-paint": { - "numericValue": 2580.495, - "displayValue": "2.6 s" - }, - "largest-contentful-paint": { - "numericValue": 2580.495, - "displayValue": "2.6 s" - }, - "total-blocking-time": { - "numericValue": 133.49999999999977, - "displayValue": "130 ms" - }, - "cumulative-layout-shift": { - "numericValue": 0.00004679164839040017, - "displayValue": "0" - }, - "speed-index": { - "numericValue": 2851.945609193184, - "displayValue": "2.9 s" - }, - "total-byte-weight": { - "numericValue": 1007959, - "displayValue": "Total size was 984 KiB" - } - }, - "failedAudits": [ - { - "id": "first-contentful-paint", - "score": 0.64, - "title": "First Contentful Paint" - }, - { - "id": "largest-contentful-paint", - "score": 0.88, - "title": "Largest Contentful Paint" - }, - { - "id": "speed-index", - "score": 0.95, - "title": "Speed Index" - }, - { - "id": "total-blocking-time", - "score": 0.96, - "title": "Total Blocking Time" - }, - { - "id": "max-potential-fid", - "score": 0, - "title": "Max Potential First Input Delay" - }, - { - "id": "interactive", - "score": 0.96, - "title": "Time to Interactive" - }, - { - "id": "mainthread-work-breakdown", - "score": 0, - "title": "Minimize main-thread work" - }, - { - "id": "image-delivery-insight", - "score": 0.5, - "title": "Improve image delivery" - }, - { - "id": "network-dependency-tree-insight", - "score": 0, - "title": "Network dependency tree" - }, - { - "id": "render-blocking-insight", - "score": 0.5, - "title": "Render-blocking requests" - } - ], - "error": null + "id": "first-contentful-paint", + "score": 0.98, + "title": "First Contentful Paint" }, { - "page": "work", - "version": "13.5.0", - "fetchTime": "2026-10-27T04:50:28.366Z", - "formFactor": "mobile", - "throttling": "simulate", - "scores": { - "performance": 96, - "accessibility": 100, - "best-practices": 100, - "seo": 100 - }, - "metrics": { - "first-contentful-paint": { - "numericValue": 1375.589, - "displayValue": "1.4 s" - }, - "largest-contentful-paint": { - "numericValue": 1375.589, - "displayValue": "1.4 s" - }, - "total-blocking-time": { - "numericValue": 225.5, - "displayValue": "230 ms" - }, - "cumulative-layout-shift": { - "numericValue": 0, - "displayValue": "0" - }, - "speed-index": { - "numericValue": 1752.5404285351192, - "displayValue": "1.8 s" - }, - "total-byte-weight": { - "numericValue": 792451, - "displayValue": "Total size was 774 KiB" - } - }, - "failedAudits": [ - { - "id": "first-contentful-paint", - "score": 0.97, - "title": "First Contentful Paint" - }, - { - "id": "total-blocking-time", - "score": 0.87, - "title": "Total Blocking Time" - }, - { - "id": "max-potential-fid", - "score": 0.41, - "title": "Max Potential First Input Delay" - }, - { - "id": "interactive", - "score": 0.82, - "title": "Time to Interactive" - }, - { - "id": "mainthread-work-breakdown", - "score": 0, - "title": "Minimize main-thread work" - }, - { - "id": "bootup-time", - "score": 0, - "title": "Reduce JavaScript execution time" - }, - { - "id": "unused-javascript", - "score": 0.5, - "title": "Reduce unused JavaScript" - }, - { - "id": "image-delivery-insight", - "score": 0.5, - "title": "Improve image delivery" - }, - { - "id": "network-dependency-tree-insight", - "score": 0, - "title": "Network dependency tree" - }, - { - "id": "render-blocking-insight", - "score": 0.5, - "title": "Render-blocking requests" - } - ], - "error": null + "id": "largest-contentful-paint", + "score": 0.92, + "title": "Largest Contentful Paint" }, { - "page": "posts", - "version": "13.5.0", - "fetchTime": "2026-10-27T04:50:51.736Z", - "formFactor": "mobile", - "throttling": "simulate", - "scores": { - "performance": 100, - "accessibility": 100, - "best-practices": 100, - "seo": 100 - }, - "metrics": { - "first-contentful-paint": { - "numericValue": 1368.65895, - "displayValue": "1.4 s" - }, - "largest-contentful-paint": { - "numericValue": 1518.65895, - "displayValue": "1.5 s" - }, - "total-blocking-time": { - "numericValue": 0, - "displayValue": "0 ms" - }, - "cumulative-layout-shift": { - "numericValue": 0, - "displayValue": "0" - }, - "speed-index": { - "numericValue": 1368.65895, - "displayValue": "1.4 s" - }, - "total-byte-weight": { - "numericValue": 107059, - "displayValue": "Total size was 105 KiB" - } - }, - "failedAudits": [ - { - "id": "first-contentful-paint", - "score": 0.97, - "title": "First Contentful Paint" - }, - { - "id": "max-potential-fid", - "score": 0.95, - "title": "Max Potential First Input Delay" - }, - { - "id": "network-dependency-tree-insight", - "score": 0, - "title": "Network dependency tree" - }, - { - "id": "render-blocking-insight", - "score": 0, - "title": "Render-blocking requests" - } - ], - "error": null + "id": "speed-index", + "score": 0.65, + "title": "Speed Index" }, { - "page": "feedback", - "version": "13.5.0", - "fetchTime": "2026-10-27T04:51:03.921Z", - "formFactor": "mobile", - "throttling": "simulate", - "scores": { - "performance": 99, - "accessibility": 100, - "best-practices": 100, - "seo": 100 - }, - "metrics": { - "first-contentful-paint": { - "numericValue": 1440.5637499999998, - "displayValue": "1.4 s" - }, - "largest-contentful-paint": { - "numericValue": 1665.0765, - "displayValue": "1.7 s" - }, - "total-blocking-time": { - "numericValue": 23, - "displayValue": "20 ms" - }, - "cumulative-layout-shift": { - "numericValue": 0.03183100726587574, - "displayValue": "0.032" - }, - "speed-index": { - "numericValue": 1440.5637499999998, - "displayValue": "1.4 s" - }, - "total-byte-weight": { - "numericValue": 110594, - "displayValue": "Total size was 108 KiB" - } - }, - "failedAudits": [ - { - "id": "first-contentful-paint", - "score": 0.97, - "title": "First Contentful Paint" - }, - { - "id": "largest-contentful-paint", - "score": 0.99, - "title": "Largest Contentful Paint" - }, - { - "id": "network-dependency-tree-insight", - "score": 0, - "title": "Network dependency tree" - }, - { - "id": "render-blocking-insight", - "score": 0, - "title": "Render-blocking requests" - } - ], - "error": null + "id": "total-blocking-time", + "score": 0.99, + "title": "Total Blocking Time" + }, + { + "id": "max-potential-fid", + "score": 0.33, + "title": "Max Potential First Input Delay" + }, + { + "id": "interactive", + "score": 0.99, + "title": "Time to Interactive" + }, + { + "id": "mainthread-work-breakdown", + "score": 0, + "title": "Minimize main-thread work" + }, + { + "id": "image-delivery-insight", + "score": 0, + "title": "Improve image delivery" + }, + { + "id": "lcp-breakdown-insight", + "score": 0, + "title": "LCP breakdown" + }, + { + "id": "network-dependency-tree-insight", + "score": 0, + "title": "Network dependency tree" + }, + { + "id": "render-blocking-insight", + "score": 0.5, + "title": "Render-blocking requests" } - ] + ], + "error": null }, { - "directory": "artifacts/lighthouse-review-3", - "results": [ + "sourceSummary": "artifacts/lighthouse-final-3/summary.json", + "page": "home", + "version": "13.5.0", + "fetchTime": "2026-10-28T10:19:34.917Z", + "formFactor": "mobile", + "throttling": "simulate", + "scores": { + "performance": 98, + "accessibility": 100, + "best-practices": 100, + "seo": 100 + }, + "metrics": { + "first-contentful-paint": { + "numericValue": 1654, + "displayValue": "1.7 s" + }, + "largest-contentful-paint": { + "numericValue": 2235, + "displayValue": "2.2 s" + }, + "total-blocking-time": { + "numericValue": 1, + "displayValue": "0 ms" + }, + "cumulative-layout-shift": { + "numericValue": 0, + "displayValue": "0" + }, + "speed-index": { + "numericValue": 1654, + "displayValue": "1.7 s" + }, + "total-byte-weight": { + "numericValue": 889953, + "displayValue": "Total size was 869 KiB" + } + }, + "failedAudits": [ { - "page": "home", - "version": "13.5.0", - "fetchTime": "2026-10-27T04:51:40.273Z", - "formFactor": "mobile", - "throttling": "simulate", - "scores": { - "performance": 97, - "accessibility": 100, - "best-practices": 100, - "seo": 100 - }, - "metrics": { - "first-contentful-paint": { - "numericValue": 1440, - "displayValue": "1.4 s" - }, - "largest-contentful-paint": { - "numericValue": 2535, - "displayValue": "2.5 s" - }, - "total-blocking-time": { - "numericValue": 74, - "displayValue": "70 ms" - }, - "cumulative-layout-shift": { - "numericValue": 0.00004679164839040017, - "displayValue": "0" - }, - "speed-index": { - "numericValue": 1440, - "displayValue": "1.4 s" - }, - "total-byte-weight": { - "numericValue": 1007959, - "displayValue": "Total size was 984 KiB" - } - }, - "failedAudits": [ - { - "id": "first-contentful-paint", - "score": 0.97, - "title": "First Contentful Paint" - }, - { - "id": "largest-contentful-paint", - "score": 0.89, - "title": "Largest Contentful Paint" - }, - { - "id": "total-blocking-time", - "score": 0.99, - "title": "Total Blocking Time" - }, - { - "id": "max-potential-fid", - "score": 0.86, - "title": "Max Potential First Input Delay" - }, - { - "id": "interactive", - "score": 0.98, - "title": "Time to Interactive" - }, - { - "id": "mainthread-work-breakdown", - "score": 0, - "title": "Minimize main-thread work" - }, - { - "id": "image-delivery-insight", - "score": 0, - "title": "Improve image delivery" - }, - { - "id": "network-dependency-tree-insight", - "score": 0, - "title": "Network dependency tree" - }, - { - "id": "render-blocking-insight", - "score": 0.5, - "title": "Render-blocking requests" - } - ], - "error": null + "id": "first-contentful-paint", + "score": 0.93, + "title": "First Contentful Paint" }, { - "page": "work", - "version": "13.5.0", - "fetchTime": "2026-10-27T04:52:03.144Z", - "formFactor": "mobile", - "throttling": "simulate", - "scores": { - "performance": 94, - "accessibility": 100, - "best-practices": 100, - "seo": 100 - }, - "metrics": { - "first-contentful-paint": { - "numericValue": 1391.1795, - "displayValue": "1.4 s" - }, - "largest-contentful-paint": { - "numericValue": 2216.1795, - "displayValue": "2.2 s" - }, - "total-blocking-time": { - "numericValue": 246.5, - "displayValue": "250 ms" - }, - "cumulative-layout-shift": { - "numericValue": 0, - "displayValue": "0" - }, - "speed-index": { - "numericValue": 1569.8906133913074, - "displayValue": "1.6 s" - }, - "total-byte-weight": { - "numericValue": 792451, - "displayValue": "Total size was 774 KiB" - } - }, - "failedAudits": [ - { - "id": "first-contentful-paint", - "score": 0.97, - "title": "First Contentful Paint" - }, - { - "id": "largest-contentful-paint", - "score": 0.94, - "title": "Largest Contentful Paint" - }, - { - "id": "total-blocking-time", - "score": 0.85, - "title": "Total Blocking Time" - }, - { - "id": "max-potential-fid", - "score": 0.04, - "title": "Max Potential First Input Delay" - }, - { - "id": "interactive", - "score": 0.9, - "title": "Time to Interactive" - }, - { - "id": "mainthread-work-breakdown", - "score": 0, - "title": "Minimize main-thread work" - }, - { - "id": "bootup-time", - "score": 0, - "title": "Reduce JavaScript execution time" - }, - { - "id": "unused-javascript", - "score": 0.5, - "title": "Reduce unused JavaScript" - }, - { - "id": "image-delivery-insight", - "score": 0.5, - "title": "Improve image delivery" - }, - { - "id": "network-dependency-tree-insight", - "score": 0, - "title": "Network dependency tree" - }, - { - "id": "render-blocking-insight", - "score": 0, - "title": "Render-blocking requests" - } - ], - "error": null + "id": "largest-contentful-paint", + "score": 0.94, + "title": "Largest Contentful Paint" + }, + { + "id": "interactive", + "score": 0.99, + "title": "Time to Interactive" + }, + { + "id": "mainthread-work-breakdown", + "score": 0.5, + "title": "Minimize main-thread work" + }, + { + "id": "image-delivery-insight", + "score": 0, + "title": "Improve image delivery" + }, + { + "id": "network-dependency-tree-insight", + "score": 0, + "title": "Network dependency tree" }, { - "page": "posts", - "version": "13.5.0", - "fetchTime": "2026-10-27T04:52:24.165Z", - "formFactor": "mobile", - "throttling": "simulate", - "scores": { - "performance": 100, - "accessibility": 100, - "best-practices": 100, - "seo": 100 + "id": "render-blocking-insight", + "score": 0.5, + "title": "Render-blocking requests" + } + ], + "error": null + } + ], + "beforeChange": { + "candidate": "Earlier simplified static build before the final Manrope preload change.", + "includedInFinalAggregates": false, + "sourceSummaries": [ + "artifacts/lighthouse-static/summary.json", + "artifacts/lighthouse-static-2/summary.json", + "artifacts/lighthouse-static-3/summary.json" + ], + "sampleCount": 15, + "summary": [ + { + "page": "home", + "performance": { + "median": 37, + "range": [ + 35, + 63 + ], + "samples": [ + 63, + 35, + 37 + ] + }, + "metrics": { + "largest-contentful-paint": { + "unit": "ms", + "median": 5998.5, + "range": [ + 3154, + 6375 + ], + "samples": [ + 3154, + 6375, + 5998.5 + ] }, - "metrics": { - "first-contentful-paint": { - "numericValue": 1460.402, - "displayValue": "1.5 s" - }, - "largest-contentful-paint": { - "numericValue": 1513.353, - "displayValue": "1.5 s" - }, - "total-blocking-time": { - "numericValue": 49, - "displayValue": "50 ms" - }, - "cumulative-layout-shift": { - "numericValue": 0, - "displayValue": "0" - }, - "speed-index": { - "numericValue": 1460.402, - "displayValue": "1.5 s" - }, - "total-byte-weight": { - "numericValue": 107059, - "displayValue": "Total size was 105 KiB" - } + "total-blocking-time": { + "unit": "ms", + "median": 1423.5, + "range": [ + 1179.5, + 1625 + ], + "samples": [ + 1179.5, + 1625, + 1423.5 + ] }, - "failedAudits": [ - { - "id": "first-contentful-paint", - "score": 0.96, - "title": "First Contentful Paint" - }, - { - "id": "max-potential-fid", - "score": 0.84, - "title": "Max Potential First Input Delay" - }, - { - "id": "mainthread-work-breakdown", - "score": 0, - "title": "Minimize main-thread work" - }, - { - "id": "network-dependency-tree-insight", - "score": 0, - "title": "Network dependency tree" - }, - { - "id": "render-blocking-insight", - "score": 0.5, - "title": "Render-blocking requests" - } + "cumulative-layout-shift": { + "unit": "unitless", + "median": 4.679164839040017e-05, + "range": [ + 0, + 4.679164839040017e-05 + ], + "samples": [ + 4.679164839040017e-05, + 0, + 4.679164839040017e-05 + ] + } + } + }, + { + "page": "work", + "performance": { + "median": 100, + "range": [ + 99, + 100 ], - "error": null + "samples": [ + 99, + 100, + 100 + ] + } + }, + { + "page": "projects", + "performance": { + "median": 100, + "range": [ + 100, + 100 + ], + "samples": [ + 100, + 100, + 100 + ] + } + }, + { + "page": "posts", + "performance": { + "median": 100, + "range": [ + 61, + 100 + ], + "samples": [ + 61, + 100, + 100 + ] }, - { - "page": "feedback", - "version": "13.5.0", - "fetchTime": "2026-10-27T04:52:39.044Z", - "formFactor": "mobile", - "throttling": "simulate", - "scores": { - "performance": 100, - "accessibility": 100, - "best-practices": 100, - "seo": 100 + "metrics": { + "largest-contentful-paint": { + "unit": "ms", + "median": 1525.7694, + "range": [ + 1519.7793000000001, + 6460.27385 + ], + "samples": [ + 6460.27385, + 1525.7694, + 1519.7793000000001 + ] }, - "metrics": { - "first-contentful-paint": { - "numericValue": 1358.286, - "displayValue": "1.4 s" - }, - "largest-contentful-paint": { - "numericValue": 1508.286, - "displayValue": "1.5 s" - }, - "total-blocking-time": { - "numericValue": 0, - "displayValue": "0 ms" - }, - "cumulative-layout-shift": { - "numericValue": 0.03180121031013782, - "displayValue": "0.032" - }, - "speed-index": { - "numericValue": 1358.286, - "displayValue": "1.4 s" - }, - "total-byte-weight": { - "numericValue": 110594, - "displayValue": "Total size was 108 KiB" - } + "total-blocking-time": { + "unit": "ms", + "median": 0, + "range": [ + 0, + 21 + ], + "samples": [ + 21, + 0, + 0 + ] }, - "failedAudits": [ - { - "id": "first-contentful-paint", - "score": 0.98, - "title": "First Contentful Paint" - }, - { - "id": "max-potential-fid", - "score": 0.98, - "title": "Max Potential First Input Delay" - }, - { - "id": "network-dependency-tree-insight", - "score": 0, - "title": "Network dependency tree" - }, - { - "id": "render-blocking-insight", - "score": 0, - "title": "Render-blocking requests" - } + "cumulative-layout-shift": { + "unit": "unitless", + "median": 0.0004939590886171721, + "range": [ + 0, + 0.0004939590886171721 + ], + "samples": [ + 0.0004939590886171721, + 0, + 0.0004939590886171721 + ] + } + } + }, + { + "page": "feedback", + "performance": { + "median": 100, + "range": [ + 100, + 100 ], - "error": null + "samples": [ + 100, + 100, + 100 + ] } - ] + } + ], + "note": "Baseline Home performance samples were 63, 35 and 37. Posts scored 61 in the first baseline sample, then 100 and 100. These earlier measurements remain visible as context, not final-candidate scores." + }, + "diagnostics": [ + { + "name": "Audit-order probe", + "includedInFinalAggregates": false, + "sourceReport": "artifacts/lighthouse-order-probe/home.json", + "fetchTime": "2026-10-28T10:13:09.287Z", + "page": "home", + "performance": 91, + "note": "Separate diagnostic run used to investigate audit-order or host effects. This is not a final-candidate performance result." + } + ], + "sourceDigests": [ + { + "path": "artifacts/lighthouse-final/summary.json", + "sha256": "65d37989f7025bac4776d58dca6452e48b84f725c8bf6e7460b400780af5cbc8" + }, + { + "path": "artifacts/lighthouse-final-2/summary.json", + "sha256": "57e145c0010ecf0cac3b7059eedec0eb2223417218e611dd444afa962d0a46e9" + }, + { + "path": "artifacts/lighthouse-final-3/summary.json", + "sha256": "850c1f9638ed6ede2dd59e3304b95ffe0eb523e4237ad68a5d384c661686b493" + }, + { + "path": "artifacts/lighthouse-static/summary.json", + "sha256": "e04443d1c791ec311c9574576f03c70c61da8d8750cd66fcf26bf81602e7b23a" + }, + { + "path": "artifacts/lighthouse-static-2/summary.json", + "sha256": "8690c19d6e7a7e9dad270d12c6dee0fe73817cd3ed3bcb6e56e94cf7afca08b5" + }, + { + "path": "artifacts/lighthouse-static-3/summary.json", + "sha256": "872f040467f67427bfa6be1f72cae4da3117b1e13453b49a0169c86a76996459" + }, + { + "path": "artifacts/lighthouse-order-probe/home.json", + "sha256": "0c707c73ce0d3f958c03cd13d2bfee7fe1771cd81859c643d624661590b1ff45" } ] } diff --git a/package-lock.json b/package-lock.json index cd9428b..3f27a15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wizardloop-website", - "version": "1.0.0-rc.1", + "version": "1.0.0-rc.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wizardloop-website", - "version": "1.0.0-rc.1", + "version": "1.0.0-rc.2", "license": "MIT", "dependencies": { "@astrojs/sitemap": "3.7.4", diff --git a/package.json b/package.json index 1ed5f6b..24d1e1c 100644 --- a/package.json +++ b/package.json @@ -1,23 +1,21 @@ { "name": "wizardloop-website", - "version": "1.0.0-rc.1", + "version": "1.0.0-rc.2", "private": true, "type": "module", "description": "WizardLoop personal portfolio: design, illustration, animation and software.", "scripts": { "dev": "astro dev --host 127.0.0.1 --port 4322", "dev:concepts": "node scripts/preview.mjs", - "dev:feedback": "node services/feedback/scripts/mock-server.mjs", "build": "node scripts/generate-previews.mjs && node scripts/generate-brand.mjs && astro build && node scripts/check-build.mjs", - "preview": "astro preview", + "preview": "astro preview --host 127.0.0.1 --port 4322", "format": "prettier --write \"src/**/*.{astro,css,ts}\" public/js/preferences.js astro.config.mjs playwright.config.ts", "format:check": "prettier --check \"src/**/*.{astro,css,ts}\" public/js/preferences.js astro.config.mjs playwright.config.ts", "lint": "npm run format:check && node scripts/check.mjs", "typecheck": "astro check", - "test:unit": "node --test services/feedback/test/*.test.mjs", "test": "playwright test --project=chromium", "test:browsers": "playwright test", - "check": "npm run lint && npm run typecheck && npm run test:unit && npm run build", + "check": "npm run lint && npm run typecheck && npm run build", "audit:performance": "node scripts/lighthouse.mjs", "test:concepts": "node scripts/qa.mjs", "test:concept-interactions": "node scripts/interactions.mjs" diff --git a/playwright.config.ts b/playwright.config.ts index 597e7bd..276c045 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -13,7 +13,8 @@ const chromiumExecutable = (process.platform === 'win32' && existsSync(windowsChromium) ? windowsChromium : undefined); -const browserSmoke = /@smoke|@keyboard|@preferences|@privacy|@profile|@motion/; +const browserSmoke = + /@smoke|@matrix|@keyboard|@preferences|@privacy|@profile|@motion/; export default defineConfig({ testDir: './tests', @@ -79,8 +80,7 @@ export default defineConfig({ ? undefined : { command: - process.env.PLAYWRIGHT_WEB_SERVER_COMMAND || - 'npm run preview -- --host 127.0.0.1 --port 4322', + process.env.PLAYWRIGHT_WEB_SERVER_COMMAND || 'npm run preview', url: baseURL, reuseExistingServer: true, timeout: 120_000, diff --git a/public/media/profile/profile.json b/public/media/profile/profile.json index 8b6c0ef..c9b51aa 100644 --- a/public/media/profile/profile.json +++ b/public/media/profile/profile.json @@ -1,15 +1,10 @@ { - "source": "Telegram public profile still", "avatar": "/media/profile/avatar.jpg", - "video": null, - "poster": "/media/profile/poster.jpg", "bio": "Independent designer, illustrator & animator. Expressive characters. Playful visuals. Unexpected loops.", - "bioSource": "Telegram public profile", - "syncedAt": "2026-10-27", - "checkedAt": "2026-10-27", - "status": "public-snapshot", - "posterSource": "public-profile-still", + "assetOrigin": "WizardLoop public Telegram profile, saved locally on 2026-10-27", + "verifiedAt": "2026-10-27", + "maintenance": "Edit this local file and replace avatar.jpg when needed. No synchronization or remote requests.", "width": 320, "height": 320, - "mediaNote": "Public still verified on 2026-10-27. Current primary original image or video has not been authenticated with MTProto." + "mediaNote": "Manually maintained profile artwork. Not a live Telegram profile." } diff --git a/scripts/check-build.mjs b/scripts/check-build.mjs index e19a276..d560ecd 100644 --- a/scripts/check-build.mjs +++ b/scripts/check-build.mjs @@ -1,40 +1,190 @@ import { readFile, readdir, stat } from 'node:fs/promises'; import path from 'node:path'; const errors = []; -const routes = ['', 'about', 'work', 'projects', 'posts', 'community', 'feedback', 'privacy', 'cookies', 'security', 'accessibility']; +const routes = [ + '', + 'about', + 'work', + 'projects', + 'posts', + 'community', + 'feedback', + 'privacy', + 'cookies', + 'security', + 'accessibility', + '404.html', +]; const titles = new Set(); +const descriptions = new Set(); +const pages = new Map(); for (const route of routes) { - const file = path.join('dist', route, 'index.html'); + const file = + route === '404.html' + ? path.join('dist', route) + : path.join('dist', route, 'index.html'); let html; - try { html = await readFile(file, 'utf8'); } catch { errors.push(`Missing HTML: ${route}`); continue; } + try { + html = await readFile(file, 'utf8'); + } catch { + errors.push(`Missing HTML: ${route}`); + continue; + } const title = html.match(/(.*?)<\/title>/s)?.[1]; - if (!title || titles.has(title)) errors.push(`Missing/duplicate title: ${route}`); + if (!title || titles.has(title)) + errors.push(`Missing/duplicate title: ${route}`); titles.add(title); - if (!html.includes(`https://wizardloop.com/${route ? route + '/' : ''}`)) errors.push(`Canonical missing: ${route}`); - for (const required of ['name="description"', 'property="og:image"', 'name="twitter:card"', 'application/ld+json', 'lang="en"', 'id="main"']) if (!html.includes(required)) errors.push(`Missing ${required}: ${route}`); - if ((html.match(/<h1\b/g) || []).length !== 1) errors.push(`Expected one h1: ${route}`); - if (html.includes(String.fromCodePoint(0x2014))) errors.push(`Forbidden punctuation: ${file}`); - for (const [, value] of html.matchAll(/(?:src|href)="(\/(?!\/)[^"?#]+)(?:[?#][^"]*)?"/g)) { - if (value.startsWith('/concept-')) { errors.push(`Concept link in production: ${value}`); continue; } + pages.set(route, html); + const canonical = html.match( + /<link\b(?=[^>]*\brel="canonical")[^>]*\bhref="([^"]+)"[^>]*>/, + )?.[1]; + const expectedCanonical = `https://wizardloop.com/${route === '404.html' ? '404/' : route ? route + '/' : ''}`; + if (canonical !== expectedCanonical) + errors.push(`Canonical missing or incorrect: ${route}`); + const description = html.match( + /<meta\b(?=[^>]*\bname="description")[^>]*\bcontent="([^"]+)"[^>]*>/, + )?.[1]; + if (!description || descriptions.has(description)) + errors.push(`Missing/duplicate description: ${route}`); + descriptions.add(description); + for (const required of [ + 'name="description"', + 'property="og:image"', + 'name="twitter:card"', + 'application/ld+json', + 'lang="en"', + 'id="main"', + ]) + if (!html.includes(required)) errors.push(`Missing ${required}: ${route}`); + if ((html.match(/<h1\b/g) || []).length !== 1) + errors.push(`Expected one h1: ${route}`); + if (html.includes(String.fromCodePoint(0x2014))) + errors.push(`Forbidden punctuation: ${file}`); + if ( + /<(?:form|iframe)\b|\bdata-(?:feedback-form|telegram-feed|profile-video|privacy-open)\b|\bid="privacy-dialog"/i.test( + html, + ) + ) + errors.push(`Removed interactive service remains: ${route}`); + if ( + route === '404.html' && + !/<meta\b(?=[^>]*\bname="robots")(?=[^>]*\bcontent="[^"]*noindex)[^>]*>/.test( + html, + ) + ) + errors.push('404 must remain noindex'); + for (const [, resource] of html.matchAll( + /<(?:script|img|video|source|audio)\b[^>]*\b(?:src|poster)="([^"]+)"/g, + )) { + if (/^(?:https?:)?\/\//i.test(resource)) + errors.push(`Non-local loaded resource ${resource} in ${route}`); + } + for (const [, resource] of html.matchAll( + /<link\b(?=[^>]*\brel="(?:stylesheet|preload|modulepreload|preconnect|dns-prefetch)")[^>]*\bhref="([^"]+)"/g, + )) { + if (/^(?:https?:)?\/\//i.test(resource)) + errors.push(`Non-local resource hint ${resource} in ${route}`); + } + for (const [, value] of html.matchAll( + /(?:src|href)="(\/(?!\/)[^"?#]+)(?:[?#][^"]*)?"/g, + )) { + if (value.startsWith('/concept-')) { + errors.push(`Concept link in production: ${value}`); + continue; + } let target = path.join('dist', value); - try { const info = await stat(target); if (info.isDirectory()) target = path.join(target, 'index.html'); await stat(target); } catch { errors.push(`Missing build target ${value} in ${route}`); } + try { + const info = await stat(target); + if (info.isDirectory()) target = path.join(target, 'index.html'); + await stat(target); + } catch { + errors.push(`Missing build target ${value} in ${route}`); + } } } -for (const file of ['CNAME', 'robots.txt', 'sitemap-index.xml', '404.html', 'social-preview.png', 'favicon.svg', 'favicon-16.png', 'favicon-32.png', 'apple-touch-icon.png', 'media/hero/portal-light.mp4', 'media/hero/portal-dark.mp4']) { - try { const value = await stat(path.join('dist', file)); if (!value.size) errors.push(`Empty asset ${file}`); } catch { errors.push(`Missing build asset ${file}`); } +for (const [route, destinations] of [ + ['work', ['https://t.me/wizard_loop']], + ['projects', ['https://github.com/WizardLoop?tab=repositories']], + ['posts', ['https://t.me/s/wizard_loop']], + ['feedback', ['https://t.me/wizardloop', 'mailto:wizard@wizardloop.com']], +]) { + const main = + pages.get(route)?.match(/<main\b[^>]*>([\s\S]*?)<\/main>/)?.[1] || ''; + for (const href of destinations) + if (!main.includes(`href="${href}"`)) + errors.push(`Missing direct link ${href} on ${route}`); +} +if ( + /\bdata-animation\b|\bcollection-grid\b|\bdata-show-collection\b/.test( + pages.get('work') || '', + ) +) + errors.push( + 'Work must remain a direct channel gateway without a local collection', + ); +for (const route of ['', 'projects']) { + const main = + pages.get(route)?.match(/<main\b[^>]*>([\s\S]*?)<\/main>/)?.[1] || ''; + if ( + /href="https:\/\/github\.com\/WizardLoop\/|\bcollection-grid\b|\bproject-row\b/.test( + main, + ) + ) + errors.push( + `Individual project or collection listing remains on ${route || 'home'}`, + ); +} +for (const file of [ + 'CNAME', + 'robots.txt', + 'sitemap-index.xml', + '404.html', + 'social-preview.png', + 'favicon.svg', + 'favicon-16.png', + 'favicon-32.png', + 'apple-touch-icon.png', + 'media/hero/portal-light.mp4', + 'media/hero/portal-dark.mp4', +]) { + try { + const value = await stat(path.join('dist', file)); + if (!value.size) errors.push(`Empty asset ${file}`); + } catch { + errors.push(`Missing build asset ${file}`); + } } -if ((await readFile('dist/CNAME', 'utf8')).trim() !== 'wizardloop.com') errors.push('Build CNAME changed'); -for (const file of await readdir('dist')) if (/^(?:concept-|\.env|services|node_modules)/.test(file)) errors.push(`Private or preview directory included: ${file}`); +if ((await readFile('dist/CNAME', 'utf8')).trim() !== 'wizardloop.com') + errors.push('Build CNAME changed'); +for (const file of await readdir('dist')) + if (/^(?:concept-|\.env|services|node_modules)/.test(file)) + errors.push(`Private or preview directory included: ${file}`); async function scan(dir) { for (const item of await readdir(dir, { withFileTypes: true })) { const file = path.join(dir, item.name); if (item.isDirectory()) await scan(file); else if (/\.(?:html|js|json|css|txt)$/.test(item.name)) { const content = await readFile(file, 'utf8'); - if (/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/.test(content) || /\b\d{8,12}:[A-Za-z0-9_-]{35}\b/.test(content)) errors.push(`Potential secret in build: ${file}`); + if ( + /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/.test(content) || + /\b\d{8,12}:[A-Za-z0-9_-]{35}\b/.test(content) + ) + errors.push(`Potential secret in build: ${file}`); + if ( + file.replaceAll('\\', '/').startsWith('dist/_astro/') && + /telegram-widget|challenges\.cloudflare\.com|api\.telegram\.org|turnstile|data-feedback-form|data-telegram-feed/i.test( + content, + ) + ) + errors.push(`Removed service code in built browser assets: ${file}`); } } } await scan('dist'); -if (errors.length) { console.error([...new Set(errors)].join('\n')); process.exitCode = 1; } -else console.log(`PASS: ${routes.length} static pages, local links/assets, SEO without JavaScript, profile assets, CNAME and basic secret patterns.`); +if (errors.length) { + console.error([...new Set(errors)].join('\n')); + process.exitCode = 1; +} else + console.log( + `PASS: ${routes.length} static pages, direct contact/gateway links, local resources, removed-service checks, SEO without JavaScript, CNAME and basic secret patterns.`, + ); diff --git a/scripts/check.mjs b/scripts/check.mjs index 7c6e8ee..922b4b0 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -5,36 +5,98 @@ const errors = []; async function walk(dir) { const list = []; for (const entry of await readdir(dir, { withFileTypes: true })) { - if (['node_modules', '.git', 'artifacts', '.astro', '.venv', '.deps', '__pycache__', 'test-output', 'dist', 'test-results', 'playwright-report', '.wrangler'].includes(entry.name)) continue; + if ( + [ + 'node_modules', + '.git', + 'artifacts', + '.astro', + '.venv', + '.deps', + '__pycache__', + 'test-output', + 'dist', + 'test-results', + 'playwright-report', + '.wrangler', + ].includes(entry.name) + ) + continue; const file = path.join(dir, entry.name); - if (entry.isDirectory()) list.push(...await walk(file)); else list.push(file); + if (entry.isDirectory()) list.push(...(await walk(file))); + else list.push(file); } return list; } const files = await walk('.'); +for (const removed of [ + 'services/feedback', + 'services/profile-sync', + 'services/posts-sync', + 'src/scripts/feedback.ts', + 'src/scripts/posts.ts', +]) { + try { + await access(removed); + errors.push(`Removed service implementation remains: ${removed}`); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } +} for (const file of files) { - if (!/\.(html|astro|css|js|mjs|ts|py|json|jsonc|md|ya?ml|txt|svg)$/.test(file) || file.endsWith('package-lock.json')) continue; + if ( + !/\.(html|astro|css|js|mjs|ts|py|json|jsonc|md|ya?ml|txt|svg)$/.test( + file, + ) || + file.endsWith('package-lock.json') + ) + continue; if (file.replaceAll('\\', '/').startsWith('public/media/licenses/')) continue; const data = await readFile(file, 'utf8'); - if (data.includes(String.fromCodePoint(0x2014))) errors.push(`Forbidden punctuation: ${file}`); - if (/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/.test(data) || /\b\d{8,12}:[A-Za-z0-9_-]{35}\b/.test(data)) errors.push(`Potential secret in source: ${file}`); + if (data.includes(String.fromCodePoint(0x2014))) + errors.push(`Forbidden punctuation: ${file}`); + if ( + /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/.test(data) || + /\b\d{8,12}:[A-Za-z0-9_-]{35}\b/.test(data) + ) + errors.push(`Potential secret in source: ${file}`); if (file.endsWith('.mjs') || file.endsWith('.js')) { - const result = spawnSync(process.execPath, ['--check', file], { encoding: 'utf8' }); - if (result.status) errors.push(`JavaScript syntax: ${file}\n${result.stderr}`); + const result = spawnSync(process.execPath, ['--check', file], { + encoding: 'utf8', + }); + if (result.status) + errors.push(`JavaScript syntax: ${file}\n${result.stderr}`); } if (file.startsWith('concepts') && file.endsWith('.html')) { - if (!/<meta name="robots" content="noindex/.test(data)) errors.push(`Missing noindex: ${file}`); - const ids = [...data.matchAll(/\bid="([^"]+)"/g)].map(x => x[1]); + if (!/<meta name="robots" content="noindex/.test(data)) + errors.push(`Missing noindex: ${file}`); + const ids = [...data.matchAll(/\bid="([^"]+)"/g)].map((x) => x[1]); if (new Set(ids).size !== ids.length) errors.push(`Duplicate IDs: ${file}`); - for (const [, target] of data.matchAll(/href="#([^"]+)"/g)) if (!ids.includes(target)) errors.push(`Missing section #${target}: ${file}`); - for (const [, asset] of data.matchAll(/(?:src|href)="(\/assets\/[^"?]+)"/g)) { - try { await access(path.join('concepts', asset)); } catch { errors.push(`Missing asset ${asset}: ${file}`); } + for (const [, target] of data.matchAll(/href="#([^"]+)"/g)) + if (!ids.includes(target)) + errors.push(`Missing section #${target}: ${file}`); + for (const [, asset] of data.matchAll( + /(?:src|href)="(\/assets\/[^"?]+)"/g, + )) { + try { + await access(path.join('concepts', asset)); + } catch { + errors.push(`Missing asset ${asset}: ${file}`); + } } - if (/<script[^>]+src="https?:/.test(data)) errors.push(`Unexpected remote script: ${file}`); + if (/<script[^>]+src="https?:/.test(data)) + errors.push(`Unexpected remote script: ${file}`); } } -if ((await readFile('CNAME', 'utf8')).trim() !== 'wizardloop.com') errors.push('CNAME changed'); +if ((await readFile('CNAME', 'utf8')).trim() !== 'wizardloop.com') + errors.push('CNAME changed'); const workflow = await readFile('.github/workflows/ci.yml', 'utf8'); -if (/deploy-pages|pages: write|id-token: write/.test(workflow)) errors.push('Preview CI must not deploy'); -if (errors.length) { console.error(errors.join('\n')); process.exitCode = 1; } -else console.log(`PASS: ${files.length} files inspected. Punctuation, syntax, local assets, section links, noindex, CNAME and non-deploying CI checks passed.`); +if (/deploy-pages|pages: write|id-token: write/.test(workflow)) + errors.push('Preview CI must not deploy'); +if (errors.length) { + console.error(errors.join('\n')); + process.exitCode = 1; +} else + console.log( + `PASS: ${files.length} files inspected. Punctuation, syntax, local assets, section links, noindex, CNAME and non-deploying CI checks passed.`, + ); diff --git a/scripts/lighthouse.mjs b/scripts/lighthouse.mjs index 7c4573c..dbd1210 100644 --- a/scripts/lighthouse.mjs +++ b/scripts/lighthouse.mjs @@ -5,12 +5,14 @@ import { existsSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; const output = process.env.LIGHTHOUSE_OUTPUT || 'artifacts/lighthouse'; +const pages = (process.env.LIGHTHOUSE_PAGES || 'home,work,projects,posts,feedback').split(','); +if (pages.some(page => !['home', 'work', 'projects', 'posts', 'feedback'].includes(page))) throw new Error('Unknown audit page'); await mkdir(output, { recursive: true }); const windowsChrome = join(homedir(), 'AppData/Local/ms-playwright/chromium-1228/chrome-win64/chrome.exe'); const chrome = await launch({ chromePath: process.env.CHROME_PATH || (process.platform === 'win32' && existsSync(windowsChrome) ? windowsChrome : undefined), chromeFlags: ['--headless', '--no-sandbox', '--disable-dev-shm-usage'] }); const results = []; try { - for (const page of ['home', 'work', 'posts', 'feedback']) { + for (const page of pages) { const result = await lighthouse(`http://127.0.0.1:4322/${page === 'home' ? '' : page + '/'}`, { port: chrome.port, output: ['html', 'json'], logLevel: 'error', onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'] }); await writeFile(`${output}/page-${page}.html`, result.report[0]); await writeFile(`${output}/page-${page}.json`, result.report[1]); diff --git a/services/feedback/.env.example b/services/feedback/.env.example deleted file mode 100644 index 7954efd..0000000 --- a/services/feedback/.env.example +++ /dev/null @@ -1,10 +0,0 @@ -# Copy only into an ignored .dev.vars file or the platform secret manager. -# Never add real values to this example. -MODE=production -ALLOWED_ORIGINS=https://wizardloop.com -TURNSTILE_HOSTNAMES=wizardloop.com -TURNSTILE_SECRET_KEY= -TELEGRAM_BOT_TOKEN= -TELEGRAM_CHAT_ID= -TELEGRAM_CHAT_ID_VERIFIED=false -RATE_LIMIT_SALT= diff --git a/services/feedback/.gitignore b/services/feedback/.gitignore deleted file mode 100644 index 2f7f78d..0000000 --- a/services/feedback/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -.dev.vars -.dev.vars.* -.env -.env.* -!.env.example -.wrangler/ -node_modules/ -dist/ diff --git a/services/feedback/package-lock.json b/services/feedback/package-lock.json deleted file mode 100644 index efa4e58..0000000 --- a/services/feedback/package-lock.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "wizardloop-feedback-worker", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "wizardloop-feedback-worker", - "version": "0.1.0", - "license": "MIT", - "engines": { "node": ">=22" } - } - } -} diff --git a/services/feedback/package.json b/services/feedback/package.json deleted file mode 100644 index 6e558d1..0000000 --- a/services/feedback/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "wizardloop-feedback-worker", - "version": "0.1.0", - "private": true, - "type": "module", - "license": "MIT", - "description": "Separate feedback gateway. No deployment is performed by repository workflows.", - "scripts": { - "test": "node --test test/*.test.mjs", - "dev:mock": "node scripts/mock-server.mjs", - "setup:chat": "node scripts/verify-chat.mjs" - }, - "engines": { "node": ">=22" } -} diff --git a/services/feedback/scripts/mock-server.mjs b/services/feedback/scripts/mock-server.mjs deleted file mode 100644 index a064301..0000000 --- a/services/feedback/scripts/mock-server.mjs +++ /dev/null @@ -1,33 +0,0 @@ -import { createServer } from 'node:http'; -import { Readable } from 'node:stream'; -import worker from '../worker.mjs'; - -const host = '127.0.0.1'; -const port = process.env.MOCK_PORT === undefined ? 8787 : Number(process.env.MOCK_PORT); -if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('MOCK_PORT must be a valid local port.'); -const env = { - MODE: 'mock', - ALLOWED_ORIGINS: 'http://127.0.0.1:4321,http://localhost:4321,http://127.0.0.1:4322,http://localhost:4322', -}; - -const server = createServer(async (incoming, outgoing) => { - try { - const headers = new Headers(); - for (const [key, value] of Object.entries(incoming.headers)) if (value !== undefined) headers.set(key, Array.isArray(value) ? value.join(',') : value); - const request = new Request(`http://${host}:${port}${incoming.url || '/'}`, { - method: incoming.method, - headers, - ...(!['GET', 'HEAD'].includes(incoming.method || '') ? { body: Readable.toWeb(incoming), duplex: 'half' } : {}), - }); - const response = await worker.fetch(request, env); - outgoing.writeHead(response.status, Object.fromEntries(response.headers)); - outgoing.end(await response.text()); - } catch { - outgoing.writeHead(400, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); - outgoing.end('{"ok":false,"code":"invalid_request"}'); - } -}); -server.listen(port, host, () => { - console.log(`Local feedback mock: http://${host}:${server.address().port}/feedback`); - console.log('Explicit local mock only. No CAPTCHA verification or Telegram messages are sent.'); -}); diff --git a/services/feedback/scripts/verify-chat.mjs b/services/feedback/scripts/verify-chat.mjs deleted file mode 100644 index ded80a3..0000000 --- a/services/feedback/scripts/verify-chat.mjs +++ /dev/null @@ -1,74 +0,0 @@ -/** Run interactively on a trusted machine. Nothing is written to disk. */ -import { randomBytes } from 'node:crypto'; -import { createInterface } from 'node:readline/promises'; - -async function hiddenToken() { - if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error('Use an interactive terminal. Tokens are never accepted as arguments.'); - process.stdout.write('Telegram bot token (hidden): '); - process.stdin.setRawMode(true); - process.stdin.resume(); - return new Promise((resolve, reject) => { - let input = ''; - const cleanup = () => { process.stdin.off('data', receive); process.stdin.setRawMode(false); process.stdin.pause(); process.stdout.write('\n'); }; - const receive = chunk => { - for (const char of chunk.toString()) { - if (char === '\u0003') { cleanup(); reject(new Error('Cancelled.')); return; } - if (char === '\r' || char === '\n') { cleanup(); resolve(input.trim()); return; } - if (char === '\u007f' || char === '\b') input = input.slice(0, -1); - else if (char >= ' ' && input.length < 200) input += char; - } - }; - process.stdin.on('data', receive); - }); -} - -let token = ''; -try { - if (process.argv.length !== 2) throw new Error('This helper accepts no command-line arguments.'); - token = await hiddenToken(); - if (!/^\d{5,}:[A-Za-z0-9_-]{20,}$/.test(token)) throw new Error('The token format is invalid.'); - async function api(method, body = {}) { - let response, payload; - try { - response = await fetch(`https://api.telegram.org/bot${token}/${method}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), - signal: AbortSignal.timeout(15_000), redirect: 'error', - }); - payload = await response.json(); - } catch { throw new Error('Telegram could not be reached. No credentials were saved.'); } - if (!response.ok || payload.ok !== true) throw new Error('Telegram rejected the setup request. Check the bot and try again.'); - return payload.result; - } - const me = await api('getMe'); - const webhook = await api('getWebhookInfo'); - if (webhook.url) throw new Error('This bot has an active webhook. Use a dedicated feedback bot; this helper will not remove a webhook.'); - const challenge = `wizardloop-verify-${randomBytes(18).toString('base64url')}`; - console.log(`Open https://t.me/${me.username} from your @wizardloop account and press Start.`); - console.log('Send this one-time text in the private chat:'); - console.log(challenge); - const terminal = createInterface({ input: process.stdin, output: process.stdout }); - await terminal.question('Press Enter after sending that text. '); - terminal.close(); - const updates = await api('getUpdates', { limit: 100, timeout: 0, allowed_updates: ['message'] }); - const matches = updates.filter(update => { - const message = update.message; - return message?.chat?.type === 'private' && message?.from?.is_bot === false - && message.from.id === message.chat.id && message.from.username?.toLowerCase() === 'wizardloop' - && message.text === challenge; - }); - if (matches.length !== 1) throw new Error('No unique matching private message was found. Check the account, send the exact text, and rerun.'); - const chatId = String(matches[0].message.chat.id); - const chat = await api('getChat', { chat_id: chatId }); - if (chat.type !== 'private' || chat.username?.toLowerCase() !== 'wizardloop' || String(chat.id) !== chatId || !/^[1-9]\d{0,15}$/.test(chatId)) { - throw new Error('The private chat identity could not be confirmed.'); - } - console.log('Verified a private chat belonging to @wizardloop. No test message was sent.'); - console.log('Copy this value into the TELEGRAM_CHAT_ID secret using the platform secret manager:'); - console.log(chatId); - console.log('Set TELEGRAM_CHAT_ID_VERIFIED=true after review. Actual send permission still needs an approved delivery test.'); - console.log('This helper wrote no files and did not change Telegram webhook or update offsets. Clear this terminal if it is shared.'); -} catch (error) { - // Only explicitly authored messages are printed; upstream errors may contain token-bearing URLs. - console.error(error instanceof Error && !/https:\/\/api\.telegram/.test(error.message) ? error.message : 'Setup failed. No credentials were saved.'); - process.exitCode = 1; -} finally { token = ''; } diff --git a/services/feedback/test/mock-server.test.mjs b/services/feedback/test/mock-server.test.mjs deleted file mode 100644 index 7e68f84..0000000 --- a/services/feedback/test/mock-server.test.mjs +++ /dev/null @@ -1,32 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; - -test('local HTTP adapter returns an explicit mock result and refuses remote origins', { timeout: 15_000 }, async () => { - const child = spawn(process.execPath, [fileURLToPath(new URL('../scripts/mock-server.mjs', import.meta.url))], { - env: { ...process.env, MOCK_PORT: '0' }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, - }); - try { - const endpoint = await new Promise((resolve, reject) => { - let text = ''; - child.stdout.on('data', chunk => { text += chunk; const found = text.match(/http:\/\/127\.0\.0\.1:\d+\/feedback/); if (found) resolve(found[0]); }); - child.on('error', reject); - child.on('exit', code => reject(new Error(`Local mock exited before startup (${code}).`))); - }); - const send = origin => fetch(endpoint, { - method: 'POST', headers: { Origin: origin, 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'Local test', message: 'Offline mock smoke test.', turnstileToken: 'local-mock-token' }), - }); - const success = await send('http://localhost:4321'); - assert.equal(success.status, 200); - assert.equal((await success.json()).mock, true); - assert.equal(success.headers.get('Access-Control-Allow-Origin'), 'http://localhost:4321'); - const denied = await send('https://wizardloop.com'); - assert.equal(denied.status, 403); - assert.equal(denied.headers.has('Access-Control-Allow-Origin'), false); - } finally { - child.kill(); - await new Promise(resolve => { if (child.exitCode !== null) resolve(); else child.once('exit', resolve); }); - } -}); diff --git a/services/feedback/test/worker.test.mjs b/services/feedback/test/worker.test.mjs deleted file mode 100644 index 97c9b57..0000000 --- a/services/feedback/test/worker.test.mjs +++ /dev/null @@ -1,194 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { createWorker } from '../worker.mjs'; - -const at = Date.UTC(2026, 9, 27, 12); -const base = { name: 'A visitor', message: 'Hello from the website.', email: '', website: '', turnstileToken: 'fresh-example-token', sourcePath: '/feedback/' }; -const env = (overrides = {}) => ({ - MODE: 'production', ALLOWED_ORIGINS: 'https://wizardloop.com', TURNSTILE_HOSTNAMES: 'wizardloop.com', - TURNSTILE_SECRET_KEY: 'unit-test-only-turnstile-secret', TELEGRAM_BOT_TOKEN: '123456:test_only_not_a_real_token_123456', - TELEGRAM_CHAT_ID: '123456789', TELEGRAM_CHAT_ID_VERIFIED: 'true', RATE_LIMIT_SALT: 'unit-test-only-random-salt-value', - RATE_LIMITER: { limit: async () => ({ success: true }) }, ...overrides, -}); -const request = (body = base, { origin = 'https://wizardloop.com', headers = {}, method = 'POST', url = 'https://feedback.invalid/feedback' } = {}) => new Request(url, { - method, headers: { Origin: origin, 'Content-Type': 'application/json', 'CF-Connecting-IP': '192.0.2.1', ...headers }, - ...(!['GET', 'HEAD', 'OPTIONS'].includes(method) ? { body: typeof body === 'string' ? body : JSON.stringify(body) } : {}), -}); -function transport({ verify, delivery, throwsAt, failVerificationOnce = false } = {}) { - const calls = []; - const fetch = async (url, options) => { - calls.push({ url, options }); - const isVerify = url.includes('siteverify'); - if (throwsAt === (isVerify ? 'verify' : 'delivery')) throw new Error('Sensitive upstream error must never escape.'); - if (isVerify && failVerificationOnce && calls.filter(c => c.url.includes('siteverify')).length === 1) return Response.json({ 'error-codes': ['internal-error'] }, { status: 503 }); - return Response.json(isVerify ? (verify || { success: true, action: 'feedback', hostname: 'wizardloop.com' }) : (delivery || { ok: true, result: { message_id: 1 } })); - }; - return { calls, fetch }; -} -async function run(body = base, options = {}, environment = env(), upstream = transport()) { - const worker = createWorker({ fetch: upstream.fetch, now: () => at }); - const response = await worker.fetch(request(body, options), environment); - return { response, payload: await response.json(), calls: upstream.calls }; -} - -test('valid verified feedback sends one safely escaped private Telegram message', async () => { - const { response, payload, calls } = await run({ ...base, name: '<b>Visitor</b>', message: '<a href="evil">hello</a> & goodbye', email: 'hello@example.invalid' }); - assert.equal(response.status, 200); - assert.deepEqual(payload, { ok: true, mock: false, message: 'Your feedback was sent. Thank you.' }); - assert.equal(calls.length, 2); - const sent = JSON.parse(calls[1].options.body); - assert.equal(sent.chat_id, '123456789'); - assert.equal(sent.parse_mode, 'HTML'); - assert.match(sent.text, /<b>Visitor<\/b>/); - assert.match(sent.text, /<a href="evil">/); - assert.match(sent.text, /2026-10-27T12:00:00.000Z/); - assert.match(sent.text, /https:\/\/wizardloop.com\/feedback\//); - assert.doesNotMatch(sent.text, /192\.0\.2\.1/); - assert.equal(sent.link_preview_options.is_disabled, true); - assert.equal(calls[0].options.body.has('remoteip'), false); -}); - -test('required fields, length limits, malformed email and header injection reject before CAPTCHA', async () => { - for (const body of [{ ...base, name: '' }, { ...base, message: '' }, { ...base, name: 'x'.repeat(81) }, { ...base, message: 'x'.repeat(2501) }, { ...base, email: 'bad\r\nBcc:someone' }, { ...base, name: 'A\nB' }, { ...base, message: '\u0000' }, { ...base, email: {} }]) { - const result = await run(body); - assert.equal(result.response.status, 422); - assert.equal(result.calls.length, 0); - } -}); - -test('honeypot, unsupported fields and attacker-controlled source URLs reject', async () => { - for (const body of [{ ...base, website: 'spam' }, { ...base, chat_id: '999' }, { ...base, sourcePath: '//evil.invalid/' }, { ...base, sourcePath: 'https://evil.invalid/' }, { ...base, sourcePath: '/feedback/?email=visitor' }]) { - const result = await run(body); - assert.equal(result.response.status, 422); - assert.equal(result.calls.length, 0); - } -}); - -test('unknown, lookalike, null and noncanonical origins have no CORS response', async () => { - for (const origin of ['https://wizardloop.com.evil.invalid', 'https://evil.invalid', 'null', 'https://wizardloop.com/', 'http://wizardloop.com']) { - const result = await run(base, { origin }); - assert.equal(result.response.status, 403); - assert.equal(result.response.headers.has('Access-Control-Allow-Origin'), false); - assert.equal(result.calls.length, 0); - } -}); - -test('a missing Origin cannot reach Telegram', async () => { - const req = request(); req.headers.delete('Origin'); - const upstream = transport(); - const result = await createWorker({ fetch: upstream.fetch }).fetch(req, env()); - assert.equal(result.status, 403); assert.equal(upstream.calls.length, 0); -}); - -test('preflight restricts methods and headers, returning exact origin and no credentials wildcard', async () => { - const worker = createWorker(); - const result = await worker.fetch(request(base, { method: 'OPTIONS', headers: { 'Access-Control-Request-Method': 'POST', 'Access-Control-Request-Headers': 'content-type' } }), env()); - assert.equal(result.status, 204); - assert.equal(result.headers.get('Access-Control-Allow-Origin'), 'https://wizardloop.com'); - assert.equal(result.headers.get('Access-Control-Allow-Headers'), 'Content-Type'); - assert.equal(result.headers.has('Access-Control-Allow-Credentials'), false); - const invalid = await worker.fetch(request(base, { method: 'OPTIONS', headers: { 'Access-Control-Request-Method': 'DELETE' } }), env()); - assert.equal(invalid.status, 403); -}); - -test('invalid JSON, wrong content type and byte limits fail safely', async () => { - for (const [body, headers, status] of [['{', {}, 400], ['[]', {}, 422], [base, { 'Content-Type': 'text/plain' }, 415], ['x'.repeat(16385), {}, 413], [base, { 'Content-Length': '16385' }, 413], [{ ...base, message: 'א'.repeat(8500) }, {}, 413]]) { - const result = await run(body, { headers }); - assert.equal(result.response.status, status); assert.equal(result.calls.length, 0); - } -}); - -test('missing rate limiter, secrets, verification flag and test CAPTCHA keys fail closed', async () => { - for (const override of [{ RATE_LIMITER: undefined }, { TURNSTILE_SECRET_KEY: '' }, { TELEGRAM_CHAT_ID: '@wizardloop' }, { TELEGRAM_CHAT_ID: '-1234' }, { TELEGRAM_CHAT_ID_VERIFIED: 'false' }, { RATE_LIMIT_SALT: '' }, { MODE: undefined }, { TURNSTILE_SECRET_KEY: '1x0000000000000000000000000000000AA' }]) { - const result = await run(base, {}, env(override)); - assert.equal(result.response.status, 503); assert.equal(result.calls.length, 0); - } -}); - -test('rate limit rejects before Siteverify and sends a Retry-After header', async () => { - const result = await run(base, {}, env({ RATE_LIMITER: { limit: async () => ({ success: false }) } })); - assert.equal(result.response.status, 429); assert.equal(result.response.headers.get('Retry-After'), '60'); assert.equal(result.calls.length, 0); -}); - -test('rate-limit infrastructure failures do not bypass protection', async () => { - const result = await run(base, {}, env({ RATE_LIMITER: { limit: async () => { throw new Error('internal details'); } } })); - assert.equal(result.response.status, 503); assert.equal(result.calls.length, 0); assert.doesNotMatch(JSON.stringify(result.payload), /internal details/); -}); - -test('rate keys do not expose raw IP and change after daily rotation', async () => { - const keys = []; - const e = env({ RATE_LIMITER: { limit: async ({ key }) => { keys.push(key); return { success: false }; } } }); - for (const time of [at, at, at + 86_400_000]) await createWorker({ now: () => time }).fetch(request(), e); - assert.match(keys[0], /^feedback:[a-f0-9]{64}$/); assert.equal(keys[0], keys[1]); assert.notEqual(keys[0], keys[2]); assert.doesNotMatch(keys[0], /192\.0\.2/); -}); - -test('invalid CAPTCHA, replay, wrong action and wrong hostname never send', async () => { - for (const verify of [{ success: false }, { success: false, 'error-codes': ['timeout-or-duplicate'] }, { success: true, action: 'login', hostname: 'wizardloop.com' }, { success: true, action: 'feedback', hostname: 'sub.wizardloop.com' }]) { - const result = await run(base, {}, env(), transport({ verify })); - assert.equal(result.response.status, 422); assert.equal(result.payload.code, 'captcha'); assert.equal(result.calls.length, 1); - } -}); - -test('verification transient retry reuses one idempotency key and then sends once', async () => { - const result = await run(base, {}, env(), transport({ failVerificationOnce: true })); - assert.equal(result.response.status, 200); assert.equal(result.calls.length, 3); - assert.equal(result.calls[0].options.body.get('idempotency_key'), result.calls[1].options.body.get('idempotency_key')); -}); - -test('CAPTCHA network failure is bounded and does not leak upstream details', async () => { - const result = await run(base, {}, env(), transport({ throwsAt: 'verify' })); - assert.equal(result.response.status, 503); assert.equal(result.calls.length, 2); assert.equal(result.payload.code, 'verification_unavailable'); assert.doesNotMatch(JSON.stringify(result.payload), /Sensitive/); -}); - -test('upstream timeout aborts requests without an unbounded wait', async () => { - let calls = 0; - const worker = createWorker({ timeoutMs: 5, fetch: async (_url, { signal }) => new Promise((_, reject) => { calls += 1; signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))); }) }); - const response = await worker.fetch(request(), env()); - assert.equal(response.status, 503); assert.equal(calls, 2); -}); - -test('Telegram rejection is generic and a later user retry can succeed with a fresh CAPTCHA', async () => { - const failed = await run(base, {}, env(), transport({ delivery: { ok: false, error_code: 429, parameters: { retry_after: 30 }, description: 'sensitive upstream detail' } })); - assert.equal(failed.response.status, 503); assert.equal(failed.response.headers.get('Retry-After'), '30'); assert.equal(failed.calls.length, 2); assert.doesNotMatch(JSON.stringify(failed.payload), /sensitive/); - const retry = await run({ ...base, turnstileToken: 'new-token-after-reset' }); - assert.equal(retry.response.status, 200); assert.equal(retry.calls.length, 2); -}); - -test('ambiguous Telegram timeout is not automatically retried or reported as successful', async () => { - const result = await run(base, {}, env(), transport({ throwsAt: 'delivery' })); - assert.equal(result.response.status, 503); assert.equal(result.calls.length, 2); assert.equal(result.payload.mayHaveBeenDelivered, true); assert.equal(result.payload.ok, false); -}); - -test('security headers apply to failures and success', async () => { - for (const result of [await run(), await run({ ...base, message: '' })]) { - assert.equal(result.response.headers.get('Cache-Control'), 'no-store'); - assert.equal(result.response.headers.get('X-Content-Type-Options'), 'nosniff'); - assert.equal(result.response.headers.get('Referrer-Policy'), 'no-referrer'); - assert.match(result.response.headers.get('Content-Security-Policy'), /frame-ancestors 'none'/); - } -}); - -test('mock requires explicit mode, a loopback target and allowed loopback origin', async () => { - const upstream = transport(); const worker = createWorker({ fetch: upstream.fetch, now: () => at }); - const localEnv = { MODE: 'mock', ALLOWED_ORIGINS: 'http://localhost:4321,https://wizardloop.com' }; - const localRequest = body => request(body, { origin: 'http://localhost:4321', url: 'http://127.0.0.1:8787/feedback' }); - const okay = await worker.fetch(localRequest({ ...base, turnstileToken: 'local-mock-token' }), localEnv); - assert.equal(okay.status, 200); assert.equal((await okay.json()).mock, true); assert.equal(upstream.calls.length, 0); - const badToken = await worker.fetch(localRequest(base), localEnv); assert.equal(badToken.status, 422); - const publicTarget = await worker.fetch(request({ ...base, turnstileToken: 'local-mock-token' }), localEnv); assert.equal(publicTarget.status, 503); - const noMode = await worker.fetch(localRequest(base), { ...localEnv, MODE: undefined }); assert.notEqual(noMode.status, 200); -}); - -test('local mock is rate limited and window expires', async () => { - let clock = at; const worker = createWorker({ now: () => clock }); const localEnv = { MODE: 'mock', ALLOWED_ORIGINS: 'http://localhost:4321' }; - const send = () => worker.fetch(request({ ...base, turnstileToken: 'local-mock-token' }, { origin: 'http://localhost:4321', url: 'http://127.0.0.1:8787/feedback' }), localEnv); - for (let i = 0; i < 5; i += 1) assert.equal((await send()).status, 200); - assert.equal((await send()).status, 429); clock += 60_000; assert.equal((await send()).status, 200); -}); - -test('unknown routes and unsupported methods cannot submit', async () => { - const upstream = transport(); const worker = createWorker({ fetch: upstream.fetch }); - assert.equal((await worker.fetch(request(base, { url: 'https://feedback.invalid/other' }), env())).status, 404); - assert.equal((await worker.fetch(request(base, { method: 'GET' }), env())).status, 405); - assert.equal(upstream.calls.length, 0); -}); diff --git a/services/feedback/worker.mjs b/services/feedback/worker.mjs deleted file mode 100644 index 728aa46..0000000 --- a/services/feedback/worker.mjs +++ /dev/null @@ -1,260 +0,0 @@ -/** - * WizardLoop feedback gateway. MIT licensed source code. - * No persistence and no application logging of visitor data or credentials. - * Runtime: Cloudflare Workers standard Web APIs; tests: Node 22+. - * @typedef {{limit(options:{key:string}):Promise<{success:boolean}>}} RateLimiter - * @typedef {{MODE?:string, ALLOWED_ORIGINS?:string, TURNSTILE_HOSTNAMES?:string, - * TURNSTILE_SECRET_KEY?:string, TELEGRAM_BOT_TOKEN?:string, TELEGRAM_CHAT_ID?:string, - * TELEGRAM_CHAT_ID_VERIFIED?:string, RATE_LIMIT_SALT?:string, RATE_LIMITER?:RateLimiter}} Env - */ - -const MAX_BODY_BYTES = 16_384; -const BODY_TIMEOUT_MS = 5_000; -const SITEVERIFY = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; -const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); -const decoder = new TextDecoder('utf-8', { fatal: true }); -const encoder = new TextEncoder(); -const commaList = value => (value || '').split(',').map(item => item.trim()).filter(Boolean); -const securityHeaders = { - 'Content-Type': 'application/json; charset=utf-8', - 'Cache-Control': 'no-store', - 'X-Content-Type-Options': 'nosniff', - 'Content-Security-Policy': "default-src 'none'; frame-ancestors 'none'; base-uri 'none'", - 'Referrer-Policy': 'no-referrer', - 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()', - 'X-Frame-Options': 'DENY', - Vary: 'Origin', -}; - -class PublicError extends Error { - constructor(status, code, message, retryAfter) { - super(message); - this.status = status; - this.code = code; - this.retryAfter = retryAfter; - } -} - -function json(status, payload, origin, extra = {}) { - return new Response(status === 204 ? null : JSON.stringify(payload), { - status, - headers: { ...securityHeaders, ...(origin ? { 'Access-Control-Allow-Origin': origin } : {}), ...extra }, - }); -} - -function originDetails(request, env) { - const origin = request.headers.get('Origin'); - let url; - try { url = new URL(origin); } catch { throw new PublicError(403, 'origin', 'This request is not allowed.'); } - if (origin !== url.origin || url.username || url.password || !['http:', 'https:'].includes(url.protocol)) { - throw new PublicError(403, 'origin', 'This request is not allowed.'); - } - const target = new URL(request.url); - const localMock = env.MODE === 'mock' && LOCAL_HOSTS.has(url.hostname) && LOCAL_HOSTS.has(target.hostname); - const origins = commaList(env.ALLOWED_ORIGINS); - if (!origins.includes(origin) || (!localMock && (url.protocol !== 'https:' || target.protocol !== 'https:'))) { - throw new PublicError(403, 'origin', 'This request is not allowed.'); - } - // An accidentally uploaded mock configuration cannot run on a public Worker. - if (env.MODE === 'mock' && !localMock) throw new PublicError(503, 'unavailable', 'Feedback is temporarily unavailable.'); - return { origin, hostname: url.hostname, localMock }; -} - -function validateConfig(env, hostname) { - const goodSecret = value => typeof value === 'string' && value.length >= 20 && !/replace|placeholder/i.test(value); - if (env.MODE !== 'production' || !commaList(env.TURNSTILE_HOSTNAMES).includes(hostname) - || !goodSecret(env.TURNSTILE_SECRET_KEY) || /^[123]x0+/.test(env.TURNSTILE_SECRET_KEY) - || !/^\d{5,}:[A-Za-z0-9_-]{20,}$/.test(env.TELEGRAM_BOT_TOKEN || '') - || !/^[1-9]\d{0,15}$/.test(env.TELEGRAM_CHAT_ID || '') || env.TELEGRAM_CHAT_ID_VERIFIED !== 'true' - || !goodSecret(env.RATE_LIMIT_SALT) || typeof env.RATE_LIMITER?.limit !== 'function') { - throw new PublicError(503, 'unavailable', 'Feedback is temporarily unavailable.'); - } -} - -async function readJson(request) { - if ((request.headers.get('Content-Type') || '').split(';')[0].trim().toLowerCase() !== 'application/json') { - throw new PublicError(415, 'content_type', 'Send this form as JSON.'); - } - const declared = request.headers.get('Content-Length'); - if (declared && (!/^\d+$/.test(declared) || Number(declared) > MAX_BODY_BYTES)) { - throw new PublicError(413, 'too_large', 'This message is too large.'); - } - if (!request.body) throw new PublicError(400, 'invalid_json', 'The submitted form is not valid.'); - const reader = request.body.getReader(); - const chunks = []; - let bytes = 0, timer; - const reading = (async () => { - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - bytes += value.byteLength; - if (bytes > MAX_BODY_BYTES) { - await reader.cancel(); - throw new PublicError(413, 'too_large', 'This message is too large.'); - } - chunks.push(value); - } - const merged = new Uint8Array(bytes); - let at = 0; - for (const chunk of chunks) { merged.set(chunk, at); at += chunk.byteLength; } - try { return JSON.parse(decoder.decode(merged)); } - catch { throw new PublicError(400, 'invalid_json', 'The submitted form is not valid.'); } - })(); - try { - return await Promise.race([reading, new Promise((_, reject) => { - timer = setTimeout(() => { - void reader.cancel().catch(() => {}); - reject(new PublicError(408, 'timeout', 'The request took too long. Please try again.')); - }, BODY_TIMEOUT_MS); - })]); - } finally { clearTimeout(timer); } -} - -function validateFields(input) { - const invalid = message => { throw new PublicError(422, 'validation', message); }; - if (!input || typeof input !== 'object' || Array.isArray(input)) invalid('The submitted form is not valid.'); - const allowed = new Set(['name', 'message', 'email', 'website', 'turnstileToken', 'sourcePath']); - if (Object.keys(input).some(key => !allowed.has(key))) invalid('The submitted form contains unsupported fields.'); - if (input.website !== undefined && (typeof input.website !== 'string' || input.website !== '')) invalid('The submitted form is not valid.'); - const field = (key, max, required = false) => { - const raw = input[key] ?? ''; - if (typeof raw !== 'string') invalid(`Please check the ${key} field.`); - const value = raw.trim(); - if ((required && !value) || value.length > max || /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u.test(value)) invalid(`Please check the ${key} field.`); - return value; - }; - const name = field('name', 80, true); - const message = field('message', 2500, true); - const email = field('email', 254); - const turnstileToken = field('turnstileToken', 2048, true); - if (/[\r\n\t]/.test(name) || (email && (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || /[<>]/.test(email)))) invalid('Please check your name and email.'); - const sourcePath = field('sourcePath', 200) || '/feedback/'; - // Never pass URL queries, fragments, credentials, or arbitrary domains to Telegram. - if (!/^\/(?:[A-Za-z0-9/_-]|%[A-Fa-f0-9]{2})*$/.test(sourcePath) || sourcePath.startsWith('//')) invalid('The source page is not valid.'); - return { name, message, email, turnstileToken, sourcePath }; -} - -export function escapeHtml(value) { - return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); -} - -async function rateKey(request, env, now) { - // CF-Connecting-IP is set by Cloudflare. Do not trust X-Forwarded-For. - const ip = request.headers.get('CF-Connecting-IP'); - if (!ip || ip.length > 64 || !/^[0-9a-fA-F:.]+$/.test(ip)) throw new PublicError(503, 'unavailable', 'Feedback is temporarily unavailable.'); - const key = await crypto.subtle.importKey('raw', encoder.encode(env.RATE_LIMIT_SALT), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); - const digest = await crypto.subtle.sign('HMAC', key, encoder.encode(`${Math.floor(now / 86_400_000)}:${ip}`)); - return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join(''); -} - -/** Dependency injection is for offline tests; production export uses native fetch. */ -export function createWorker({ fetch: transport = globalThis.fetch, now = Date.now, timeoutMs = 5000 } = {}) { - const mockLimits = new Map(); - - async function upstream(url, init) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const response = await transport(url, { ...init, redirect: 'error', signal: controller.signal }); - const payload = await response.json(); - return { response, payload }; - } finally { clearTimeout(timer); } - } - - async function verify(token, env, hostname) { - const idempotencyKey = crypto.randomUUID(); - for (let attempt = 0; attempt < 2; attempt += 1) { - let result; - try { - result = await upstream(SITEVERIFY, { - method: 'POST', - body: new URLSearchParams({ secret: env.TURNSTILE_SECRET_KEY, response: token, idempotency_key: idempotencyKey }), - }); - } catch { - if (attempt === 0) continue; - throw new PublicError(503, 'verification_unavailable', 'Verification is unavailable. Please retry the challenge.'); - } - const { response, payload } = result; - if (!response.ok || payload?.['error-codes']?.includes('internal-error')) { - if (attempt === 0) continue; - throw new PublicError(503, 'verification_unavailable', 'Verification is unavailable. Please retry the challenge.'); - } - if (payload?.success !== true || payload.hostname !== hostname || payload.action !== 'feedback' - || (Array.isArray(payload['error-codes']) && payload['error-codes'].length > 0)) { - throw new PublicError(422, 'captcha', 'Please complete a new verification challenge.'); - } - return; - } - } - - return { - /** @param {Request} request @param {Env} env */ - async fetch(request, env) { - let allowedOrigin; - try { - if (new URL(request.url).pathname !== '/feedback') return json(404, { ok: false, code: 'not_found', message: 'Not found.' }); - const { origin, hostname, localMock } = originDetails(request, env); - allowedOrigin = origin; - if (!localMock) validateConfig(env, hostname); - if (request.method === 'OPTIONS') { - const requestedHeaders = commaList(request.headers.get('Access-Control-Request-Headers')).map(value => value.toLowerCase()); - if (request.headers.get('Access-Control-Request-Method') !== 'POST' || requestedHeaders.some(value => value !== 'content-type')) { - throw new PublicError(403, 'preflight', 'This request is not allowed.'); - } - return json(204, null, origin, { 'Access-Control-Allow-Methods': 'POST', 'Access-Control-Allow-Headers': 'Content-Type', 'Access-Control-Max-Age': '600' }); - } - if (request.method !== 'POST') return json(405, { ok: false, code: 'method', message: 'Use POST for this endpoint.' }, origin, { Allow: 'POST, OPTIONS' }); - if (localMock) { - const window = Math.floor(now() / 60_000); - // Only the explicitly local mock uses process-local counters. - if (mockLimits.get('window') !== window) { mockLimits.clear(); mockLimits.set('window', window); } - const count = (mockLimits.get(origin) || 0) + 1; - mockLimits.set(origin, count); - if (count > 5) throw new PublicError(429, 'rate_limited', 'Please wait a minute before trying again.', 60); - } else { - let limit; - try { limit = await env.RATE_LIMITER.limit({ key: `feedback:${await rateKey(request, env, now())}` }); } - catch { throw new PublicError(503, 'unavailable', 'Feedback is temporarily unavailable.'); } - if (limit?.success !== true) throw new PublicError(429, 'rate_limited', 'Please wait a minute before trying again.', 60); - } - const fields = validateFields(await readJson(request)); - if (localMock) { - if (fields.turnstileToken !== 'local-mock-token') throw new PublicError(422, 'captcha', 'Use the explicit local mock verification control.'); - return json(200, { ok: true, mock: true, message: 'Local test complete. No message was sent to Telegram.' }, origin); - } - await verify(fields.turnstileToken, env, hostname); - const source = origin + fields.sourcePath; - const text = [ - '<b>WizardLoop website feedback</b>', - '', - `<b>Name:</b> ${escapeHtml(fields.name)}`, - ...(fields.email ? [`<b>Email:</b> ${escapeHtml(fields.email)}`] : []), - `<b>Received:</b> ${new Date(now()).toISOString()}`, - `<b>Source:</b> <a href="${escapeHtml(source)}">${escapeHtml(source)}</a>`, - '', - escapeHtml(fields.message), - ].join('\n'); - let delivered; - try { - delivered = await upstream(`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendMessage`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ chat_id: env.TELEGRAM_CHAT_ID, text, parse_mode: 'HTML', link_preview_options: { is_disabled: true }, protect_content: true }), - }); - } catch { - // No automatic sendMessage retry: a timeout may occur after successful delivery. - return json(503, { ok: false, code: 'delivery_unconfirmed', mayHaveBeenDelivered: true, message: 'Delivery could not be confirmed. Please wait before retrying with a new verification.' }, origin); - } - if (!delivered.response.ok || delivered.payload?.ok !== true) { - const retryAfter = delivered.payload?.error_code === 429 ? Math.min(3600, Math.max(1, Number(delivered.payload?.parameters?.retry_after) || 60)) : undefined; - throw new PublicError(503, 'delivery_unavailable', 'Your feedback could not be confirmed. Please retry later with a new verification.', retryAfter); - } - return json(200, { ok: true, mock: false, message: 'Your feedback was sent. Thank you.' }, origin); - } catch (error) { - if (error instanceof PublicError) return json(error.status, { ok: false, code: error.code, message: error.message }, allowedOrigin, error.retryAfter ? { 'Retry-After': String(error.retryAfter) } : {}); - return json(503, { ok: false, code: 'unavailable', message: 'Feedback is temporarily unavailable.' }, allowedOrigin); - } - }, - }; -} - -export default createWorker(); diff --git a/services/feedback/wrangler.jsonc b/services/feedback/wrangler.jsonc deleted file mode 100644 index 8ea37c7..0000000 --- a/services/feedback/wrangler.jsonc +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "node_modules/wrangler/config-schema.json", - "name": "wizardloop-feedback", - "main": "worker.mjs", - "compatibility_date": "2026-10-27", - "workers_dev": false, - "preview_urls": false, - "routes": [], - "observability": { "enabled": false }, - "vars": { - "MODE": "production", - "ALLOWED_ORIGINS": "https://wizardloop.com", - "TURNSTILE_HOSTNAMES": "wizardloop.com", - "TELEGRAM_CHAT_ID_VERIFIED": "false" - }, - "ratelimits": [ - { - "name": "RATE_LIMITER", - // Choose an unused positive namespace integer in your account before approved deployment. - "namespace_id": "1001", - "simple": { "limit": 5, "period": 60 } - } - ] -} diff --git a/services/posts-sync/.gitignore b/services/posts-sync/.gitignore deleted file mode 100644 index 53d711f..0000000 --- a/services/posts-sync/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -__pycache__/ -*.pyc -.env* -test-output/ -*.pending -state.json -.lock diff --git a/services/posts-sync/deploy/run_service.py b/services/posts-sync/deploy/run_service.py deleted file mode 100644 index f15d842..0000000 --- a/services/posts-sync/deploy/run_service.py +++ /dev/null @@ -1,213 +0,0 @@ -"""Inactive Linux host adapter. No deployment, GitHub write, or embedded secrets.""" -from __future__ import annotations - -import argparse -from contextlib import contextmanager -from datetime import datetime, timezone -import hashlib -import json -import os -from pathlib import Path -import re -import shutil -import stat -import subprocess -import sys -import tempfile -import zipfile - -REPOSITORY = Path("/opt/wizardloop/repository") -DATA = Path("/var/lib/wizardloop-content") -BUILDS = Path("/var/lib/wizardloop-build") -PYTHON = "/opt/wizardloop/profile-venv/bin/python" -PROFILE_FILES = {"avatar.jpg", "poster.jpg", "profile.mp4", "profile.json"} - - -def safe_env(): - # Build/package tools never inherit bot tokens or the credential mount path. - return {"PATH": "/usr/local/bin:/usr/bin:/bin", "LANG": "C.UTF-8", "LC_ALL": "C.UTF-8", - "HOME": str(BUILDS), "CI": "true", "ASTRO_TELEMETRY_DISABLED": "1", - "PYTHONDONTWRITEBYTECODE": "1"} - - -def secret_env(names): - directory = Path(os.environ["CREDENTIALS_DIRECTORY"]) - result = safe_env() - for filename, variable in names.items(): - path = directory / filename - if path.stat().st_size > 4096: - raise ValueError("invalid_credential") - value = path.read_text(encoding="utf-8").strip() - if not value or "\n" in value or "\r" in value: - raise ValueError("invalid_credential") - result[variable] = value - return result - - -@contextmanager -def snapshot_lock(shared=False): - # Precreated by the operator, group-readable. flock works with this read FD; - # the kernel releases it on exit. Both sync writers use an exclusive lock. - import fcntl - with (DATA / "snapshot.lock").open("rb") as stream: - fcntl.flock(stream.fileno(), fcntl.LOCK_SH if shared else fcntl.LOCK_EX) - yield - fcntl.flock(stream.fileno(), fcntl.LOCK_UN) - - -def command(args, *, env=None, cwd=REPOSITORY, timeout=1800, capture=False): - return subprocess.run(args, cwd=cwd, env=env or safe_env(), check=True, timeout=timeout, - stdout=subprocess.PIPE if capture else None, - stderr=subprocess.PIPE if capture else None, text=capture) - - -def readable_public(path): - if path.is_symlink() or not path.is_file(): - raise ValueError("invalid_public_snapshot") - path.chmod(0o640) - - -def sync_posts(): - output = DATA / "public/posts.json" - seed = output if output.exists() else REPOSITORY / "src/content/posts.json" - env = secret_env({"posts-token": "POSTS_TELEGRAM_BOT_TOKEN"}) - with snapshot_lock(): - command(["/usr/bin/python3", str(REPOSITORY / "services/posts-sync/posts_sync.py"), - "--live", "--seed", str(seed), "--output", str(output), - "--state-dir", str(DATA / "private/posts"), - "--exclude-ids", "/etc/wizardloop/removed-posts.json"], env=env, timeout=1200) - readable_public(output) - - -def sync_profile(): - output = DATA / "public/profile" - env = secret_env({"profile-api-id": "TELEGRAM_API_ID", "profile-api-hash": "TELEGRAM_API_HASH", - "profile-token": "TELEGRAM_BOT_TOKEN"}) - with snapshot_lock(): - command([PYTHON, str(REPOSITORY / "services/profile-sync/profile_sync.py"), - "--force", "--output", str(output)], env=env, timeout=600) - for name in PROFILE_FILES: - if (output / name).exists(): - readable_public(output / name) - - -def read_manifest(path): - if path.is_symlink() or path.stat().st_size > 4 * 1024 * 1024: - raise ValueError("invalid_public_manifest") - result = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(result, dict) or result.get("status") == "fixture": - raise ValueError("fixture_or_invalid_manifest") - return result - - -def copy_public_snapshot(source, destination): - """Only explicit public files cross from the sync account to the builder.""" - posts = read_manifest(source / "posts.json") - profile = read_manifest(source / "profile/profile.json") - if posts.get("channel") != "wizard_loop" or posts.get("status") not in {"incremental", "protected"}: - raise ValueError("unverified_posts_source") - if posts.get("status") == "protected" and posts.get("posts") != []: - raise ValueError("protected_index_must_be_empty") - if profile.get("source") not in {"telegram", "github", "local"}: - raise ValueError("unverified_profile_source") - expected = {"avatar": "/media/profile/avatar.jpg", "poster": "/media/profile/poster.jpg"} - if any(profile.get(key) != value for key, value in expected.items()): - raise ValueError("unexpected_profile_path") - if profile.get("video") not in (None, "/media/profile/profile.mp4"): - raise ValueError("unexpected_video_path") - media = destination / "public/media/profile" - media.mkdir(parents=True, exist_ok=True) - for name in PROFILE_FILES: - target = media / name - if target.exists() or target.is_symlink(): - if target.is_dir(): - raise ValueError("unexpected_profile_directory") - target.unlink() - names = {"profile.json", "avatar.jpg", "poster.jpg"} - if profile.get("video"): - names.add("profile.mp4") - for name in names: - path = source / "profile" / name - if path.is_symlink() or not path.is_file() or path.stat().st_size > 64 * 1024 * 1024: - raise ValueError("invalid_profile_asset") - shutil.copyfile(path, media / name) - (destination / "src/content").mkdir(parents=True, exist_ok=True) - shutil.copyfile(source / "posts.json", destination / "src/content/posts.json") - return {"postsSyncedAt": posts.get("lastSyncedAt"), "postsStatus": posts.get("status"), - "postsCheckedAt": posts.get("checkedAt"), "profileSyncedAt": profile.get("syncedAt"), - "profileStatus": profile.get("status"), "profileSource": profile.get("source")} - - -def unpack_source(archive, destination): - with zipfile.ZipFile(archive) as bundle: - for item in bundle.infolist(): - target = destination / item.filename - if (item.filename.startswith(("/", "\\")) or "\\" in item.filename - or not target.resolve().is_relative_to(destination.resolve()) - or stat.S_ISLNK(item.external_attr >> 16)): - raise ValueError("unsafe_source_archive") - bundle.extractall(destination) - - -def bundle_static(dist, output): - with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive: - for path in sorted(dist.rglob("*")): - if path.is_symlink(): - raise ValueError("symlink_in_static_build") - if path.is_file(): - archive.write(path, path.relative_to(dist)) - - -def prepare_build(): - # The repository and this adapter are root-owned and pinned by the operator. - # This process has no bot credentials, no GitHub token, and no write access - # to the source checkout or private cursor directory. - git = ["/usr/bin/git", "-c", "safe.directory=" + str(REPOSITORY)] - commit = command(git + ["rev-parse", "HEAD"], capture=True).stdout.strip() - if not re.fullmatch(r"[a-f0-9]{40,64}", commit): - raise ValueError("invalid_source_commit") - BUILDS.mkdir(parents=True, exist_ok=True) - with tempfile.TemporaryDirectory(prefix="prepare-", dir=BUILDS) as temporary: - temporary = Path(temporary) - archive = temporary / "source.zip" - work = temporary / "site" - work.mkdir() - command(git + ["archive", "--format=zip", "--output=" + str(archive), commit]) - unpack_source(archive, work) - with snapshot_lock(shared=True): - provenance = copy_public_snapshot(DATA / "public", work) - # No credentials are passed into dependency installation or build hooks. - command(["/usr/bin/npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund"], cwd=work) - command(["/usr/bin/npm", "run", "check"], cwd=work) - stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") - output = BUILDS / "artifacts" / (stamp + "-" + commit[:12]) - output.mkdir(parents=True, exist_ok=False) - pending = output / "static.zip.pending" - try: - bundle_static(work / "dist", pending) - digest = hashlib.sha256(pending.read_bytes()).hexdigest() - (output / "provenance.json").write_text(json.dumps({"sourceCommit": commit, - "preparedAt": stamp, "sha256": digest, "published": False, - "checks": "npm run check", **provenance}, indent=2) + "\n", encoding="utf-8") - pending.rename(output / "static.zip") - except Exception: - pending.unlink(missing_ok=True) - raise - print(json.dumps({"event": "static_artifact_prepared", "published": False, "sourceCommit": commit})) - - -def main(argv=None): - parser = argparse.ArgumentParser(description="Operator-installed persistent sync adapter. No publishing.") - parser.add_argument("mode", choices=("posts", "profile", "build")) - args = parser.parse_args(argv) - try: - {"posts": sync_posts, "profile": sync_profile, "build": prepare_build}[args.mode]() - return 0 - except Exception: - # Never print subprocess exceptions: callers can contain credentialed state. - print(json.dumps({"event": "scheduled_content_job_failed", "mode": args.mode})) - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/services/posts-sync/deploy/wizardloop-build.service b/services/posts-sync/deploy/wizardloop-build.service deleted file mode 100644 index f116756..0000000 --- a/services/posts-sync/deploy/wizardloop-build.service +++ /dev/null @@ -1,26 +0,0 @@ -[Unit] -Description=WizardLoop validated local artifact preparation without publishing -Requires=wizardloop-profile.service -After=wizardloop-profile.service network-online.target -Wants=network-online.target -ConditionPathExists=/etc/wizardloop/ENABLE_CONTENT_SYNC - -[Service] -Type=oneshot -User=wizardloop-build -Group=wizardloop-content -WorkingDirectory=/opt/wizardloop/repository -ExecStart=/usr/bin/python3 /opt/wizardloop/repository/services/posts-sync/deploy/run_service.py build -TimeoutStartSec=45min -UMask=0027 -NoNewPrivileges=yes -PrivateTmp=yes -ProtectHome=yes -ProtectSystem=strict -ReadOnlyPaths=/var/lib/wizardloop-content/public -ReadOnlyPaths=/var/lib/wizardloop-content/snapshot.lock -InaccessiblePaths=/var/lib/wizardloop-content/private /etc/wizardloop/secrets -ReadWritePaths=/var/lib/wizardloop-build -RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 -CapabilityBoundingSet= -LockPersonality=yes diff --git a/services/posts-sync/deploy/wizardloop-build.timer b/services/posts-sync/deploy/wizardloop-build.timer deleted file mode 100644 index 6726f5c..0000000 --- a/services/posts-sync/deploy/wizardloop-build.timer +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=WizardLoop profile refresh and local artifact preparation every six hours - -[Timer] -OnCalendar=*-*-* 00/6:15:00 UTC -Persistent=true -RandomizedDelaySec=2min -Unit=wizardloop-build.service - -[Install] -WantedBy=timers.target diff --git a/services/posts-sync/deploy/wizardloop-posts.service b/services/posts-sync/deploy/wizardloop-posts.service deleted file mode 100644 index 0072e6f..0000000 --- a/services/posts-sync/deploy/wizardloop-posts.service +++ /dev/null @@ -1,23 +0,0 @@ -[Unit] -Description=WizardLoop private incremental posts sync -Wants=network-online.target -After=network-online.target -ConditionPathExists=/etc/wizardloop/ENABLE_CONTENT_SYNC - -[Service] -Type=oneshot -User=wizardloop-sync -Group=wizardloop-content -WorkingDirectory=/opt/wizardloop/repository -ExecStart=/usr/bin/python3 /opt/wizardloop/repository/services/posts-sync/deploy/run_service.py posts -LoadCredential=posts-token:/etc/wizardloop/secrets/posts-token -TimeoutStartSec=25min -UMask=0027 -NoNewPrivileges=yes -PrivateTmp=yes -ProtectHome=yes -ProtectSystem=strict -ReadWritePaths=/var/lib/wizardloop-content -RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 -CapabilityBoundingSet= -LockPersonality=yes diff --git a/services/posts-sync/deploy/wizardloop-posts.timer b/services/posts-sync/deploy/wizardloop-posts.timer deleted file mode 100644 index 147291a..0000000 --- a/services/posts-sync/deploy/wizardloop-posts.timer +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=WizardLoop posts poll every five minutes - -[Timer] -OnBootSec=2min -OnUnitInactiveSec=5min -AccuracySec=15sec -Unit=wizardloop-posts.service - -[Install] -WantedBy=timers.target diff --git a/services/posts-sync/deploy/wizardloop-profile.service b/services/posts-sync/deploy/wizardloop-profile.service deleted file mode 100644 index fe50d28..0000000 --- a/services/posts-sync/deploy/wizardloop-profile.service +++ /dev/null @@ -1,25 +0,0 @@ -[Unit] -Description=WizardLoop private profile sync before artifact preparation -Wants=network-online.target -After=network-online.target -ConditionPathExists=/etc/wizardloop/ENABLE_CONTENT_SYNC - -[Service] -Type=oneshot -User=wizardloop-sync -Group=wizardloop-content -WorkingDirectory=/opt/wizardloop/repository -ExecStart=/usr/bin/python3 /opt/wizardloop/repository/services/posts-sync/deploy/run_service.py profile -LoadCredential=profile-api-id:/etc/wizardloop/secrets/profile-api-id -LoadCredential=profile-api-hash:/etc/wizardloop/secrets/profile-api-hash -LoadCredential=profile-token:/etc/wizardloop/secrets/profile-token -TimeoutStartSec=15min -UMask=0027 -NoNewPrivileges=yes -PrivateTmp=yes -ProtectHome=yes -ProtectSystem=strict -ReadWritePaths=/var/lib/wizardloop-content -RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 -CapabilityBoundingSet= -LockPersonality=yes diff --git a/services/posts-sync/fixtures/updates.json b/services/posts-sync/fixtures/updates.json deleted file mode 100644 index 045fff7..0000000 --- a/services/posts-sync/fixtures/updates.json +++ /dev/null @@ -1,10 +0,0 @@ -[ - [ - {"update_id": 100, "channel_post": {"message_id": 101, "date": 1793102400, "chat": {"id": -1000000000001, "type": "channel", "username": "wizard_loop"}, "text": "Offline fixture: a studio note"}}, - {"update_id": 101, "channel_post": {"message_id": 102, "date": 1793102500, "chat": {"id": -1000000000001, "type": "channel", "username": "wizard_loop"}, "media_group_id": "fixture-album", "caption": "Offline fixture: album caption", "photo": [{"file_id": "fixture-unused"}]}} - ], - [ - {"update_id": 102, "channel_post": {"message_id": 103, "date": 1793102500, "chat": {"id": -1000000000001, "type": "channel", "username": "wizard_loop"}, "media_group_id": "fixture-album", "video": {"file_id": "fixture-unused"}}}, - {"update_id": 103, "edited_channel_post": {"message_id": 101, "date": 1793102400, "edit_date": 1793102600, "chat": {"id": -1000000000001, "type": "channel", "username": "wizard_loop"}, "text": "Offline fixture: edited studio note", "entities": [{"type": "custom_emoji", "offset": 0, "length": 2, "custom_emoji_id": "fixture-unused"}]}} - ] -] diff --git a/services/posts-sync/posts_sync.py b/services/posts-sync/posts_sync.py deleted file mode 100644 index 2fec9f2..0000000 --- a/services/posts-sync/posts_sync.py +++ /dev/null @@ -1,449 +0,0 @@ -"""WizardLoop channel index sync. MIT code; channel content retains its rights.""" -from __future__ import annotations - -import argparse -import contextlib -import copy -import json -import os -from pathlib import Path -import re -import sys -import tempfile -import time -from datetime import datetime, timezone -import urllib.error -import urllib.request - -CHANNEL = "wizard_loop" -ROOT = Path(__file__).resolve().parents[2] -DEFAULT_OUTPUT = ROOT / "src/content/posts.json" -MAX_BYTES = 4 * 1024 * 1024 -MAX_MESSAGES = 10000 -ALLOWED_METHODS = {"getMe", "getWebhookInfo", "getChat", "getChatMember", "getUpdates"} - - -class SyncError(Exception): - """Only fixed, nonpersonal error codes may cross the logging boundary.""" - - -class ProtectedChannel(SyncError): - """Authoritative privacy change, distinct from a temporary network failure.""" - def __init__(self, channel_id, bot_id): - super().__init__("channel_content_protected") - self.channel_id, self.bot_id = channel_id, bot_id - - -class Retryable(SyncError): - def __init__(self, delay=1): - super().__init__("temporary_api_failure") - self.delay = delay - - -def now(): - return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") - - -def date_string(value): - if not isinstance(value, int) or isinstance(value, bool) or value <= 0: - raise SyncError("invalid_message_date") - try: - return datetime.fromtimestamp(value, timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") - except (ValueError, OverflowError, OSError): - raise SyncError("invalid_message_date") from None - - -def positive(value): - return isinstance(value, int) and not isinstance(value, bool) and value > 0 - - -def clean_text(value, limit): - if not isinstance(value, str): - return "" - return "".join(c for c in value.replace(chr(0x2014), "-") if c in "\n\t" or ord(c) >= 32)[:limit].strip() - - -def read_json(path, required=True): - if not path.exists() and not required: - return None - try: - if path.stat().st_size > MAX_BYTES: - raise SyncError("local_data_too_large") - return json.loads(path.read_text(encoding="utf-8-sig")) - except (OSError, ValueError): - raise SyncError("invalid_local_data") from None - - -def atomic_json(path, value, private=False): - data = (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode("utf-8") - if len(data) > MAX_BYTES: - raise SyncError("local_data_too_large") - path.parent.mkdir(parents=True, exist_ok=True) - fd, pending = tempfile.mkstemp(prefix=".posts-", suffix=".pending", dir=path.parent) - try: - if private: - os.chmod(pending, 0o600) - with os.fdopen(fd, "wb") as stream: - stream.write(data) - stream.flush() - os.fsync(stream.fileno()) - os.replace(pending, path) - finally: - if os.path.exists(pending): - os.unlink(pending) - - -@contextlib.contextmanager -def lock(state_dir): - state_dir.mkdir(parents=True, exist_ok=True, mode=0o700) - path = state_dir / ".lock" - try: - fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) - except FileExistsError: - raise SyncError("sync_already_running_or_stale_lock") from None - try: - os.close(fd) - yield - finally: - path.unlink(missing_ok=True) - - -class NoRedirect(urllib.request.HTTPRedirectHandler): - def redirect_request(self, req, fp, code, msg, headers, newurl): - raise SyncError("api_redirect_rejected") - - -class BotAPI: - def __init__(self, token, opener=None, sleep=time.sleep): - if not re.fullmatch(r"[0-9]{5,20}:[A-Za-z0-9_-]{20,100}", token or ""): - raise SyncError("missing_or_invalid_bot_token") - self._token = token - self._opener = opener or urllib.request.build_opener(NoRedirect()) - self._sleep = sleep - - def _once(self, method, params): - if method not in ALLOWED_METHODS: - raise SyncError("api_method_not_allowed") - # The token must appear in Telegram's URL protocol. Never log the URL, - # Request object, raw response, or exception text. Redirects are refused. - request = urllib.request.Request( - "https://api.telegram.org/bot" + self._token + "/" + method, - data=json.dumps(params).encode("utf-8"), - headers={"Content-Type": "application/json", "User-Agent": "WizardLoop-Posts-Sync/1.0"}, - method="POST", - ) - status = 200 - try: - with self._opener.open(request, timeout=35) as response: - raw = response.read(MAX_BYTES + 1) - except urllib.error.HTTPError as error: - status = error.code - raw = error.read(MAX_BYTES + 1) - error.close() - except (urllib.error.URLError, TimeoutError, OSError): - raise Retryable() from None - if len(raw) > MAX_BYTES: - raise SyncError("api_response_too_large") - try: - body = json.loads(raw) - except (ValueError, UnicodeError): - if status >= 500: - raise Retryable() from None - raise SyncError("invalid_api_response") from None - if not isinstance(body, dict): - raise SyncError("invalid_api_response") - code = body.get("error_code", status) - if code == 429: - delay = body.get("parameters", {}).get("retry_after", 2) - if not isinstance(delay, (int, float)) or not 0 <= delay <= 30: - raise SyncError("api_backoff_required") - raise Retryable(delay) - if status >= 500 or (isinstance(code, int) and code >= 500): - raise Retryable() - if status != 200 or body.get("ok") is not True: - raise SyncError("api_access_or_request_rejected") - return body.get("result") - - def call(self, method, **params): - for attempt in range(3): - try: - return self._once(method, params) - except Retryable as error: - if attempt == 2: - raise SyncError("api_unavailable_after_retries") from None - self._sleep(max(error.delay, 2 ** attempt)) - - -def verify_channel(api): - info = api.call("getWebhookInfo") - if not isinstance(info, dict) or info.get("url"): - raise SyncError("existing_webhook_or_invalid_status") - bot = api.call("getMe") - chat = api.call("getChat", chat_id="@" + CHANNEL) - if not isinstance(bot, dict) or not bot.get("is_bot") or not positive(bot.get("id")): - raise SyncError("invalid_bot_identity") - if (not isinstance(chat, dict) or chat.get("type") != "channel" - or str(chat.get("username", "")).lower() != CHANNEL - or not isinstance(chat.get("id"), int) or chat["id"] >= 0): - raise SyncError("channel_identity_mismatch") - member = api.call("getChatMember", chat_id=chat["id"], user_id=bot["id"]) - if not isinstance(member, dict) or member.get("status") not in ("administrator", "creator"): - raise SyncError("channel_bot_admin_required") - if chat.get("has_protected_content") is True: - raise ProtectedChannel(chat["id"], bot["id"]) - return chat["id"], bot["id"] - - -def media_types(message): - kinds = [] - if message.get("photo"): - kinds.append("Photo") - for key, label in (("video", "Video"), ("animation", "Animation"), ("audio", "Audio"), - ("voice", "Voice"), ("video_note", "Video message"), ("poll", "Poll"), - ("paid_media", "Paid media"), ("rich_message", "Rich message")): - if message.get(key): - kinds.append(label) - sticker = message.get("sticker") - if isinstance(sticker, dict): - kinds.append("TGS sticker" if sticker.get("is_animated") else "WebM sticker" if sticker.get("is_video") else "Sticker") - if message.get("document") and not message.get("animation"): - kinds.append("Document") - entities = message.get("entities", []) + message.get("caption_entities", []) - if any(isinstance(item, dict) and item.get("type") == "custom_emoji" for item in entities): - kinds.append("Custom emoji") - return sorted(set(kinds)) - - -def normalize_message(message): - if not positive(message.get("message_id")): - raise SyncError("invalid_message_id") - text = clean_text(message.get("text") or message.get("caption"), 4096) - kinds = media_types(message) - if not text and not kinds: - return None # Channel service events are not portfolio posts. - group = message.get("media_group_id") - if group is not None and (not isinstance(group, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", group)): - raise SyncError("invalid_media_group") - return {"id": message["message_id"], "text": text, "media": kinds, "group": group, - "date": date_string(message.get("date")), - "editedAt": date_string(message["edit_date"]) if message.get("edit_date") else None} - - -def initial_state(channel_id, bot_id): - return {"version": 1, "channelId": channel_id, "botId": bot_id, "nextOffset": None, - "messages": {}, "blockedIds": [], "lastSyncedAt": None} - - -def validate_state(state, channel_id, bot_id): - if (not isinstance(state, dict) or state.get("version") != 1 - or state.get("channelId") != channel_id or state.get("botId") != bot_id - or not isinstance(state.get("messages"), dict) - or not isinstance(state.get("blockedIds"), list) - or len(state["messages"]) > MAX_MESSAGES - or (state.get("nextOffset") is not None and not positive(state["nextOffset"]))): - raise SyncError("private_state_identity_or_schema_mismatch") - - -def apply_updates(state, updates, channel_id): - if not isinstance(updates, list) or len(updates) > 100: - raise SyncError("invalid_updates_batch") - updated = copy.deepcopy(state) - ids = [item.get("update_id") if isinstance(item, dict) else None for item in updates] - if any(not positive(item) for item in ids) or len(ids) != len(set(ids)): - raise SyncError("invalid_update_id") - for update in sorted(updates, key=lambda item: item["update_id"]): - if state["nextOffset"] is not None and update["update_id"] < state["nextOffset"]: - continue - message = update.get("channel_post") or update.get("edited_channel_post") - # Ignore other chats and unwanted update types that predate allowed_updates. - if isinstance(message, dict): - chat = message.get("chat", {}) - if chat.get("id") == channel_id and chat.get("type") == "channel": - if not positive(message.get("message_id")): - raise SyncError("invalid_message_id") - key = str(message["message_id"]) - if message.get("has_protected_content") or chat.get("has_protected_content"): - updated["messages"].pop(key, None) - updated["blockedIds"] = sorted(set(updated["blockedIds"] + [message["message_id"]])) - else: - normalized = normalize_message(message) - if normalized: - updated["messages"][key] = normalized - updated["blockedIds"] = [item for item in updated["blockedIds"] if item != message["message_id"]] - updated["nextOffset"] = update["update_id"] + 1 - if len(updated["messages"]) > MAX_MESSAGES: - raise SyncError("message_capacity_reached") - return updated - - -def validate_seed(seed): - if (not isinstance(seed, dict) or seed.get("channel") != CHANNEL - or not isinstance(seed.get("posts"), list) or len(seed["posts"]) > MAX_MESSAGES): - raise SyncError("invalid_seed") - seen = set() - for item in seed["posts"]: - if not isinstance(item, dict) or not positive(item.get("id")) or item["id"] in seen: - raise SyncError("invalid_seed_post") - seen.add(item["id"]) - for field in ("title", "kind", "note"): - if not isinstance(item.get(field), str): - raise SyncError("invalid_seed_post") - if not re.fullmatch(r"https://t\.me/wizard_loop/" + str(item["id"]), str(item.get("url", "")), re.I): - raise SyncError("invalid_seed_url") - if any(not positive(member) for member in item.get("messageIds", [item["id"]])): - raise SyncError("invalid_seed_album") - - -def render_index(seed, state, excluded=(), fixture=False, timestamp=None): - validate_seed(seed) - blocked = set(state["blockedIds"]) | set(excluded) - groups = {} - for message in state["messages"].values(): - if message["id"] not in blocked: - groups.setdefault(message["group"] or "single:" + str(message["id"]), []).append(message) - generated, handled = [], set() - for messages in groups.values(): - messages.sort(key=lambda item: item["id"]) - members = [item["id"] for item in messages] - handled.update(members) - text = next((item["text"] for item in messages if item["text"]), "") - media = sorted({kind for item in messages for kind in item["media"]}) - kind = "Album" if len(messages) > 1 else ", ".join(media) or "Text" - first_line = next((line.strip() for line in text.splitlines() if line.strip()), "") - title = first_line[:96] or "Telegram " + kind.lower() - note = "Open the original post for its complete formatting and media." - if set(media) & {"TGS sticker", "WebM sticker", "Custom emoji", "Rich message", "Paid media"}: - note += " Some content may require the Telegram app; an embed is not a guarantee of full playback." - if len(messages) > 1: - note += " Album members are linked individually; Telegram controls the embedded album presentation." - generated.append({"id": members[0], "title": title, "kind": kind, - "url": f"https://t.me/{CHANNEL}/{members[0]}", "note": note, - "origin": "fixture" if fixture else "telegram-updates", "date": messages[0]["date"], - "editedAt": max((item["editedAt"] for item in messages if item["editedAt"]), default=None), - "messageIds": members, "media": media, "excerpt": clean_text(text, 280)}) - # Seed entries are a deliberately incomplete, verified manual history. They - # survive every poll until a matching message is updated or owner-excluded. - retained = [] - for item in seed["posts"]: - members = set(item.get("messageIds", [item["id"]])) - if members & (handled | blocked): - continue - kept = {key: clean_text(item[key], 800) for key in ("title", "kind", "note")} - kept.update({"id": item["id"], "url": f"https://t.me/{CHANNEL}/{item['id']}", - "origin": item.get("origin", "manual-verified")}) - for key in ("date", "editedAt", "messageIds", "media", "excerpt"): - if key in item: - kept[key] = item[key] - retained.append(kept) - return {"channel": CHANNEL, "source": f"https://t.me/s/{CHANNEL}", - "status": "fixture" if fixture else "incremental", - "verifiedAt": seed.get("verifiedAt"), "lastSyncedAt": timestamp or now(), - "historyComplete": False, - "posts": sorted(retained + generated, key=lambda item: item["id"], reverse=True)} - - -def persist_batch(output, state_path, index, state): - # Public index first, then private cursor. If the second write fails, Telegram - # replays unacknowledged updates. The deterministic merge is idempotent. - atomic_json(output, index) - atomic_json(state_path, state, private=True) - - -def run_sync(api, seed, output, state_dir, excluded=(), max_batches=20, fixture_batches=None): - fixture = fixture_batches is not None - protected = False - try: - channel_id, bot_id = (-1000000000001, 100000) if fixture else verify_channel(api) - except ProtectedChannel as notice: - channel_id, bot_id = notice.channel_id, notice.bot_id - protected = True - state_path = state_dir / "state.json" - retained_path = state_dir / "last-public-index.json" - state = read_json(state_path, required=False) or initial_state(channel_id, bot_id) - validate_state(state, channel_id, bot_id) - validate_seed(seed) - if protected: - # Keep recovery material private, and do not acknowledge or read updates. - # Repeated protected checks must not replace the saved index with an empty one. - if seed.get("status") != "protected": - atomic_json(retained_path, seed, private=True) - if not state_path.exists(): - atomic_json(state_path, state, private=True) - index = {"channel": CHANNEL, "source": f"https://t.me/s/{CHANNEL}", - "status": "protected", "verifiedAt": seed.get("verifiedAt"), - "lastSyncedAt": state.get("lastSyncedAt"), "checkedAt": now(), - "historyComplete": False, "posts": []} - atomic_json(output, index) - return {"event": "posts_sync_complete", "status": "protected", - "updatesProcessed": 0, "posts": 0, "historyComplete": False} - if seed.get("status") == "protected": - retained = read_json(retained_path, required=False) - if retained is not None: - validate_seed(retained) - seed = retained - last_index = seed - count = 0 - for number in range(max_batches): - if fixture: - if number >= len(fixture_batches): - break - updates = fixture_batches[number] - else: - params = {"limit": 100, "timeout": 20, "allowed_updates": ["channel_post", "edited_channel_post"]} - if state["nextOffset"] is not None: - params["offset"] = state["nextOffset"] - updates = api.call("getUpdates", **params) - proposed = apply_updates(state, updates, channel_id) - proposed["lastSyncedAt"] = now() - index = render_index(last_index, proposed, excluded, fixture, proposed["lastSyncedAt"]) - persist_batch(output, state_path, index, proposed) - state, last_index = proposed, index - count += len(updates) - if not fixture and len(updates) < 100: - break - return {"event": "posts_sync_complete", "status": "fixture" if fixture else "incremental", - "updatesProcessed": count, "posts": len(last_index["posts"]), "historyComplete": False} - - -def main(argv=None): - parser = argparse.ArgumentParser(description="Index WizardLoop posts with supported Bot API updates.") - mode = parser.add_mutually_exclusive_group(required=True) - mode.add_argument("--live", action="store_true", help="Use configured Bot API credentials.") - mode.add_argument("--fixture", type=Path, help="Offline fixture; requires a separate output file.") - parser.add_argument("--seed", type=Path, default=DEFAULT_OUTPUT) - parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) - parser.add_argument("--state-dir", type=Path, default=os.environ.get("POSTS_SYNC_STATE_DIR")) - parser.add_argument("--exclude-ids", type=Path, help="Owner-maintained JSON array of removed message IDs.") - args = parser.parse_args(argv) - try: - if not args.state_dir: - raise SyncError("private_state_directory_required") - state_dir = Path(args.state_dir).resolve() - if args.live and (state_dir == ROOT or ROOT in state_dir.parents): - raise SyncError("live_state_must_be_outside_repository") - if args.fixture and args.output.resolve() == DEFAULT_OUTPUT.resolve(): - raise SyncError("fixture_requires_separate_output") - seed = read_json(args.seed) - validate_seed(seed) - excluded = read_json(args.exclude_ids) if args.exclude_ids else [] - if not isinstance(excluded, list) or any(not positive(item) for item in excluded): - raise SyncError("invalid_excluded_ids") - batches = read_json(args.fixture) if args.fixture else None - if batches is not None and (not isinstance(batches, list) or not batches): - raise SyncError("invalid_fixture") - api = None if args.fixture else BotAPI(os.environ.get("POSTS_TELEGRAM_BOT_TOKEN")) - with lock(state_dir): - result = run_sync(api, seed, args.output.resolve(), state_dir, excluded, fixture_batches=batches) - print(json.dumps(result)) - return 0 - except SyncError as error: - print(json.dumps({"event": "posts_sync_failed", "reason": str(error), "lastGoodPreserved": True})) - return 1 - except Exception: - # Intentionally omit exception text/traceback: HTTP exceptions can carry credentials. - print(json.dumps({"event": "posts_sync_failed", "reason": "local_or_unexpected_failure"})) - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/services/posts-sync/test_deploy.py b/services/posts-sync/test_deploy.py deleted file mode 100644 index 8164c60..0000000 --- a/services/posts-sync/test_deploy.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Offline deployment-adapter tests; no systemd activation or network.""" -import importlib.util -import json -import os -from pathlib import Path -import tempfile -import unittest -from unittest.mock import patch -import zipfile - -SPEC = importlib.util.spec_from_file_location("content_deploy", Path(__file__).parent / "deploy/run_service.py") -deploy = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(deploy) - - -class AdapterTests(unittest.TestCase): - def setUp(self): - self.temp = tempfile.TemporaryDirectory() - self.addCleanup(self.temp.cleanup) - self.root = Path(self.temp.name) - - def snapshot(self, video=False): - source = self.root / "public" - (source / "profile").mkdir(parents=True) - (source / "posts.json").write_text(json.dumps({"channel": "wizard_loop", "status": "incremental", "posts": []}), encoding="utf-8") - (source / "profile/profile.json").write_text(json.dumps({"source": "telegram", "status": "current", - "avatar": "/media/profile/avatar.jpg", "poster": "/media/profile/poster.jpg", - "video": "/media/profile/profile.mp4" if video else None}), encoding="utf-8") - for name in ("avatar.jpg", "poster.jpg", "profile.mp4", "unlisted-secret.txt"): - (source / "profile" / name).write_bytes(b"fixture-bytes") - (source / "state.json").write_text("private-operational-data", encoding="utf-8") - return source - - def test_build_environment_has_no_credential_inheritance(self): - with patch.dict(os.environ, {"TELEGRAM_BOT_TOKEN": "secret", "POSTS_TELEGRAM_BOT_TOKEN": "secret", - "GITHUB_TOKEN": "secret", "CREDENTIALS_DIRECTORY": "private"}): - env = deploy.safe_env() - self.assertNotIn("secret", json.dumps(env)) - self.assertNotIn("CREDENTIALS_DIRECTORY", env) - self.assertEqual(env["ASTRO_TELEMETRY_DISABLED"], "1") - - def test_only_requested_credentials_are_read(self): - (self.root / "posts-token").write_text("fixture-placeholder", encoding="utf-8") - with patch.dict(os.environ, {"CREDENTIALS_DIRECTORY": str(self.root), "GITHUB_TOKEN": "not-inherited"}): - env = deploy.secret_env({"posts-token": "POSTS_TELEGRAM_BOT_TOKEN"}) - self.assertEqual(env["POSTS_TELEGRAM_BOT_TOKEN"], "fixture-placeholder") - self.assertNotIn("GITHUB_TOKEN", env) - - def test_snapshot_only_copies_allowlisted_public_files(self): - source = self.snapshot() - destination = self.root / "work" - deploy.copy_public_snapshot(source, destination) - files = sorted(str(path.relative_to(destination)).replace("\\", "/") for path in destination.rglob("*") if path.is_file()) - self.assertEqual(files, ["public/media/profile/avatar.jpg", "public/media/profile/poster.jpg", - "public/media/profile/profile.json", "src/content/posts.json"]) - - def test_video_copied_only_when_manifest_declares_it(self): - source = self.snapshot(video=True) - destination = self.root / "work" - deploy.copy_public_snapshot(source, destination) - self.assertTrue((destination / "public/media/profile/profile.mp4").exists()) - - def test_stale_video_removed_from_build_source(self): - source = self.snapshot(video=False) - destination = self.root / "work" - target = destination / "public/media/profile" - target.mkdir(parents=True) - (target / "profile.mp4").write_bytes(b"stale") - deploy.copy_public_snapshot(source, destination) - self.assertFalse((target / "profile.mp4").exists()) - - def test_fixture_cannot_become_build_input(self): - source = self.snapshot() - (source / "posts.json").write_text('{"status":"fixture"}', encoding="utf-8") - with self.assertRaises(ValueError): - deploy.copy_public_snapshot(source, self.root / "work") - - def test_external_profile_path_rejected(self): - source = self.snapshot() - manifest = json.loads((source / "profile/profile.json").read_text()) - manifest["avatar"] = "https://unverified.example/avatar.jpg" - (source / "profile/profile.json").write_text(json.dumps(manifest), encoding="utf-8") - with self.assertRaises(ValueError): - deploy.copy_public_snapshot(source, self.root / "work") - - def test_protected_empty_index_builds_to_remove_stale_embeds(self): - source = self.snapshot() - (source / "posts.json").write_text(json.dumps({"channel": "wizard_loop", "status": "protected", "posts": []}), encoding="utf-8") - result = deploy.copy_public_snapshot(source, self.root / "work") - self.assertEqual(result["postsStatus"], "protected") - - def test_protected_nonempty_index_is_rejected(self): - source = self.snapshot() - (source / "posts.json").write_text(json.dumps({"channel": "wizard_loop", "status": "protected", "posts": [{"id": 51}]}), encoding="utf-8") - with self.assertRaises(ValueError): - deploy.copy_public_snapshot(source, self.root / "work") - - def test_source_zip_traversal_rejected(self): - archive = self.root / "unsafe.zip" - with zipfile.ZipFile(archive, "w") as bundle: - bundle.writestr("../outside.txt", "unsafe") - with self.assertRaises(ValueError): - deploy.unpack_source(archive, self.root / "work") - self.assertFalse((self.root / "outside.txt").exists()) - - def test_static_archive_contains_only_dist_contents(self): - dist = self.root / "dist" - dist.mkdir() - (dist / "index.html").write_text("<h1>Fixture</h1>", encoding="utf-8") - (self.root / "private.json").write_text("private", encoding="utf-8") - output = self.root / "static.zip" - deploy.bundle_static(dist, output) - with zipfile.ZipFile(output) as bundle: - self.assertEqual(bundle.namelist(), ["index.html"]) - - def test_service_templates_are_gated_and_builder_has_no_credentials(self): - folder = Path(__file__).parent / "deploy" - for path in folder.glob("*.service"): - source = path.read_text(encoding="utf-8") - self.assertIn("ConditionPathExists=/etc/wizardloop/ENABLE_CONTENT_SYNC", source) - self.assertNotIn("Environment=TELEGRAM", source) - build = (folder / "wizardloop-build.service").read_text(encoding="utf-8") - self.assertNotIn("LoadCredential", build) - self.assertIn("User=wizardloop-build", build) - self.assertIn("InaccessiblePaths=/var/lib/wizardloop-content/private /etc/wizardloop/secrets", build) - - -if __name__ == "__main__": - unittest.main() diff --git a/services/posts-sync/test_posts_sync.py b/services/posts-sync/test_posts_sync.py deleted file mode 100644 index f73d7c9..0000000 --- a/services/posts-sync/test_posts_sync.py +++ /dev/null @@ -1,330 +0,0 @@ -"""Offline behavioral tests. No authenticated Telegram requests.""" -import contextlib -import copy -import io -import json -from pathlib import Path -import tempfile -import unittest -from unittest.mock import patch -import urllib.error - -import posts_sync as sync - -CHAT = -1000000000001 -BOT = 100000 -SEED = {"channel": "wizard_loop", "source": "https://t.me/s/wizard_loop", "status": "verified-snapshot", - "verifiedAt": "2026-10-27", "posts": [{"id": 51, "title": "Verified manual title", "kind": "Text", - "url": "https://t.me/Wizard_Loop/51", "note": "Verified source."}]} - - -def update(number=10, message_id=60, edit=False, **extra): - message = {"message_id": message_id, "date": 1793102400, "chat": {"id": CHAT, "type": "channel"}, "text": "A public note"} - message.update(extra) - return {"update_id": number, "edited_channel_post" if edit else "channel_post": message} - - -class FakeAPI: - def __init__(self, batches=None, webhook="", admin=True, protected=False): - self.batches = iter(batches or [[]]) - self.calls = [] - self.webhook = webhook - self.admin = admin - self.protected = protected - - def call(self, method, **params): - self.calls.append((method, params)) - return {"getWebhookInfo": lambda: {"url": self.webhook}, - "getMe": lambda: {"id": BOT, "is_bot": True}, - "getChat": lambda: {"id": CHAT, "type": "channel", "username": "wizard_loop", "has_protected_content": self.protected}, - "getChatMember": lambda: {"status": "administrator" if self.admin else "member"}, - "getUpdates": lambda: next(self.batches)}[method]() - - -class IndexTests(unittest.TestCase): - def setUp(self): - self.state = sync.initial_state(CHAT, BOT) - - def index(self, updates, seed=SEED, **kwargs): - self.state = sync.apply_updates(self.state, updates, CHAT) - return sync.render_index(seed, self.state, timestamp="2026-10-27T12:00:00Z", **kwargs) - - def test_manual_history_preserved_and_not_claimed_complete(self): - result = self.index([update()]) - self.assertEqual([item["id"] for item in result["posts"]], [60, 51]) - self.assertFalse(result["historyComplete"]) - self.assertEqual(result["verifiedAt"], SEED["verifiedAt"]) - self.assertEqual(result["posts"][1]["origin"], "manual-verified") - - def test_matching_edit_replaces_manual_title_and_date(self): - result = self.index([update(message_id=51, edit=True, text="Revised title", edit_date=1793102500)]) - self.assertEqual(len(result["posts"]), 1) - self.assertEqual(result["posts"][0]["title"], "Revised title") - self.assertIsNotNone(result["posts"][0]["editedAt"]) - - def test_out_of_order_replay_and_duplicates_are_idempotent(self): - batch = [update(12, edit=True, text="Latest"), update(11, text="Earlier")] - first = self.index(batch) - replay = self.index(batch, seed=first) - self.assertEqual(first, replay) - self.assertEqual(self.state["nextOffset"], 13) - self.assertEqual(first["posts"][0]["title"], "Latest") - - def test_album_across_batches_and_caption_edit(self): - first = self.index([update(media_group_id="album", caption="Album caption", text="", photo=[{}])]) - second = self.index([update(11, 61, media_group_id="album", text="", video={"file_id": "not-public"})], seed=first) - album = second["posts"][0] - self.assertEqual(album["messageIds"], [60, 61]) - self.assertEqual(album["kind"], "Album") - self.assertEqual(album["title"], "Album caption") - self.assertEqual(album["media"], ["Photo", "Video"]) - third = self.index([update(12, 60, edit=True, media_group_id="album", text="", caption="New caption", photo=[{}])], seed=second) - self.assertEqual(third["posts"][0]["title"], "New caption") - - def test_media_capabilities_are_honest(self): - result = self.index([update(text="", sticker={"is_animated": True, "file_id": "secret-file"}, - caption_entities=[{"type": "custom_emoji", "custom_emoji_id": "secret-emoji"}])]) - item = result["posts"][0] - self.assertEqual(item["media"], ["Custom emoji", "TGS sticker"]) - self.assertIn("not a guarantee", item["note"]) - self.assertNotIn("secret-", json.dumps(result) + json.dumps(self.state)) - self.assertEqual(sync.media_types({"sticker": {"is_video": True}}), ["WebM sticker"]) - self.assertEqual(sync.media_types({"animation": {"file_id": "x"}, "document": {"file_id": "x"}}), ["Animation"]) - - def test_other_chats_and_private_messages_ignored_but_cursor_advances(self): - result = self.index([update(chat={"id": -999, "type": "channel"}), {"update_id": 11, "message": {"text": "private"}}]) - self.assertEqual(len(result["posts"]), 1) - self.assertEqual(self.state["nextOffset"], 12) - self.assertNotIn("private", json.dumps(self.state)) - - def test_protected_edit_removes_previously_public_post(self): - first = self.index([update()]) - result = self.index([update(11, has_protected_content=True)], seed=first) - self.assertEqual([item["id"] for item in result["posts"]], [51]) - self.assertEqual(self.state["blockedIds"], [60]) - - def test_owner_exclusions_apply_to_manual_and_new_posts(self): - result = self.index([update()], excluded=[51, 60]) - self.assertEqual(result["posts"], []) - - def test_service_events_are_not_posts(self): - result = self.index([update(text="", new_chat_title="A renamed channel")]) - self.assertEqual(len(result["posts"]), 1) - - def test_plain_text_is_bounded_and_emdash_removed(self): - result = self.index([update(text="<script>alert(1)</script>" + chr(0x2014) + "x" * 8000)]) - item = result["posts"][0] - self.assertLessEqual(len(item["title"]), 96) - self.assertLessEqual(len(item["excerpt"]), 280) - self.assertNotIn(chr(0x2014), json.dumps(result, ensure_ascii=False)) - self.assertTrue(item["title"].startswith("<script>")) # Consumer must escape text, never set:html. - - def test_invalid_batch_is_atomic_in_memory(self): - original = copy.deepcopy(self.state) - with self.assertRaises(sync.SyncError): - self.index([update(), update(11, date=-1)]) - self.assertEqual(self.state, original) - - def test_seed_url_cannot_introduce_javascript(self): - seed = copy.deepcopy(SEED) - seed["posts"][0]["url"] = "javascript:alert(1)" - with self.assertRaises(sync.SyncError): - sync.validate_seed(seed) - - def test_state_cannot_move_between_bots_or_channels(self): - with self.assertRaises(sync.SyncError): - sync.validate_state(self.state, CHAT, 999) - - -class StorageTests(unittest.TestCase): - def setUp(self): - self.temp = tempfile.TemporaryDirectory() - self.addCleanup(self.temp.cleanup) - self.root = Path(self.temp.name) - self.output = self.root / "posts.json" - self.private = self.root / "private" - - def test_authoritative_protection_hides_index_without_polling_or_advancing_state(self): - sync.run_sync(FakeAPI([[update()]]), SEED, self.output, self.private) - before = (self.private / "state.json").read_bytes() - seed = sync.read_json(self.output) - api = FakeAPI(protected=True) - result = sync.run_sync(api, seed, self.output, self.private) - index = sync.read_json(self.output) - self.assertEqual(result["status"], "protected") - self.assertEqual(index["status"], "protected") - self.assertEqual(index["posts"], []) - self.assertIn("checkedAt", index) - self.assertNotIn("getUpdates", [method for method, params in api.calls]) - self.assertEqual((self.private / "state.json").read_bytes(), before) - self.assertEqual(sync.read_json(self.private / "last-public-index.json"), seed) - self.assertNotIn("channelId", self.output.read_text()) - - def test_repeated_protection_preserves_private_seed_and_restores_after_unprotection(self): - sync.run_sync(FakeAPI(protected=True), SEED, self.output, self.private) - private_seed = (self.private / "last-public-index.json").read_bytes() - sync.run_sync(FakeAPI(protected=True), sync.read_json(self.output), self.output, self.private) - self.assertEqual((self.private / "last-public-index.json").read_bytes(), private_seed) - api = FakeAPI([[update()]]) - result = sync.run_sync(api, sync.read_json(self.output), self.output, self.private) - self.assertEqual(result["status"], "incremental") - self.assertEqual([item["id"] for item in sync.read_json(self.output)["posts"]], [60, 51]) - self.assertEqual(sync.read_json(self.private / "state.json")["nextOffset"], 11) - - def test_owner_exclusion_still_applies_after_protection_is_removed(self): - sync.run_sync(FakeAPI(protected=True), SEED, self.output, self.private) - sync.run_sync(FakeAPI([[]]), sync.read_json(self.output), self.output, self.private, excluded=[51]) - self.assertEqual(sync.read_json(self.output)["posts"], []) - - def test_failed_protection_output_write_does_not_acknowledge_updates(self): - sync.atomic_json(self.output, SEED) - api = FakeAPI(protected=True) - original_write = sync.atomic_json - def write(path, value, private=False): - if path == self.output: - raise OSError("public output unavailable") - original_write(path, value, private) - with patch.object(sync, "atomic_json", side_effect=write): - with self.assertRaises(OSError): - sync.run_sync(api, SEED, self.output, self.private) - self.assertEqual(sync.read_json(self.output), SEED) - self.assertNotIn("getUpdates", [method for method, params in api.calls]) - self.assertEqual(sync.read_json(self.private / "state.json")["nextOffset"], None) - - def test_real_fixture_offline_two_batches(self): - batches = sync.read_json(Path(__file__).parent / "fixtures/updates.json") - result = sync.run_sync(None, SEED, self.output, self.private, fixture_batches=batches) - index = sync.read_json(self.output) - self.assertEqual(result["status"], "fixture") - self.assertEqual(result["updatesProcessed"], 4) - self.assertEqual(len(index["posts"]), 3) - self.assertEqual(index["posts"][0]["messageIds"], [102, 103]) - self.assertEqual(index["posts"][1]["title"], "Offline fixture: edited studio note") - self.assertNotIn("file_id", self.output.read_text()) - self.assertNotIn("channelId", self.output.read_text()) - - def test_cursor_persisted_before_next_request_ack(self): - batch = [update(number=number, message_id=number + 100) for number in range(1, 101)] - api = FakeAPI([batch, []]) - original_call = api.call - - def checking_call(method, **params): - if method == "getUpdates" and "offset" in params: - self.assertEqual(sync.read_json(self.private / "state.json")["nextOffset"], 101) - self.assertEqual(len(sync.read_json(self.output)["posts"]), 101) - return original_call(method, **params) - - api.call = checking_call - sync.run_sync(api, SEED, self.output, self.private) - polls = [params for method, params in api.calls if method == "getUpdates"] - self.assertNotIn("offset", polls[0]) - self.assertEqual(polls[1]["offset"], 101) - self.assertEqual(polls[0]["allowed_updates"], ["channel_post", "edited_channel_post"]) - - def test_failed_output_write_does_not_advance_cursor(self): - state = sync.initial_state(CHAT, BOT) - sync.atomic_json(self.private / "state.json", state) - with patch.object(sync, "atomic_json", side_effect=OSError("write failed")): - with self.assertRaises(OSError): - sync.run_sync(FakeAPI([[update()]]), SEED, self.output, self.private) - self.assertEqual(sync.read_json(self.private / "state.json")["nextOffset"], None) - - def test_failed_cursor_write_replays_without_losing_output(self): - original_write = sync.atomic_json - - def write(path, value, private=False): - if private: - raise OSError("state unavailable") - original_write(path, value) - - with patch.object(sync, "atomic_json", side_effect=write): - with self.assertRaises(OSError): - sync.run_sync(FakeAPI([[update()]]), SEED, self.output, self.private) - valid_index = sync.read_json(self.output) - sync.run_sync(FakeAPI([[update()]]), valid_index, self.output, self.private) - self.assertEqual(len(sync.read_json(self.output)["posts"]), 2) - self.assertEqual(sync.read_json(self.private / "state.json")["nextOffset"], 11) - - def test_network_failure_preserves_last_good(self): - sync.atomic_json(self.output, SEED) - before = self.output.read_bytes() - api = FakeAPI() - api.call = lambda *args, **kwargs: (_ for _ in ()).throw(sync.SyncError("api_unavailable_after_retries")) - with self.assertRaises(sync.SyncError): - sync.run_sync(api, SEED, self.output, self.private) - self.assertEqual(before, self.output.read_bytes()) - - def test_lock_prevents_concurrent_writers(self): - with sync.lock(self.private): - with self.assertRaises(sync.SyncError): - with sync.lock(self.private): - pass - self.assertFalse((self.private / ".lock").exists()) - - def test_fixture_refuses_real_content_output(self): - output = io.StringIO() - with contextlib.redirect_stdout(output): - result = sync.main(["--fixture", "unused.json", "--state-dir", str(self.private)]) - self.assertEqual(result, 1) - self.assertIn("fixture_requires_separate_output", output.getvalue()) - - def test_live_refuses_public_private_state(self): - with contextlib.redirect_stdout(io.StringIO()): - result = sync.main(["--live", "--state-dir", str(sync.ROOT / "public/state")]) - self.assertEqual(result, 1) - - -class TransportTests(unittest.TestCase): - def api(self): - return sync.BotAPI("123456:" + "x" * 30, sleep=lambda delay: None) - - def test_webhook_is_never_removed(self): - api = FakeAPI(webhook="https://private.example/hook") - with self.assertRaises(sync.SyncError): - sync.verify_channel(api) - self.assertEqual([method for method, params in api.calls], ["getWebhookInfo"]) - - def test_minimum_configured_admin_membership_enforced(self): - with self.assertRaises(sync.SyncError): - sync.verify_channel(FakeAPI(admin=False)) - - def test_timeout_retry_is_bounded(self): - api = self.api() - with patch.object(api, "_once", side_effect=sync.Retryable()) as request: - with self.assertRaisesRegex(sync.SyncError, "api_unavailable_after_retries"): - api.call("getMe") - self.assertEqual(request.call_count, 3) - - def test_transient_retry_can_succeed(self): - api = self.api() - with patch.object(api, "_once", side_effect=[sync.Retryable(2), {"id": BOT}]) as request: - self.assertEqual(api.call("getMe"), {"id": BOT}) - self.assertEqual(request.call_count, 2) - - def test_request_secret_not_in_error(self): - api = self.api() - api._opener.open = lambda *args, **kwargs: (_ for _ in ()).throw(urllib.error.URLError("secret-token-or-url")) - with self.assertRaises(sync.SyncError) as caught: - api.call("getMe") - self.assertNotIn("secret", str(caught.exception)) - self.assertNotIn("123456", str(caught.exception)) - - def test_redirect_is_rejected(self): - with self.assertRaises(sync.SyncError): - sync.NoRedirect().redirect_request(None, None, 302, "redirect", {}, "https://evil.example") - - def test_unapproved_method_cannot_send_or_delete(self): - with self.assertRaisesRegex(sync.SyncError, "api_method_not_allowed"): - self.api().call("sendMessage") - - def test_rate_limit_large_wait_stops_without_sleep(self): - api = self.api() - response = io.BytesIO(json.dumps({"ok": False, "error_code": 429, "parameters": {"retry_after": 400}}).encode()) - response.__enter__ = lambda: response - api._opener.open = lambda *args, **kwargs: response - with self.assertRaisesRegex(sync.SyncError, "api_backoff_required"): - api.call("getMe") - - -if __name__ == "__main__": - unittest.main() diff --git a/services/profile-sync/.gitignore b/services/profile-sync/.gitignore deleted file mode 100644 index f4878e7..0000000 --- a/services/profile-sync/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -.venv/ -.deps/ -__pycache__/ -*.pyc -test-output/ -*.session* -.env* diff --git a/services/profile-sync/fixtures/active-still.json b/services/profile-sync/fixtures/active-still.json deleted file mode 100644 index dfc505b..0000000 --- a/services/profile-sync/fixtures/active-still.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "telegram": { - "unavailable": false, - "user": { "photo": { "photo_id": 7, "personal": false } }, - "full": { "profile_photo": { "id": 7, "video_sizes": [] } }, - "history": [ { "id": 99 }, { "id": 7 } ], - "bio": "Offline fixture for profile synchronization tests." - }, - "github": { "unavailable": true } -} diff --git a/services/profile-sync/profile_sync.py b/services/profile-sync/profile_sync.py deleted file mode 100644 index db3eeca..0000000 --- a/services/profile-sync/profile_sync.py +++ /dev/null @@ -1,582 +0,0 @@ -"""Bot-only, bounded profile synchronization. No personal sessions or browser scraping.""" -from __future__ import annotations - -import argparse -import asyncio -from contextlib import contextmanager -from dataclasses import dataclass -from datetime import datetime, timezone -from io import BytesIO -import json -import logging -import math -import os -from pathlib import Path -import re -import shutil -import subprocess -import tempfile -from types import SimpleNamespace -from typing import Any -from urllib.parse import urlsplit -from urllib.request import HTTPRedirectHandler, Request, build_opener -import warnings - -from PIL import Image, ImageDraw, ImageOps - -MAX_IMAGE_BYTES = 12 * 1024 * 1024 -MAX_VIDEO_BYTES = 64 * 1024 * 1024 -ROOT = Path(__file__).resolve().parents[2] -DEFAULT_OUTPUT = ROOT / "public/media/profile" -PUBLIC_PREFIX = "/media/profile/" -DEFAULT_BIO = "I'm an independent creator working across design, motion, animation, software and Telegram bots." -PUBLIC_FILES = ("avatar.jpg", "poster.jpg", "profile.mp4", "profile.json") -Image.MAX_IMAGE_PIXELS = 16_000_000 -warnings.simplefilter("error", Image.DecompressionBombWarning) - - -class Unavailable(Exception): - """Transient or unproven source. Never print the underlying API exception.""" - - -class NoPublicProfile(Unavailable): - """Explicit missing/personal primary: do not keep displaying a stale private image.""" - - -class ConfigurationError(Exception): - """A non-secret configuration problem.""" - - -def now() -> str: - return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") - - -def clean_bio(value: Any) -> str: - if not isinstance(value, str): - return "" - value = value.replace(chr(0x2014), "-") - value = "".join(c for c in value if c in "\t\n" or ord(c) >= 32) - return " ".join(value.split())[:500] - - -def field(obj: Any, name: str, default: Any = None) -> Any: - return obj.get(name, default) if isinstance(obj, dict) else getattr(obj, name, default) - - -def choose_primary(user: Any, full: Any, history: list[Any]) -> Any: - """The current ID is authoritative; dates and history order are irrelevant.""" - user_photo = field(user, "photo") - active_id = field(user_photo, "photo_id") - if not active_id or field(user_photo, "personal", False): - raise NoPublicProfile("no_public_primary") - personal = field(full, "personal_photo") - if field(personal, "id") == active_id: - raise NoPublicProfile("personal_photo_rejected") - primary = field(full, "profile_photo") - fallback = field(full, "fallback_photo") - if field(fallback, "id") == active_id and field(primary, "id") != active_id: - raise NoPublicProfile("fallback_is_not_primary") - if field(primary, "id") == active_id: - return primary - for photo in history: - if field(photo, "id") == active_id: - return photo - raise Unavailable("active_photo_not_resolved") - - -def choose_video(photo: Any) -> Any | None: - """Only full animated-profile MPEG4 type u, never preview p or sticker markup.""" - variants = [v for v in field(photo, "video_sizes", []) or [] - if field(v, "type") == "u" and field(v, "w", 0) > 0 - and field(v, "h", 0) > 0 and field(v, "size", 0) > 0] - return max(variants, key=lambda v: (field(v, "w") * field(v, "h"), field(v, "size")), default=None) - - -def choose_still(photo: Any) -> Any: - variants = [s for s in field(photo, "sizes", []) or [] - if field(s, "w", 0) > 0 and field(s, "h", 0) > 0 and field(s, "type")] - if not variants: - raise Unavailable("profile_still_missing") - return max(variants, key=lambda s: (field(s, "w") * field(s, "h"), field(s, "size", 0))) - - -def normalize_image(data: bytes) -> tuple[bytes, int, int]: - if not data or len(data) > MAX_IMAGE_BYTES: - raise Unavailable("image_size_rejected") - try: - with Image.open(BytesIO(data)) as original: - original.load() - image = ImageOps.exif_transpose(original) - if image.width < 32 or image.height < 32: - raise Unavailable("image_dimensions_rejected") - if image.mode in ("RGBA", "LA") or "transparency" in image.info: - rgba = image.convert("RGBA") - image = Image.new("RGB", image.size, "#171a18") - image.paste(rgba, mask=rgba.getchannel("A")) - else: - image = image.convert("RGB") - target = BytesIO() - image.save(target, "JPEG", quality=95, optimize=True, progressive=True) - return target.getvalue(), image.width, image.height - except Unavailable: - raise - except Exception: - raise Unavailable("invalid_image") from None - - -def local_mark() -> bytes: - image = Image.new("RGB", (640, 640), "#171a18") - draw = ImageDraw.Draw(image) - draw.ellipse((92, 92, 548, 548), outline="#cef948", width=12) - draw.line([(177, 240), (242, 405), (320, 275), (398, 405), (463, 240)], fill="#cef948", width=27, joint="curve") - output = BytesIO() - image.save(output, "JPEG", quality=95) - return output.getvalue() - - -def validate_video(data: bytes) -> None: - if len(data) < 32 or len(data) > MAX_VIDEO_BYTES or data[4:8] != b"ftyp": - raise Unavailable("invalid_mp4") - offset, boxes = 0, set() - while offset + 8 <= len(data): - size, box = int.from_bytes(data[offset:offset + 4], "big"), data[offset + 4:offset + 8] - header = 8 - if size == 1: - if offset + 16 > len(data): - raise Unavailable("invalid_mp4") - size, header = int.from_bytes(data[offset + 8:offset + 16], "big"), 16 - if size == 0: - size = len(data) - offset - if size < header or offset + size > len(data): - raise Unavailable("invalid_mp4") - boxes.add(box) - offset += size - if offset != len(data) or not {b"ftyp", b"moov", b"mdat"}.issubset(boxes): - raise Unavailable("invalid_mp4") - - -@dataclass -class Candidate: - source: str - avatar: bytes - bio: str = "" - video: bytes | None = None - video_start: float = 0 - media_note: str = "" - fixture: bool = False - - -@dataclass -class Settings: - output: Path - local_avatar: Path | None = None - local_bio: str = DEFAULT_BIO - cache_hours: float = 6 - ffmpeg: str | None = None - force: bool = False - - -async def bounded_retry(operation, attempts: int = 3, timeout: float = 25): - for attempt in range(attempts): - try: - return await asyncio.wait_for(operation(), timeout) - except NoPublicProfile: - raise - except Exception as error: - seconds = getattr(error, "seconds", None) - if seconds is not None and (not isinstance(seconds, (int, float)) or seconds > 30): - raise Unavailable("source_backoff_required") from None - if attempt == attempts - 1: - raise Unavailable("source_unavailable") from None - await asyncio.sleep(max(0, seconds) if seconds is not None else 0.4 * 2 ** attempt) - - -class TelegramProvider: - """Uses only a fresh bot MemorySession. No phone prompts or session files exist.""" - def __init__(self, environment: dict[str, str] | None = None): - env = environment if environment is not None else os.environ - if any(env.get(name) for name in ("TELEGRAM_SESSION", "TELEGRAM_STRING_SESSION", "TELEGRAM_PHONE")): - raise ConfigurationError("personal_sessions_are_not_supported") - self.api_id = env.get("TELEGRAM_API_ID", "") - self.api_hash = env.get("TELEGRAM_API_HASH", "") - self.bot_token = env.get("TELEGRAM_BOT_TOKEN", "") - self.username = "wizardloop" - - async def fetch(self) -> Candidate: - if not (self.api_id.isdecimal() and self.api_hash and self.bot_token): - raise Unavailable("telegram_not_configured") - try: - from telethon import TelegramClient, functions, types, utils - from telethon.sessions import MemorySession - except ImportError: - raise Unavailable("telegram_dependency_unavailable") from None - logging.getLogger("telethon").setLevel(logging.CRITICAL) - client = TelegramClient(MemorySession(), int(self.api_id), self.api_hash, - receive_updates=False, request_retries=0, connection_retries=1, - flood_sleep_threshold=0, timeout=10) - - async def resolve(): - resolved = await client(functions.contacts.ResolveUsernameRequest(username=self.username)) - identifier = getattr(resolved.peer, "user_id", None) - user = next((u for u in resolved.users if u.id == identifier), None) - if user is None: - raise NoPublicProfile("public_user_missing") - input_user = utils.get_input_user(user) - details = await client(functions.users.GetFullUserRequest(id=input_user)) - user = next((u for u in details.users if u.id == user.id), user) - full = details.full_user - try: - return user, full, choose_primary(user, full, []) - except NoPublicProfile: - raise - except Unavailable: - history = [] - for offset in range(0, 500, 100): - page = await client(functions.photos.GetUserPhotosRequest(user_id=input_user, offset=offset, max_id=0, limit=100)) - history.extend(page.photos) - try: - return user, full, choose_primary(user, full, history) - except NoPublicProfile: - raise - except Unavailable: - if len(page.photos) < 100: - break - raise Unavailable("active_photo_not_resolved") - - async def download(photo, variant, maximum): - cached = getattr(variant, "bytes", None) - if isinstance(cached, bytes): - if len(cached) > maximum: - raise Unavailable("download_limit") - return cached - location = types.InputPhotoFileLocation(id=photo.id, access_hash=photo.access_hash, - file_reference=photo.file_reference, thumb_size=variant.type) - size = getattr(variant, "size", None) - if size is not None and size > maximum: - raise Unavailable("download_limit") - content = bytearray() - stream = client.iter_download(location, dc_id=photo.dc_id, file_size=size) - try: - async for chunk in stream: - if len(content) + len(chunk) > maximum: - raise Unavailable("download_limit") - content.extend(chunk) - finally: - await stream.close() - if size and len(content) != size: - raise Unavailable("incomplete_download") - return bytes(content) - - async def get_candidate(): - user, full, photo = await resolve() - avatar = await download(photo, choose_still(photo), MAX_IMAGE_BYTES) - selected = choose_video(photo) - video, start, note = None, 0, "" - if selected is not None: - try: - video = await download(photo, selected, MAX_VIDEO_BYTES) - validate_video(video) - start = field(selected, "video_start_ts", 0) or 0 - if not isinstance(start, (int, float)) or not math.isfinite(start) or not 0 <= start <= 3600: - start = 0 - except Exception as error: - if getattr(error, "seconds", None) is not None: - raise - # Retry the entire lookup once to refresh an expiring file reference. - refreshed_user, refreshed_full, refreshed = await resolve() - if refreshed.id != photo.id: - raise Unavailable("primary_changed_during_download") - retry_video = choose_video(refreshed) - if retry_video is not None: - try: - video = await download(refreshed, retry_video, MAX_VIDEO_BYTES) - validate_video(video) - start = field(retry_video, "video_start_ts", 0) or 0 - except Exception as error: - if getattr(error, "seconds", None) is not None: - raise - video = None - note = "Animated profile unavailable; showing the current profile still." if video is None else "" - elif field(field(user, "photo"), "has_video", False): - note = "A full animated profile rendition was not accessible; showing the profile still." - verify = await client(functions.users.GetFullUserRequest(id=utils.get_input_user(user))) - fresh_user = next((u for u in verify.users if u.id == user.id), user) - confirmed = choose_primary(fresh_user, verify.full_user, [photo]) - if field(confirmed, "id") != photo.id: - raise Unavailable("primary_changed_during_download") - return Candidate("telegram", avatar, clean_bio(field(full, "about")), video, start, note) - - try: - await bounded_retry(client.connect) - await bounded_retry(lambda: client.sign_in(bot_token=self.bot_token)) - me = await bounded_retry(client.get_me) - if not me or not me.bot: - raise NoPublicProfile("bot_authorization_required") - return await bounded_retry(get_candidate, attempts=2, timeout=120) - finally: - try: - await asyncio.wait_for(client.disconnect(), 10) - except Exception: - pass - - -class SafeRedirects(HTTPRedirectHandler): - def redirect_request(self, req, fp, code, msg, headers, newurl): - validate_public_url(newurl) - return super().redirect_request(req, fp, code, msg, headers, newurl) - - -def validate_public_url(url: str) -> None: - parts = urlsplit(url) - if parts.scheme != "https" or parts.username or parts.password or parts.port not in (None, 443) or parts.hostname not in {"api.github.com", "avatars.githubusercontent.com"}: - raise Unavailable("untrusted_public_source") - - -def public_read(url: str, maximum: int) -> bytes: - validate_public_url(url) - request = Request(url, headers={"User-Agent": "WizardLoop-profile-sync", "Accept": "application/vnd.github+json" if urlsplit(url).hostname == "api.github.com" else "image/*"}) - with build_opener(SafeRedirects()).open(request, timeout=12) as response: - length = response.headers.get("Content-Length") - if length and int(length) > maximum: - raise Unavailable("download_limit") - result = response.read(maximum + 1) - if len(result) > maximum: - raise Unavailable("download_limit") - return result - - -class GitHubProvider: - async def metadata(self) -> dict: - async def operation(): - raw = await asyncio.to_thread(public_read, "https://api.github.com/users/WizardLoop", 64 * 1024) - data = json.loads(raw) - if str(data.get("login", "")).lower() != "wizardloop": - raise Unavailable("github_identity_mismatch") - return data - return await bounded_retry(operation, attempts=2, timeout=35) - - async def fetch_bio(self) -> str: - return clean_bio((await self.metadata()).get("bio")) - - async def fetch(self) -> Candidate: - data = await self.metadata() - avatar_url = data.get("avatar_url", "") - if urlsplit(avatar_url).hostname != "avatars.githubusercontent.com": - raise Unavailable("github_avatar_host_rejected") - avatar = await bounded_retry(lambda: asyncio.to_thread(public_read, avatar_url, MAX_IMAGE_BYTES), attempts=2, timeout=20) - return Candidate("github", avatar, clean_bio(data.get("bio"))) - - -class FixtureProvider: - """Local synthetic providers only; cannot download URLs or authenticate.""" - def __init__(self, fixture: dict, source: str): - self.fixture, self.source = fixture, source - - async def fetch(self) -> Candidate: - config = self.fixture.get(self.source, {}) - if config.get("unavailable", True): - raise Unavailable("fixture_unavailable") - if self.source == "telegram": - choose_primary(config["user"], config["full"], config.get("history", [])) - return Candidate(self.source, local_mark(), clean_bio(config.get("bio")), fixture=True, - media_note="Synthetic offline fixture. No live profile sync or video was performed.") - - async def fetch_bio(self) -> str: - return (await self.fetch()).bio - - -def make_poster(video: bytes | None, avatar: bytes, timestamp: float, ffmpeg: str | None) -> tuple[bytes, str]: - if video is None or not ffmpeg: - return avatar, "profile-still" - if not isinstance(timestamp, (int, float)) or not math.isfinite(timestamp) or not 0 <= timestamp <= 3600: - timestamp = 0 - with tempfile.TemporaryDirectory(prefix="wizardloop-poster-") as directory: - source, target = Path(directory) / "profile.mp4", Path(directory) / "poster.jpg" - source.write_bytes(video) - try: - clean_env = {key: os.environ[key] for key in ("PATH", "SystemRoot", "WINDIR", "TEMP", "TMP", "HOME") if key in os.environ} - subprocess.run([ffmpeg, "-nostdin", "-hide_banner", "-loglevel", "error", "-protocol_whitelist", "file,pipe", "-ss", str(timestamp), "-i", str(source), "-map", "0:v:0", "-frames:v", "1", "-q:v", "2", "-y", str(target)], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=25, env=clean_env) - return normalize_image(target.read_bytes())[0], "video-frame" - except (OSError, subprocess.SubprocessError, Unavailable): - return avatar, "profile-still" - - -def read_previous(output: Path) -> dict | None: - try: - data = json.loads((output / "profile.json").read_text(encoding="utf-8")) - if data.get("avatar") != PUBLIC_PREFIX + "avatar.jpg" or data.get("poster") != PUBLIC_PREFIX + "poster.jpg": - return None - normalize_image((output / "avatar.jpg").read_bytes()) - normalize_image((output / "poster.jpg").read_bytes()) - if data.get("video"): - if data["video"] != PUBLIC_PREFIX + "profile.mp4": - return None - validate_video((output / "profile.mp4").read_bytes()) - # Never round-trip arbitrary fields into public metadata. - keys = {"source", "avatar", "video", "poster", "bio", "bioSource", "syncedAt", "checkedAt", "status", "posterSource", "width", "height", "mediaNote"} - result = {key: value for key, value in data.items() if key in keys} - result["bio"] = clean_bio(result.get("bio")) - return result - except (OSError, ValueError, TypeError, Unavailable): - return None - - -def atomic_file(target: Path, data: bytes) -> None: - if target.is_file() and target.read_bytes() == data: - return - temporary = target.with_name(target.name + ".pending") - try: - with temporary.open("wb") as stream: - stream.write(data) - stream.flush() - os.fsync(stream.fileno()) - os.replace(temporary, target) - finally: - temporary.unlink(missing_ok=True) - - -def commit(output: Path, assets: dict[str, bytes | None], manifest: dict) -> None: - """Atomic per-file replacements, manifest last, rollback on ordinary write failure.""" - payloads = dict(assets) - payloads["profile.json"] = (json.dumps(manifest, ensure_ascii=False, indent=2) + "\n").encode("utf-8") - if any(name not in PUBLIC_FILES for name in payloads): - raise ConfigurationError("unexpected_output_filename") - backups = {name: (output / name).read_bytes() if (output / name).is_file() else None for name in payloads} - written = [] - try: - for name, content in payloads.items(): - if content is not None: - atomic_file(output / name, content) - else: - (output / name).unlink(missing_ok=True) - written.append(name) - except OSError: - for name in reversed(written): - if backups[name] is None: - (output / name).unlink(missing_ok=True) - else: - atomic_file(output / name, backups[name]) - raise Unavailable("output_commit_failed") from None - - -@contextmanager -def output_lock(output: Path): - output.mkdir(parents=True, exist_ok=True) - lock = output / ".sync.lock" - try: - descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) - except FileExistsError: - raise ConfigurationError("sync_already_locked") from None - try: - with os.fdopen(descriptor, "w") as stream: - stream.write(now()) - yield - finally: - lock.unlink(missing_ok=True) - - -async def sync(settings: Settings, telegram, github) -> dict: - with output_lock(settings.output): - previous = read_previous(settings.output) - checked_at = now() - if previous and not settings.force: - try: - checked = datetime.fromisoformat(previous["checkedAt"].replace("Z", "+00:00")) - elapsed = (datetime.now(timezone.utc) - checked).total_seconds() - if 0 <= elapsed < settings.cache_hours * 3600: - return previous - except (KeyError, TypeError, ValueError): - pass - candidate, retain_allowed = None, True - try: - candidate = await telegram.fetch() - normalize_image(candidate.avatar) - except NoPublicProfile: - candidate, retain_allowed = None, False - except Exception: - candidate = None - if candidate is None and previous and retain_allowed and previous.get("source", "").startswith("telegram"): - retained = {**previous, "status": "retained", "checkedAt": checked_at} - commit(settings.output, {}, retained) - return retained - secondary, secondary_bio = None, "" - if candidate is None: - try: - secondary = await github.fetch() - normalize_image(secondary.avatar) - except Exception: - secondary = None - if secondary is None: - # Biography availability is independent of the avatar download. - try: - secondary_bio = clean_bio(await github.fetch_bio()) - except Exception: - secondary_bio = "" - elif not clean_bio(candidate.bio): - try: - secondary_bio = clean_bio(await github.fetch_bio()) - except Exception: - secondary_bio = "" - candidate = candidate or secondary - if candidate is None and previous and retain_allowed: - retained = {**previous, "status": "retained", "checkedAt": checked_at} - commit(settings.output, {}, retained) - return retained - if candidate is None: - try: - avatar = settings.local_avatar.read_bytes() if settings.local_avatar and settings.local_avatar.is_file() else local_mark() - normalize_image(avatar) - except (OSError, Unavailable): - avatar = local_mark() - candidate = Candidate("local", avatar) - avatar, width, height = normalize_image(candidate.avatar) - if candidate.video is not None: - validate_video(candidate.video) - bio, bio_source = clean_bio(candidate.bio), candidate.source - if not bio and secondary_bio: - bio, bio_source = secondary_bio, "github" - if not bio: - bio, bio_source = clean_bio(settings.local_bio), "local" - poster, poster_source = make_poster(candidate.video, avatar, candidate.video_start, settings.ffmpeg) - status = "fixture" if candidate.fixture else "current" if candidate.source == "telegram" else "fallback" - note = candidate.media_note - if candidate.video and poster_source != "video-frame": - note = "Video available; poster uses the verified profile still because frame extraction was unavailable." - manifest = {"source": candidate.source, "avatar": PUBLIC_PREFIX + "avatar.jpg", "video": PUBLIC_PREFIX + "profile.mp4" if candidate.video else None, - "poster": PUBLIC_PREFIX + "poster.jpg", "bio": bio, "bioSource": bio_source, - "syncedAt": checked_at, "checkedAt": checked_at, "status": status, - "posterSource": poster_source, "width": width, "height": height, "mediaNote": note} - commit(settings.output, {"avatar.jpg": avatar, "poster.jpg": poster, "profile.mp4": candidate.video}, manifest) - return manifest - - -def main() -> int: - parser = argparse.ArgumentParser(description="Synchronize the public WizardLoop profile with bot-only MTProto.") - parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) - parser.add_argument("--local-avatar", type=Path) - parser.add_argument("--force", action="store_true", help="Bypass the six-hour public metadata cache") - parser.add_argument("--fixture", type=Path, help="Offline synthetic fixture; requires a separate explicit output directory") - args = parser.parse_args() - try: - settings = Settings(args.output.resolve(), args.local_avatar, clean_bio(os.environ.get("PROFILE_LOCAL_BIO", DEFAULT_BIO)), - ffmpeg=os.environ.get("FFMPEG_BINARY") or shutil.which("ffmpeg"), force=args.force or bool(args.fixture)) - if args.fixture: - if args.output.resolve() == DEFAULT_OUTPUT.resolve(): - raise ConfigurationError("fixture_requires_separate_output") - fixture = json.loads(args.fixture.read_text(encoding="utf-8")) - telegram, github = FixtureProvider(fixture, "telegram"), FixtureProvider(fixture, "github") - else: - telegram, github = TelegramProvider(), GitHubProvider() - result = asyncio.run(sync(settings, telegram, github)) - print(json.dumps({"event": "profile_sync_complete", "source": result["source"], "status": result["status"], "video": bool(result.get("video")), "posterSource": result.get("posterSource")})) - return 0 - except ConfigurationError as error: - print(json.dumps({"event": "configuration_required", "reason": str(error)})) - return 2 - except Exception: - print(json.dumps({"event": "profile_sync_failed", "reason": "existing_assets_preserved_where_available"})) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/services/profile-sync/requirements.txt b/services/profile-sync/requirements.txt deleted file mode 100644 index 31b8b16..0000000 --- a/services/profile-sync/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -Telethon==1.45.0 -Pillow==13.0.0 -# Resolved transitive dependencies, pinned for reproducible isolated installs. -pyaes==1.6.1 -rsa==4.9.1 -pyasn1==0.6.4 diff --git a/services/profile-sync/test_profile_sync.py b/services/profile-sync/test_profile_sync.py deleted file mode 100644 index eb8a41e..0000000 --- a/services/profile-sync/test_profile_sync.py +++ /dev/null @@ -1,288 +0,0 @@ -"""Offline tests. These do not authenticate to Telegram or imply live video support.""" -import asyncio -from contextlib import redirect_stdout -from io import BytesIO, StringIO -import json -from pathlib import Path -import tempfile -import unittest -from unittest.mock import AsyncMock, patch - -import profile_sync as sync - - -class Provider: - def __init__(self, candidate=None, error=None, bio=""): - self.candidate, self.error, self.bio, self.calls = candidate, error, bio, 0 - - async def fetch(self): - self.calls += 1 - if self.error: - raise self.error - return self.candidate - - async def fetch_bio(self): - if self.error: - raise self.error - return self.bio or self.candidate.bio - - -class SelectionTests(unittest.TestCase): - def test_active_photo_is_not_the_newest_history_entry(self): - active, newer = {"id": 7}, {"id": 99, "date": "later"} - result = sync.choose_primary({"photo": {"photo_id": 7}}, {}, [newer, active]) - self.assertIs(result, active) - - def test_full_user_primary_must_match_current_user_photo(self): - active = {"id": 7} - self.assertIs(sync.choose_primary({"photo": {"photo_id": 7}}, {"profile_photo": active}, []), active) - with self.assertRaises(sync.Unavailable): - sync.choose_primary({"photo": {"photo_id": 7}}, {"profile_photo": {"id": 99}}, [{"id": 99}]) - - def test_personal_and_fallback_photos_are_not_published(self): - with self.assertRaises(sync.NoPublicProfile): - sync.choose_primary({"photo": {"photo_id": 7, "personal": True}}, {}, [{"id": 7}]) - with self.assertRaises(sync.NoPublicProfile): - sync.choose_primary({"photo": {"photo_id": 7}}, {"personal_photo": {"id": 7}}, [{"id": 7}]) - with self.assertRaises(sync.NoPublicProfile): - sync.choose_primary({"photo": {"photo_id": 7}}, {"fallback_photo": {"id": 7}}, [{"id": 7}]) - - def test_missing_active_id_never_selects_history(self): - with self.assertRaises(sync.NoPublicProfile): - sync.choose_primary({}, {}, [{"id": 99}]) - - def test_best_full_video_beats_a_larger_preview_and_newer_photo(self): - best = {"type": "u", "w": 1080, "h": 1080, "size": 2000, "video_start_ts": 1.75} - photo = {"video_sizes": [{"type": "p", "w": 2000, "h": 2000, "size": 5000}, - {"type": "u", "w": 640, "h": 640, "size": 1000}, best]} - self.assertIs(sync.choose_video(photo), best) - self.assertEqual(sync.choose_video(photo)["video_start_ts"], 1.75) - - def test_no_full_video_does_not_promote_preview_to_full_animation(self): - self.assertIsNone(sync.choose_video({"video_sizes": []})) - self.assertIsNone(sync.choose_video({"video_sizes": [{"type": "p", "w": 100, "h": 100, "size": 1000}]})) - - def test_largest_still_selected(self): - largest = {"type": "y", "w": 1080, "h": 1080} - self.assertIs(sync.choose_still({"sizes": [{"type": "s", "w": 100, "h": 100}, largest]}), largest) - - def test_personal_sessions_are_rejected(self): - with self.assertRaisesRegex(sync.ConfigurationError, "personal_sessions_are_not_supported"): - sync.TelegramProvider({"TELEGRAM_SESSION": "secret-canary"}) - - def test_only_expected_https_download_hosts_allowed(self): - for url in ["http://api.github.com/users/WizardLoop", "https://127.0.0.1/private", "https://avatars.githubusercontent.com@evil.example/image", "file:///etc/passwd"]: - with self.assertRaises(sync.Unavailable): - sync.validate_public_url(url) - sync.validate_public_url("https://avatars.githubusercontent.com/u/1?v=4") - - def test_bio_is_plain_limited_text_without_forbidden_dash(self): - result = sync.clean_bio("Hello" + chr(0x2014) + "there\x00\n" + "x" * 600) - self.assertNotIn(chr(0x2014), result) - self.assertNotIn("\x00", result) - self.assertLessEqual(len(result), 500) - - def test_image_and_video_reject_invalid_payloads(self): - with self.assertRaises(sync.Unavailable): - sync.normalize_image(b"<html>not an image</html>") - with self.assertRaises(sync.Unavailable): - sync.validate_video(b"<html>not a video</html>") - - def test_poster_falls_back_honestly_when_ffmpeg_is_missing(self): - avatar = sync.local_mark() - self.assertEqual(sync.make_poster(b"test", avatar, 1.75, None), (avatar, "profile-still")) - - def test_poster_uses_requested_frame_without_shell_or_credentials(self): - avatar = sync.local_mark() - calls = [] - def fake_ffmpeg(command, **kwargs): - calls.append((command, kwargs)) - Path(command[-1]).write_bytes(avatar) - with patch.object(sync.subprocess, "run", side_effect=fake_ffmpeg), patch.dict(sync.os.environ, {"TELEGRAM_BOT_TOKEN": "secret-canary"}): - poster, source = sync.make_poster(b"test", avatar, 1.75, "ffmpeg") - self.assertEqual(source, "video-frame") - self.assertTrue(poster.startswith(b"\xff\xd8")) - command, options = calls[0] - self.assertEqual(command[command.index("-ss") + 1], "1.75") - self.assertFalse(options.get("shell", False)) - self.assertNotIn("TELEGRAM_BOT_TOKEN", options["env"]) - - -class PipelineTests(unittest.IsolatedAsyncioTestCase): - async def asyncSetUp(self): - self.directory = tempfile.TemporaryDirectory(prefix="wizardloop-profile-test-") - self.output = Path(self.directory.name) - self.settings = sync.Settings(self.output, force=True) - self.image = sync.local_mark() - - async def asyncTearDown(self): - self.directory.cleanup() - - def unavailable(self): - return Provider(error=sync.Unavailable("secret-canary-should-not-appear")) - - async def test_telegram_still_has_no_claimed_video(self): - result = await sync.sync(self.settings, Provider(sync.Candidate("telegram", self.image, "Owner bio")), self.unavailable()) - self.assertEqual(result["source"], "telegram") - self.assertIsNone(result["video"]) - self.assertEqual(result["posterSource"], "profile-still") - self.assertFalse((self.output / "profile.mp4").exists()) - - async def test_telegram_failure_preserves_latest_good_snapshot(self): - original = await sync.sync(self.settings, Provider(sync.Candidate("telegram", self.image, "Owner bio")), self.unavailable()) - previous_image = (self.output / "avatar.jpg").read_bytes() - github = Provider(sync.Candidate("github", self.image, "Different source")) - result = await sync.sync(self.settings, self.unavailable(), github) - self.assertEqual(result["status"], "retained") - self.assertEqual(result["syncedAt"], original["syncedAt"]) - self.assertEqual(result["bio"], "Owner bio") - self.assertEqual((self.output / "avatar.jpg").read_bytes(), previous_image) - self.assertEqual(github.calls, 0) - - async def test_telegram_failure_without_previous_uses_github(self): - result = await sync.sync(self.settings, self.unavailable(), Provider(sync.Candidate("github", self.image, "GitHub bio"))) - self.assertEqual(result["source"], "github") - self.assertEqual(result["bioSource"], "github") - self.assertEqual(result["status"], "fallback") - - async def test_both_sources_fail_uses_local_mark(self): - result = await sync.sync(self.settings, self.unavailable(), self.unavailable()) - self.assertEqual(result["source"], "local") - self.assertEqual(result["bio"], sync.DEFAULT_BIO) - self.assertTrue((self.output / "poster.jpg").is_file()) - self.assertNotIn("secret-canary", (self.output / "profile.json").read_text()) - - async def test_github_bio_survives_avatar_failure_without_telegram(self): - github = Provider(error=sync.Unavailable("avatar_download_failed")) - github.fetch_bio = AsyncMock(return_value="Available GitHub biography") - result = await sync.sync(self.settings, self.unavailable(), github) - self.assertEqual(result["source"], "local") - self.assertEqual(result["bioSource"], "github") - self.assertEqual(result["bio"], "Available GitHub biography") - github.fetch_bio.assert_awaited_once() - - async def test_explicit_privacy_removal_does_not_retain_old_telegram_photo(self): - await sync.sync(self.settings, Provider(sync.Candidate("telegram", self.image)), self.unavailable()) - result = await sync.sync(self.settings, Provider(error=sync.NoPublicProfile()), Provider(sync.Candidate("github", self.image, "Public fallback"))) - self.assertEqual(result["source"], "github") - self.assertNotEqual(result["status"], "retained") - - async def test_github_fallback_can_refresh_on_later_runs(self): - await sync.sync(self.settings, self.unavailable(), Provider(sync.Candidate("github", self.image, "Old bio"))) - result = await sync.sync(self.settings, self.unavailable(), Provider(sync.Candidate("github", self.image, "New bio"))) - self.assertEqual(result["bio"], "New bio") - - async def test_missing_telegram_bio_uses_github_bio_independently(self): - result = await sync.sync(self.settings, Provider(sync.Candidate("telegram", self.image)), Provider(bio="GitHub public bio")) - self.assertEqual(result["source"], "telegram") - self.assertEqual(result["bioSource"], "github") - self.assertEqual(result["bio"], "GitHub public bio") - - async def test_cache_avoids_repeated_network_calls(self): - await sync.sync(self.settings, Provider(sync.Candidate("telegram", self.image)), self.unavailable()) - self.settings.force = False - telegram = self.unavailable() - await sync.sync(self.settings, telegram, self.unavailable()) - self.assertEqual(telegram.calls, 0) - - async def test_failed_commit_restores_previous_assets_and_manifest(self): - await sync.sync(self.settings, Provider(sync.Candidate("telegram", self.image, "Before")), self.unavailable()) - before = {name: (self.output / name).read_bytes() for name in ("avatar.jpg", "poster.jpg", "profile.json")} - original_replace, failed = sync.os.replace, False - changed = BytesIO() - sync.Image.new("RGB", (640, 640), "#665599").save(changed, "JPEG") - - def fail_once(source, target): - nonlocal failed - if Path(target).name == "poster.jpg" and not failed: - failed = True - raise OSError("simulated disk failure") - return original_replace(source, target) - - with patch.object(sync.os, "replace", side_effect=fail_once): - with self.assertRaises(sync.Unavailable): - await sync.sync(self.settings, Provider(sync.Candidate("telegram", changed.getvalue(), "After")), self.unavailable()) - self.assertEqual({name: (self.output / name).read_bytes() for name in before}, before) - self.assertFalse((self.output / ".sync.lock").exists()) - - async def test_cached_unknown_fields_cannot_leak_to_public_manifest(self): - await sync.sync(self.settings, Provider(sync.Candidate("telegram", self.image)), self.unavailable()) - path = self.output / "profile.json" - data = json.loads(path.read_text()) - data["access_hash"] = "secret-canary" - data["session"] = "session-canary" - path.write_text(json.dumps(data)) - await sync.sync(self.settings, self.unavailable(), self.unavailable()) - self.assertNotIn("canary", path.read_text()) - - async def test_fixture_is_explicitly_labelled(self): - fixture = json.loads((Path(__file__).parent / "fixtures/active-still.json").read_text()) - result = await sync.sync(self.settings, sync.FixtureProvider(fixture, "telegram"), sync.FixtureProvider(fixture, "github")) - self.assertEqual(result["status"], "fixture") - self.assertIn("No live", result["mediaNote"]) - self.assertIsNone(result["video"]) - - async def test_retry_recovers_from_a_transient_error(self): - attempts = 0 - async def operation(): - nonlocal attempts - attempts += 1 - if attempts == 1: - raise OSError("private-request-detail") - return "ready" - self.assertEqual(await sync.bounded_retry(operation, attempts=2), "ready") - self.assertEqual(attempts, 2) - - async def test_long_flood_wait_stops_without_repeated_requests(self): - attempts = 0 - class FloodWait(Exception): - seconds = 120 - async def operation(): - nonlocal attempts - attempts += 1 - raise FloodWait("private-request-detail") - with self.assertRaisesRegex(sync.Unavailable, "source_backoff_required"): - await sync.bounded_retry(operation) - self.assertEqual(attempts, 1) - - async def test_request_timeout_becomes_a_non_secret_failure(self): - async def operation(): - await asyncio.sleep(1) - with self.assertRaisesRegex(sync.Unavailable, "source_unavailable"): - await sync.bounded_retry(operation, attempts=1, timeout=0.001) - - -class CommandTests(unittest.TestCase): - def test_fixture_rejects_repository_output_from_another_working_directory(self): - previous_directory = Path.cwd() - with tempfile.TemporaryDirectory() as directory: - try: - sync.os.chdir(directory) - arguments = ["profile_sync.py", "--fixture", "unused-fixture.json", "--output", str(sync.DEFAULT_OUTPUT)] - with patch("sys.argv", arguments), patch.object(sync, "sync", new=AsyncMock()) as runner, redirect_stdout(StringIO()) as output: - self.assertEqual(sync.main(), 2) - runner.assert_not_called() - self.assertIn("fixture_requires_separate_output", output.getvalue()) - finally: - sync.os.chdir(previous_directory) - - def test_failure_logs_do_not_include_exception_secrets(self): - output = StringIO() - with patch("sys.argv", ["profile_sync.py"]), patch.object(sync, "TelegramProvider", side_effect=RuntimeError("secret-canary")), redirect_stdout(output): - self.assertEqual(sync.main(), 1) - self.assertNotIn("secret-canary", output.getvalue()) - self.assertIn("profile_sync_failed", output.getvalue()) - - def test_telethon_schema_constructor_signatures_without_authentication(self): - try: - from telethon import functions, types - except ImportError: - self.skipTest("Install pinned requirements to check Telethon schema signatures") - location = types.InputPhotoFileLocation(id=7, access_hash=0, file_reference=b"", thumb_size="u") - request = functions.photos.GetUserPhotosRequest(user_id=types.InputUser(user_id=7, access_hash=0), offset=0, max_id=0, limit=100) - self.assertEqual(location.thumb_size, "u") - self.assertEqual(request.limit, 100) - - -if __name__ == "__main__": - unittest.main() diff --git a/src/config/site.ts b/src/config/site.ts index 5755e7b..167af1f 100644 --- a/src/config/site.ts +++ b/src/config/site.ts @@ -11,6 +11,7 @@ export const site = { channelPreview: 'https://t.me/s/wizard_loop', community: 'https://t.me/wizardloopchat', github: 'https://github.com/WizardLoop', + repositories: 'https://github.com/WizardLoop?tab=repositories', source: 'https://github.com/WizardLoop/wizardloop.github.io', }, navigation: [ @@ -20,6 +21,6 @@ export const site = { { label: 'Builds', href: '/projects/' }, { label: 'Posts', href: '/posts/' }, { label: 'Community', href: '/community/' }, - { label: 'Feedback', href: '/feedback/' }, + { label: 'Contact', href: '/feedback/' }, ], }; diff --git a/src/content/posts.json b/src/content/posts.json deleted file mode 100644 index 2c2c7bc..0000000 --- a/src/content/posts.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "channel": "wizard_loop", - "source": "https://t.me/s/wizard_loop", - "status": "verified-snapshot", - "verifiedAt": "2026-10-27", - "posts": [ - { "id": 54, "title": "Adaptive Star of David", "kind": "Animation", "url": "https://t.me/Wizard_Loop/54", "note": "Open the original post for the complete animation and message." }, - { "id": 53, "title": "Psychedelic Adaptive", "kind": "Animated media", "url": "https://t.me/Wizard_Loop/53", "note": "The official post includes animated media. Some custom emoji may require Telegram." }, - { "id": 52, "title": "Amanimo sticker", "kind": "TGS sticker", "url": "https://t.me/Wizard_Loop/52", "note": "The official embed exposes a TGS sticker. Client support can vary." }, - { "id": 51, "title": "Amanimo", "kind": "Pack drop", "url": "https://t.me/Wizard_Loop/51", "note": "Read the original pack announcement, including its formatting and media." } - ] -} diff --git a/src/content/projects.json b/src/content/projects.json deleted file mode 100644 index 8fbd1e2..0000000 --- a/src/content/projects.json +++ /dev/null @@ -1,32 +0,0 @@ -[ - { - "slug": "magiclottie", - "name": "MagicLottie", - "category": "Animation tools", - "description": "A mobile-first, open-source editor for Lottie and TGS animations.", - "role": "Open-source project by WizardLoop", - "url": "https://github.com/WizardLoop/MagicLottie", - "art": "paints-the-world", - "color": "violet" - }, - { - "slug": "shabbat", - "name": "Shabbat", - "category": "Telegram bots", - "description": "A Telegram bot that automatically locks and unlocks groups during Shabbat and Jewish holidays.", - "role": "Open-source project by WizardLoop", - "url": "https://github.com/WizardLoop/shabbat", - "art": "signature-spirit", - "color": "peach" - }, - { - "slug": "creationdate", - "name": "CreationDate", - "category": "Software experiments", - "description": "An open-source project that estimates the creation date of Telegram accounts.", - "role": "Open-source project by WizardLoop", - "url": "https://github.com/WizardLoop/CreationDate", - "art": "arcane-coder", - "color": "mint" - } -] diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index e54b4b1..46efb0c 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -116,6 +116,13 @@ const schema = { type="font/woff2" crossorigin /> + <link + rel="preload" + href="/fonts/manrope-latin.woff2" + as="font" + type="font/woff2" + crossorigin + /> </head> <body id="top"> <a class="skip-link" href="#main" tabindex="0"> @@ -255,14 +262,18 @@ const schema = { </a> <a href={site.links.telegram}> <Icon name="telegram" size={18} /> - Contact me + Message me + </a> + <a href={`mailto:${site.email}`}> + <Icon name="mail" size={18} /> + Email me </a> </div> <div class="footer-nav"> <h2>A good web</h2> <a href="/feedback/"> <Icon name="mail" size={18} /> - Feedback + Contact </a> <a href="/accessibility/">Accessibility</a> <a href="/privacy/">Privacy & cookies</a> @@ -271,9 +282,7 @@ const schema = { </div> <div class="footer-bottom wrap"> <span>© 2026 WizardLoop. All rights reserved.</span> - <button class="text-button" data-privacy-open> - Privacy preferences - </button> + <a href="/cookies/">Cookies & storage</a> <a href={site.links.source}> <Icon name="code" size={17} /> Website source @@ -284,45 +293,6 @@ const schema = { their respective rights. </p> </footer> - <dialog - class="privacy-dialog" - id="privacy-dialog" - aria-labelledby="privacy-title" - > - <div class="dialog-heading"> - <h2 id="privacy-title">Your privacy, your choice.</h2> - <button - class="icon-button" - data-privacy-close - aria-label="Close privacy preferences" - > - <Icon name="close" /> - </button> - </div> - <p> - This site does not use advertising or analytics. Theme and motion - preferences stay in your browser. - </p> - <label class="consent-option"> - <input type="checkbox" id="telegram-consent" /> - <span> - <strong>Load Telegram posts</strong> - <br /> - Telegram receives your connection information and may store cookies - when its embeds load. Animated content is controlled by Telegram. - </span> - </label> - <p class="small-text"> - You can read every post in Telegram without enabling embeds here. - Revoking permission removes loaded embeds. - </p> - <button class="button" data-privacy-save> - Save preferences <Icon name="check" size={18} /> - </button> - <a class="text-link" href="/privacy/"> - Read the privacy policy - </a> - </dialog> <div class="sr-only" role="status" diff --git a/src/pages/about.astro b/src/pages/about.astro index 2b128ea..58fbbc1 100644 --- a/src/pages/about.astro +++ b/src/pages/about.astro @@ -28,24 +28,8 @@ import profile from '../../public/media/profile/profile.json'; /> <div class="profile-detail"> <div class="profile-media"> - {profile.video && ( - <video - data-profile-video - muted - loop - playsinline - preload="none" - poster={profile.poster || '/media/profile/avatar.jpg'} - width="96" - height="96" - > - <source src={profile.video} type="video/mp4" /> - </video> - )} <img - hidden={Boolean(profile.video)} data-profile-image - data-github-fallback="/media/profile/github.jpg" src={profile.avatar || '/media/profile/avatar.jpg'} width="96" height="96" @@ -54,7 +38,7 @@ import profile from '../../public/media/profile/profile.json'; </div> <div> <strong>WizardLoop</strong> - <span data-profile-source>{profile.source}</span> + <span>Design, motion & code</span> </div> </div> </div> @@ -75,12 +59,6 @@ import profile from '../../public/media/profile/profile.json'; question: what could this become? </p> <blockquote>{profile.bio}</blockquote> - <p class="small-text"> - Profile text source: {profile.bioSource}. Last verified:{' '} - {profile.syncedAt ? profile.syncedAt.slice(0, 10) : 'local fallback'}.{' '} - {profile.status === 'public-snapshot' && - 'The public still is a verified snapshot. Automatic primary-video synchronization is not connected yet.'} - </p> <div class="inline-actions"> <a class="button" href={site.links.telegram}> <Icon name="telegram" /> diff --git a/src/pages/accessibility.astro b/src/pages/accessibility.astro index cb8e5fc..6d94a4f 100644 --- a/src/pages/accessibility.astro +++ b/src/pages/accessibility.astro @@ -4,7 +4,7 @@ import Base from '../layouts/Base.astro'; <Base title="Accessibility" - description="WizardLoop's accessibility features, WCAG 2.2 AA technical target, known third-party limitations and ways to report a barrier." + description="WizardLoop's accessibility features, WCAG 2.2 AA technical target, known testing limitations and ways to report a barrier." > <section class="wrap prose-page"> <header class="page-intro"> @@ -19,7 +19,7 @@ import Base from '../layouts/Base.astro'; across different devices and access needs. </p> <p> - Reviewed <time datetime="2026-10-27">27 October 2026</time>. + Reviewed <time datetime="2026-10-28">28 October 2026</time>. </p> </header> <div class="prose"> @@ -46,8 +46,8 @@ import Base from '../layouts/Base.astro'; <ul> <li>Semantic page sections, headings and a Skip to content link.</li> <li> - Keyboard-operable navigation, links, form fields and controls with - visible focus indicators. + Keyboard-operable navigation, links and controls with visible focus + indicators. </li> <li> A mobile menu with focus management, Escape to close and focus @@ -65,10 +65,6 @@ import Base from '../layouts/Base.astro'; Text alternatives for meaningful images and hidden decorative illustrations. </li> - <li> - Form labels, required-field information and status messages intended - for assistive technology. - </li> <li> Appropriate language and direction handling, including Hebrew content where it appears. @@ -83,20 +79,15 @@ import Base from '../layouts/Base.astro'; </p> <p> The header's motion control pauses the site's repeated animation. Your - operating system's reduced-motion preference is respected. Pausing also - unloads optional Telegram embeds, because their internal animation - cannot be reliably controlled from this site. Original post links remain - available. + operating system's reduced-motion preference is respected. Local artwork + has still previews when motion is paused or playback is unavailable. </p> <h2>Known limitations</h2> <p> - Telegram embeds and the configured Cloudflare Turnstile challenge are - third-party interfaces. Their markup, keyboard behavior, media playback - and screen-reader output are not fully under my control. Telegram may - also omit some media or formatting available in its own apps. You can - decline embeds, open original posts, or use the contact alternatives - below. + Telegram and GitHub open as separate websites. Their accessibility and + media playback are outside this website's control. You can choose email + instead of Telegram for direct contact. </p> <p> A complete human screen-reader audit has not been performed. Automated @@ -105,10 +96,9 @@ import Base from '../layouts/Base.astro'; accessibility score or legal compliance claim is made here. </p> <p> - The feedback form can be unavailable when its external service is not - configured or is temporarily failing. Email and direct Telegram contact - remain alternatives. If one of those channels is difficult to use, - describe your preferred way to receive a response. + The contact page provides direct Telegram and email links without a form + or verification challenge. If one channel is difficult to use, choose + the other and describe your preferred way to receive a response. </p> <h2>Report an accessibility barrier</h2> diff --git a/src/pages/cookies.astro b/src/pages/cookies.astro index e79f116..8c314bd 100644 --- a/src/pages/cookies.astro +++ b/src/pages/cookies.astro @@ -4,7 +4,7 @@ import Base from '../layouts/Base.astro'; <Base title="Cookies & storage" - description="The local preferences WizardLoop saves, how optional Telegram embeds work, and how to change or remove your choices." + description="WizardLoop remembers only your local theme and motion choices. No analytics, advertising cookies or embedded social feeds." > <section class="wrap prose-page"> <header class="page-intro"> @@ -12,92 +12,57 @@ import Base from '../layouts/Base.astro'; <h1> A few preferences. <br /> - No analytics. + No tracking scripts. </h1> <p> - This site does not add advertising or analytics cookies. It uses local - browser storage for choices you make and asks before loading optional - Telegram content. - </p> - <p> - Reviewed <time datetime="2026-10-27">27 October 2026</time>. + Updated <time datetime="2026-10-28">28 October 2026</time>. </p> </header> <div class="prose"> <h2>What this website remembers</h2> + <p> + The website code does not set cookies. It uses two local-storage entries + for choices you make: + </p> <ul> <li> - <strong>Theme:</strong> your light or dark appearance choice. + <strong>Theme:</strong> light or dark appearance, stored as{' '} + <code>wizardloop-theme</code>. </li> <li> - <strong>Motion:</strong> whether you paused the site's animation. Your - system's reduced-motion setting is also respected. - </li> - <li> - <strong>Telegram preference:</strong> whether you chose to allow - optional embedded posts. + <strong>Motion:</strong> whether you paused animation, stored as{' '} + <code>wizardloop-motion</code>. Your system's reduced-motion + preference is also respected. </li> </ul> <p> - These values use local storage, which is different from a cookie but - still stores information in your browser. They are not used to create a - marketing profile. They remain until you change them or clear this - site's data. If storage is blocked, the site still works and preferences - may not persist. - </p> - - <h2>External services</h2> - <p> - Optional Telegram embeds stay unloaded until you choose to enable them. - Telegram can then process connection information and use its own cookies - or storage. You can leave embeds disabled and open the original post - separately. The{' '} - <a href="https://telegram.org/privacy">Telegram Privacy Policy</a>{' '} - describes its practices. - </p> - <p> - When the feedback form is configured, Cloudflare Turnstile provides an - anti-abuse check. Its explanation appears before loading the check. It - is not an analytics or advertising integration; Cloudflare still - processes technical information needed for its service. See{' '} - <a href="https://www.cloudflare.com/privacypolicy/"> - Cloudflare's Privacy Policy - </a> - . - </p> - <p> - There is no blanket consent banner for features this site does not use. - Optional external posts have their own choice at the point where you - would load them. - </p> - - <h2>Change your preferences</h2> - <p> - Use the theme and motion controls in the header to change those - settings. Use the Telegram preference control on the Posts page to - withdraw permission for embeds. Declining is available without - preventing access to the rest of the site. - </p> - <p> - <button class="button" type="button" data-privacy-open> - Manage external content - </button> + Local storage is different from a cookie, but both store information in + your browser. These settings remain until you change them or clear this + website's data. They are not used to identify visitors or build a + marketing profile. If storage is unavailable, the site remains usable + and your choices may apply only to the current visit. </p> + <h2>No embedded services</h2> <p> - <a class="text-link" href="/posts/"> - Visit the Posts page - </a> + The site has no advertising, analytics, social widgets, CAPTCHA or + embedded Telegram feed. It does not need a banner asking permission to + load those features. Links to Telegram and GitHub take you to their + websites only when you choose to follow them; their own storage and + privacy rules then apply. </p> + <h2>Change or clear your choices</h2> <p> - You can also clear local storage and cookies through your browser's - site-data settings. Clearing this site's data resets its choices. - Telegram-owned cookies must be cleared for Telegram's domain, and - withdrawing permission does not reverse information already sent to a - provider. + Use the theme and motion buttons in the header. To reset all saved + choices, clear the data for <strong>wizardloop.com</strong> in your + browser's site settings. An older version's Telegram preference, if + present, is no longer read or used and is removed when you clear the + site's data. </p> <p> - For questions, see the <a href="/privacy/">Privacy Policy</a> or email{' '} - <a href="mailto:wizard@wizardloop.com">wizard@wizardloop.com</a>. + These statements describe the website code, not independent hosting + security logs or data stored by websites you visit through links. Read + the <a href="/privacy/">Privacy Policy</a> or{' '} + <a href="/feedback/">contact me</a> if you have a question. </p> </div> </section> diff --git a/src/pages/feedback.astro b/src/pages/feedback.astro index e1fd80f..5a02969 100644 --- a/src/pages/feedback.astro +++ b/src/pages/feedback.astro @@ -1,172 +1,130 @@ --- import Base from '../layouts/Base.astro'; -import MotionArt from '../components/MotionArt.astro'; import Icon from '../components/Icon.astro'; import { site } from '../config/site'; -const endpoint = import.meta.env.PUBLIC_FEEDBACK_API_URL || ''; -const sitekey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY || ''; +import '../styles/destinations.css'; --- <Base - title="Feedback & contact" - description="Send WizardLoop a question, an idea or feedback. A personal conversation about design, animation, software or the website." + title="Contact" + description="Contact WizardLoop directly on Telegram or by email. Share an idea, ask a question about the work or join the community." > - <section class="page-intro wrap"> - <p class="eyebrow">AN IDEA? A QUESTION? A LITTLE HELLO?</p> - <h1> - Let's start - <br /> - <span class="outlined-word">a conversation.</span> - </h1> - </section> - <section class="feedback-grid wrap"> - <aside class="feedback-aside"> - <MotionArt - name="psychedelic-heart" - alt="WizardLoop's wizard creates a heart-shaped infinity loop" - /> + <section class="destination-page destination-contact wrap"> + <header class="destination-heading"> + <p class="eyebrow">CONTACT / AN IDEA, A QUESTION, A LITTLE HELLO</p> + <h1> + Let's start + <br /> + <em>a conversation.</em> + </h1> <p> - Something to share about the work or the website? I'd like to hear it. - Your email is optional. Include it if you'd like an email reply. - </p> - <a class="text-link" href={site.links.telegram}> - <Icon name="telegram" /> - Message me on Telegram - </a> - <p class="small-text"> - Or write to{' '} - <a class="text-link" href={`mailto:${site.email}`}> - <Icon name="mail" size={17} /> - {site.email} - </a> + Something to make, something to ask, or something to share. I'd like to + hear it. </p> - </aside> - <form - class="feedback-form" - method="post" - data-feedback-form - data-endpoint={endpoint} - data-sitekey={sitekey} - novalidate + </header> + <section + class="destination-contact-layout" + aria-labelledby="contact-heading" > - <div class="local-demo" data-local-demo hidden> - <strong>Local test mode.</strong> This form uses a local mock. No - feedback is sent to Telegram. - </div> - <div class="notice" data-feedback-unavailable hidden> - The feedback service is not connected yet. Please use{' '} - <a href={site.links.telegram}>Telegram</a> or{' '} - <a href={`mailto:${site.email}`}>email</a>. - </div> - <fieldset class="feedback-controls" data-feedback-controls disabled> - <legend class="sr-only">Your feedback</legend> - <div class="field"> - <label for="feedback-name"> - Name <span>Required</span> - </label> - <input - id="feedback-name" - name="name" - autocomplete="name" - required - minlength="1" - maxlength="80" - aria-describedby="name-error" - /> - <p id="name-error" class="field-error"></p> - </div> - <div class="field"> - <label for="feedback-message"> - Your message <span>Required</span> - </label> - <textarea - id="feedback-message" - name="message" - required - minlength="1" - maxlength="2500" - rows="6" - aria-describedby="message-error message-hint" - ></textarea> - <p id="message-error" class="field-error"></p> - <p class="field-hint" id="message-hint"> - Up to 2,500 characters. Please leave out passwords and sensitive - personal information. - </p> - </div> - <div class="field"> - <label for="feedback-email"> - Email <span>Optional</span> - </label> - <input - id="feedback-email" - name="email" - type="email" - autocomplete="email" - maxlength="254" - aria-describedby="email-error" - /> - <p id="email-error" class="field-error"></p> - </div> - <div class="honeypot" aria-hidden="true"> - <label for="feedback-website">Website</label> - <input - id="feedback-website" - name="website" - tabindex="-1" - autocomplete="off" - /> + <div class="destination-contact-intro"> + <span class="destination-index">STRAIGHT TO ME</span> + <h2 id="contact-heading"> + Pick a place + <br /> + to say hello. + </h2> + <p> + Message me on Telegram or send an email. Tell me a little about your + idea, question or feedback, and include any links that help explain + it. + </p> + <div class="destination-contact-drawing" aria-hidden="true"> + <svg viewBox="0 0 320 225" fill="none" focusable="false"> + <path class="drawing-shadow" d="m66 65 186-19 13 132-186 19z" /> + <path + class="drawing-violet drawing-stroke" + d="m55 54 186-19 13 132-186 19z" + /> + <path + class="drawing-paper drawing-stroke" + d="m55 54 99 66 87-85z" + /> + <path class="drawing-stroke" d="m68 186 58-86m128 67-75-72" /> + <path + class="drawing-yellow drawing-stroke" + d="m271 54 7 19 20 5-20 6-7 19-7-19-20-6 20-5z" + /> + <path + class="drawing-accent-stroke" + d="M13 111h25m-20 17h22m-13 17h15m148 60 20-4m-57 8 21-2" + /> + <circle class="drawing-ink" cx="34" cy="68" r="3" /> + </svg> + <span class="destination-handnote"> + Good things start with hello. + </span> </div> - <div class="captcha-section"> - <p class="captcha-info" data-captcha-description> - Before sending, Cloudflare Turnstile checks that this request is - legitimate. It processes connection and browser information.{' '} - <a href="/privacy/">Privacy details</a>. - </p> + </div> + <div class="destination-contact-links"> + <a class="destination-contact-link" href={site.links.telegram}> + <span class="destination-contact-icon"> + <Icon name="telegram" size={28} /> + </span> + <span class="destination-contact-label"> + <strong>Telegram</strong> + <span>@wizardloop</span> + </span> + <Icon name="northeast" class="destination-link-arrow" size={23} /> + </a> + <a class="destination-contact-link" href={`mailto:${site.email}`}> + <span class="destination-contact-icon"> + <Icon name="mail" size={28} /> + </span> + <span class="destination-contact-label"> + <strong>Email</strong> + <span>{site.email}</span> + </span> + <Icon name="northeast" class="destination-link-arrow" size={23} /> + </a> + <div class="destination-email-copy"> <button - class="text-button captcha-start" + class="destination-copy-button" type="button" - data-verify-start - disabled + data-copy-email={site.email} + hidden > - Start verification + Copy email address </button> - <div id="turnstile-container"></div> - <p class="verification-state" role="status" data-verification-state> - Verification has not started. - </p> + <p + class="destination-copy-status" + role="status" + aria-live="polite" + aria-atomic="true" + data-copy-status + ></p> </div> - <button class="button" type="submit" data-feedback-submit disabled> - Send feedback <Icon name="arrow" size={20} /> - </button> - </fieldset> - <p class="form-disclosure"> - By sending, you ask me to receive your name, message and optional email - in a private Telegram conversation through the feedback service.{' '} - <a href="/privacy/">How your information is handled</a>. - </p> - <div - class="form-status" - data-feedback-status - role="status" - aria-live="polite" - hidden - ></div> - <noscript> - <p class="notice"> - The form needs JavaScript for verification. Contact me on{' '} - <a href={site.links.telegram}>Telegram</a> or by{' '} - <a href={`mailto:${site.email}`}>email</a> instead. + <p class="destination-contact-help"> + Telegram opens my profile for a direct message. Email opens your mail + app, or you can copy the address above and write to me yourself. + </p> + <p class="destination-contact-note"> + Prefer a shared conversation? Come join the community. </p> - </noscript> - </form> + <a class="destination-small-link" href={site.links.community}> + <Icon name="community" size={21} /> + <span>WizardLoop community</span> + <Icon name="northeast" size={19} /> + </a> + <a class="destination-small-link" href={site.links.github}> + <Icon name="github" size={21} /> + <span>Find me on GitHub</span> + <Icon name="northeast" size={19} /> + </a> + <p class="destination-contact-help"> + Community conversations and public GitHub discussions are visible to + others. + </p> + </div> + </section> </section> </Base> -<style> - .feedback-controls { - border: 0; - padding: 0; - margin: 0; - min-inline-size: 0; - } -</style> diff --git a/src/pages/index.astro b/src/pages/index.astro index 90cd5d3..8e55594 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -4,7 +4,6 @@ import Icon from '../components/Icon.astro'; import MotionArt from '../components/MotionArt.astro'; import HeroArt from '../components/HeroArt.astro'; import { site } from '../config/site'; -import projects from '../content/projects.json'; --- <Base title="I make things come alive" profile> @@ -13,7 +12,7 @@ import projects from '../content/projects.json'; <p class="eyebrow"> <span class="ink-dot"></span>INDEPENDENT CREATIVE / WIZARDLOOP </p> - <span class="edition-note">A SMALL STUDIO. A CURIOUS UNIVERSE.</span> + <span class="edition-note">ONE CURIOUS CREATOR. A WORLD OF IDEAS.</span> </div> <div class="hero-grid"> <div class="hero-copy"> @@ -66,7 +65,7 @@ import projects from '../content/projects.json'; <div class="hero-stage"> <div class="halftone" aria-hidden="true"></div> <span class="panel-coordinate" aria-hidden="true"> - FIG. 001 / PORTAL ALCHEMY + A WINDOW INTO MY IMAGINATION </span> <HeroArt /> <svg class="comic-rays" viewBox="0 0 500 500" aria-hidden="true"> @@ -87,8 +86,7 @@ import projects from '../content/projects.json'; </span> <span class="art-source"> Original WizardLoop animation - <br /> - Infinity Atelier collection + <br />A little piece of my world </span> </div> </div> @@ -104,83 +102,62 @@ import projects from '../content/projects.json'; <Icon name="northeast" /> </div> </section> - <section class="work-teaser section wrap" aria-labelledby="work-title"> + <section class="section wrap" aria-labelledby="explore-title"> <div class="section-heading"> <div> - <p class="eyebrow">01 / STRAIGHT FROM MY IMAGINATION</p> - <h2 id="work-title"> - Small worlds. + <p class="eyebrow">01 / FOLLOW THE CURIOSITY</p> + <h2 id="explore-title"> + Different doors. <br /> - <span class="outlined-word">Big personality.</span> + <span class="outlined-word">One creative world.</span> </h2> </div> - <a class="text-link" href="/work/"> - See the collection <Icon name="northeast" /> - </a> </div> - <div class="art-grid"> - <a class="art-card violet-card" href="/work/#infinity-atelier"> - <div class="card-art"> - <span class="card-tag">DESIGN + ANIMATION</span> - <MotionArt - name="signature-spirit" - alt="Purple infinity wizard with a curved pointed hat and bright eyes" - /> - <span class="card-index" aria-hidden="true"> - 01 - </span> - </div> - <div class="card-caption"> - <div> - <h3>Infinity Atelier</h3> - <p>Thirty little moments of wizardry.</p> - </div> - <span class="round-arrow"> - <Icon name="northeast" /> - </span> - </div> + <div class="home-paths"> + <a class="home-path" href="/work/"> + <span class="home-path-icon"> + <Icon name="sparkle" size={32} /> + </span> + <span class="eyebrow">THE VISUAL SIDE</span> + <h3> + Art, motion + <br />& imagination. + </h3> + <p>Find my portfolio and creative work in the Telegram channel.</p> + <span class="text-link"> + Find the work <Icon name="northeast" /> + </span> </a> - <a class="art-card mint-card" href="/work/#infinity-atelier"> - <div class="card-art"> - <span class="card-tag">ILLUSTRATED LOOPS</span> - <MotionArt - name="paints-the-world" - alt="WizardLoop's wizard paints a ribbon of light in the air" - /> - <span class="card-index" aria-hidden="true"> - 02 - </span> - </div> - <div class="card-caption"> - <div> - <h3>Paints the World</h3> - <p>A character with a mind of its own.</p> - </div> - <span class="round-arrow"> - <Icon name="northeast" /> - </span> - </div> + <a class="home-path" href="/projects/"> + <span class="home-path-icon"> + <Icon name="github" size={32} /> + </span> + <span class="eyebrow">THE CODE SIDE</span> + <h3> + Ideas with + <br /> + source code. + </h3> + <p>Explore my public repositories, tools and experiments on GitHub.</p> + <span class="text-link"> + Explore the builds <Icon name="northeast" /> + </span> </a> - <a class="art-card peach-card" href="/projects/"> - <div class="card-art"> - <span class="card-tag">CODE + CURIOSITY</span> - <MotionArt - name="arcane-coder" - alt="WizardLoop's wizard beside a laptop and floating code symbols" - /> - <span class="card-index" aria-hidden="true"> - 03 - </span> - </div> - <div class="card-caption"> - <div> - <h3>A different kind of spell</h3> - <p>Useful tools. Open source. Real code.</p> - </div> - <span class="round-arrow"> - <Icon name="northeast" /> - </span> - </div> + <a class="home-path" href="/feedback/"> + <span class="home-path-icon"> + <Icon name="telegram" size={32} /> + </span> + <span class="eyebrow">THE HUMAN SIDE</span> + <h3> + It starts with + <br />a conversation. + </h3> + <p> + A question, a collaboration or a hello. Choose how to get in touch. + </p> + <span class="text-link"> + Let's talk <Icon name="northeast" /> + </span> </a> </div> </section> @@ -215,32 +192,6 @@ import projects from '../content/projects.json'; </a> </div> </section> - <section class="builds-section section wrap" aria-labelledby="builds-title"> - <div class="section-heading"> - <div> - <p class="eyebrow">03 / LESS HOCUS. MORE FOCUS.</p> - <h2 id="builds-title"> - Things I've <span class="marker-word">built.</span> - </h2> - </div> - <a class="text-link" href="/projects/"> - All builds <Icon name="northeast" /> - </a> - </div> - <div class="project-list"> - {projects.map((project, i) => ( - <a class="project-row" href={project.url}> - <span class="project-number">0{i + 1}</span> - <div> - <h3>{project.name}</h3> - <p>{project.description}</p> - </div> - <span class="project-category">{project.category}</span> - <Icon name="northeast" size={28} /> - </a> - ))} - </div> - </section> <section class="posts-teaser section wrap" aria-labelledby="posts-title"> <div class="post-comic"> <span class="comic-label">A NOTE FROM THE STUDIO</span> @@ -255,7 +206,7 @@ import projects from '../content/projects.json'; </span> </div> <div> - <p class="eyebrow">04 / NOTES, LOOPS & LITTLE DISCOVERIES</p> + <p class="eyebrow">03 / NOTES, LOOPS & LITTLE DISCOVERIES</p> <h2 id="posts-title"> The making. <br /> @@ -265,11 +216,11 @@ import projects from '../content/projects.json'; </h2> <p> Animation drops, works in progress and things worth sharing. Find them - on my Telegram channel, with original posts close at hand. + on my Telegram channel, in their original format. </p> <div class="inline-actions"> - <a class="button" href="/posts/"> - Read the posts <Icon name="arrow" /> + <a class="button" href={site.links.channelPreview}> + Read on Telegram <Icon name="arrow" /> </a> <a class="text-link" href={site.links.channel}> <Icon name="telegram" /> diff --git a/src/pages/posts.astro b/src/pages/posts.astro index 36762ac..e819d2b 100644 --- a/src/pages/posts.astro +++ b/src/pages/posts.astro @@ -1,116 +1,91 @@ --- import Base from '../layouts/Base.astro'; import Icon from '../components/Icon.astro'; -import posts from '../content/posts.json'; import { site } from '../config/site'; -const syncedAt = - 'lastSyncedAt' in posts ? String(posts.lastSyncedAt).slice(0, 10) : null; +import '../styles/destinations.css'; --- <Base title="Posts from the studio" - description="Follow original posts, animation drops and studio notes from WizardLoop's Telegram channel, with optional official Telegram embeds." + description="Read WizardLoop's posts on Telegram. Design, motion, creative experiments and notes from the making, in their original home." > - <section class="page-intro wrap"> - <p class="eyebrow">NOTES FROM THE STUDIO / @WIZARD_LOOP</p> - <h1> - Fresh from - <br /> - <span class="outlined-word">the loop.</span> - </h1> - <p> - Animation drops, experiments and a glimpse of the making. These links lead - to real channel posts. Load their official Telegram embeds when you want - to see more. - </p> - <a class="text-link" href={site.links.channel}> - <Icon name="telegram" /> - Join the channel <Icon name="northeast" size={18} /> - </a> - </section> - <section class="posts-shell wrap" data-telegram-feed> - <div class="embed-consent notice" hidden={posts.posts.length === 0}> - <div> - <h2>A small privacy pause.</h2> + <section class="destination-page destination-posts wrap"> + <header class="destination-heading"> + <p class="eyebrow">POSTS / NOTES FROM THE STUDIO</p> + <h1> + Fresh from + <br /> + <em>the loop.</em> + </h1> + <p> + Thoughts, visual experiments and things in the making. Find the posts on + my Telegram channel. + </p> + </header> + <section + class="destination-stage destination-dispatch" + aria-labelledby="posts-heading" + > + <div class="destination-copy"> + <span class="destination-index">A DIRECT LINE TO THE STUDIO</span> + <h2 id="posts-heading"> + Read it where + <br /> + it happens. + </h2> <p> - Loading posts connects you to Telegram, which can set cookies and - receive your connection information. Some animated stickers and custom - emoji may only play completely in Telegram. - </p> - <p class="small-text"> - The site's motion button unloads these embeds because it cannot - control animation inside Telegram's frame. + Open the public feed to explore the original posts and media, or join + the channel to follow along in Telegram. </p> + <div class="destination-actions"> + <a class="button" href={site.links.channelPreview}> + Read the posts <Icon name="northeast" size={19} /> + </a> + <a class="text-link" href={site.links.channel}> + <Icon name="telegram" size={19} /> Join the channel + </a> + </div> + <p class="destination-address">Opens the public Telegram feed.</p> </div> - <div class="consent-actions"> - <button class="button" data-telegram-load> - <Icon name="telegram" /> - Load Telegram posts - </button> - <button class="button secondary-button" data-telegram-reset hidden> - Unload Telegram posts - </button> - <button class="text-button" data-telegram-retry> - Retry embeds - </button> - <a class="text-link" href="/privacy/"> - Privacy details - </a> + <div class="destination-art dispatch-drawing" aria-hidden="true"> + <svg viewBox="0 0 460 400" fill="none" focusable="false"> + <circle class="drawing-violet" cx="235" cy="202" r="127" /> + <path + class="drawing-stroke drawing-dotted" + d="M87 258c-60-93 33-181 99-125 57 49-66 64-96 139-19 48 38 96 102 63" + /> + <path + class="drawing-shadow" + d="m124 186 237-91-71 226-67-76-47 35 11-66z" + /> + <path + class="drawing-paper drawing-stroke" + d="m114 175 237-91-71 226-67-76-47 35 11-66z" + /> + <path + class="drawing-peach drawing-stroke" + d="m177 203-11 66 47-35L351 84z" + /> + <path class="drawing-stroke" d="m213 234 138-150" /> + <path + class="drawing-yellow drawing-stroke" + d="m82 73 9 23 24 5-24 7-9 23-8-23-24-7 24-5z" + /> + <path + class="drawing-stroke" + d="m327 317 17 6m-26 11 12 13m-28-7 4 17m64-200 20-2m-17 20 17 6" + /> + <circle class="drawing-ink" cx="265" cy="52" r="4" /> + <circle class="drawing-ink" cx="385" cy="271" r="4" /> + </svg> + <span class="destination-handnote">See you in the loop.</span> </div> - <p class="embed-status" role="status" data-telegram-status> - Telegram content is off. You can still open every original post. - </p> - </div> - <p class="feed-source"> - {posts.status === 'protected' - ? 'This channel is currently protected. Posts are not republished here.' - : posts.status === 'incremental' - ? `Incremental channel snapshot updated ${syncedAt || posts.verifiedAt}. Earlier channel history may be incomplete.` - : `Verified post links from ${posts.verifiedAt}. Automatic synchronization is not connected yet.`}{' '} - <a href={site.links.channelPreview}> - View the latest posts in Telegram <Icon name="northeast" size={14} /> + </section> + <div class="destination-after"> + <p>Keep the conversation going.</p> + <a class="text-link" href={site.links.community}> + Join the community <Icon name="arrow" size={19} /> </a> - </p> - <div class="post-list"> - {posts.posts.map((post) => ( - <article class="telegram-card"> - <div class="post-heading"> - <span class="post-avatar"> - <Icon name="telegram" size={22} /> - </span> - <div> - <h2 dir="auto">{post.title}</h2> - <span> - {post.kind} / Telegram post #{post.id} - </span> - </div> - <a - class="icon-link" - href={post.url} - aria-label={`Open ${post.title} in Telegram`} - > - <Icon name="northeast" /> - </a> - </div> - <div - class="telegram-slot" - data-telegram-slot={`wizard_loop/${post.id}`} - ></div> - <p dir="auto">{post.note}</p> - <a class="text-link" href={post.url}> - Open original post <Icon name="northeast" size={17} /> - </a> - </article> - ))} </div> - <button class="button secondary-button" data-posts-more hidden> - More from the loop <Icon name="arrow" /> - </button> - <noscript> - <p class="notice"> - JavaScript is disabled. The original Telegram links above remain - available. No third-party embeds have been loaded. - </p> - </noscript> </section> </Base> diff --git a/src/pages/privacy.astro b/src/pages/privacy.astro index 63cf184..5e6a239 100644 --- a/src/pages/privacy.astro +++ b/src/pages/privacy.astro @@ -4,154 +4,86 @@ import Base from '../layouts/Base.astro'; <Base title="Privacy" - description="How WizardLoop handles feedback, local preferences, optional Telegram embeds and information processed by hosting and security providers." + description="How WizardLoop handles local preferences, GitHub Pages hosting and direct conversations by Telegram or email." > <section class="wrap prose-page"> <header class="page-intro"> - <p class="eyebrow">THE SMALL PRINT</p> + <p class="eyebrow">YOUR BROWSER, YOUR CHOICE</p> <h1> Your privacy, <br /> in plain language. </h1> <p> - WizardLoop is my personal creative website. This notice explains what - happens when you visit, load optional content or get in touch. - </p> - <p> - Reviewed <time datetime="2026-10-27">27 October 2026</time>. + Updated <time datetime="2026-10-28">28 October 2026</time>. </p> </header> <div class="prose"> - <h2>Visiting the website</h2> + <h2>A small, static website</h2> <p> - The site is hosted on GitHub Pages. I do not run advertising, visitor - analytics or a marketing mailing list here. Hosting providers still - process connection information, such as an IP address and browser - details, to deliver and protect the website. GitHub describes its - processing in the{' '} - <a href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement"> - GitHub Privacy Statement - </a> - . + This is WizardLoop's personal creative website, hosted on GitHub Pages. + The site does not run advertising, visitor analytics, contact forms or + embedded social feeds. Fonts, illustrations and profile artwork are + served with the site. No profile or post synchronization runs in the + background. </p> <p> - Your browser can remember your theme, motion and optional - Telegram-content preferences in local storage. These preferences are not - sent to a visitor analytics service. See the{' '} - <a href="/cookies/">Cookie & Storage Policy</a> for controls. - </p> - - <h2>Feedback and direct contact</h2> - <p> - When the feedback service is enabled, the form asks for your name and - message. Your email is optional. I use those details to read your - feedback and, when possible, respond to your request. You do not need to - submit feedback to browse this website. - </p> - <p> - The form sends your information through a separate Cloudflare service to - a private Telegram conversation with me. The delivered message includes - your submitted details, the source page and a receipt time. It does not - include your IP address. The service does not maintain a separate - database of feedback messages or log their contents. - </p> - <p> - Cloudflare Turnstile and rate limiting help prevent automated abuse. - Cloudflare processes technical connection information for those checks. - The rate limiter uses a daily-changing, secret-derived identifier based - on the connection IP; this identifier is not sent to Telegram. Turnstile - is used only for feedback when configured, with an explanation before it - loads. Read{' '} - <a href="https://www.cloudflare.com/privacypolicy/"> - Cloudflare's Privacy Policy + GitHub processes connection information to deliver and protect the + website. Hosting can involve technical records such as IP addresses and + browser details. Read the{' '} + <a href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement"> + GitHub Privacy Statement </a>{' '} - for its provider-level processing. + for its practices. </p> - <div class="notice"> - <p> - Availability is shown on the feedback page. An unconfigured form does - not send messages. A local mock explicitly states that nothing was - sent to Telegram. A live delivery is only reported as successful after - the service receives a success response. - </p> - </div> + <h2>Your appearance preferences</h2> <p> - If you choose email or Telegram instead, the provider you use also - processes that conversation. Avoid sending information that is - unnecessary for your request. - </p> - - <h2>Optional Telegram content</h2> - <p> - Telegram post embeds are not loaded until you choose to enable them. - Loading an embed connects your browser to Telegram, which can receive - connection information and use its own storage or cookies. Opening a - Telegram link also takes you to its service. See the{' '} - <a href="https://telegram.org/privacy">Telegram Privacy Policy</a>. - </p> - <p> - You can decline embeds and use the original post links. You can also - withdraw the website's stored Telegram preference from the{' '} - <a href="/posts/">Posts page</a>. This stops further embedding here; it - cannot undo data already received by Telegram or clear cookies owned by - Telegram. - </p> - <p> - <button class="button" type="button" data-privacy-open> - Manage external content - </button> - </p> - - <h2>Keeping and deleting information</h2> - <p> - Your browser preferences remain until you change or clear them. Feedback - sent to me remains in the private Telegram conversation until I delete - it. There is currently no automatic deletion schedule. I review requests - for deletion and keep information only where it remains necessary to - handle the conversation or an applicable legal obligation. + Your browser can remember your light or dark theme and whether you + paused motion. These choices stay in local storage and are not sent to + an analytics service. You can change them in the header or clear this + website's data in your browser. See{' '} + <a href="/cookies/">Cookies & storage</a> for details. </p> + <h2>Links and direct conversations</h2> <p> - Provider security records, backups and service retention are governed by - the providers' own policies. Information may be processed in countries - other than your own. I cannot promise immediate removal from a - provider's independent records or backups. + Telegram posts and GitHub repositories open on their original websites + when you follow a link. This website does not load their widgets. Those + services have their own privacy practices, including the{' '} + <a href="https://telegram.org/privacy">Telegram Privacy Policy</a> and + GitHub's policy linked above. </p> - - <h2>Your choices and rights</h2> <p> - You can browse without submitting feedback, decline external embeds, - remove browser preferences and contact me about information you have - provided. Where applicable data-protection law grants rights of access, - correction, deletion, restriction, objection or portability, you can - make a request using the contact details below. I may need limited - information to identify the relevant message and confirm that the - request is yours. + If you contact me by Telegram or email, I receive the information you + choose to send and the account details your provider shares with the + conversation. I use that information to understand your request and + reply. There is no website submission service, CAPTCHA or separate + feedback database. Your messaging or email provider also processes the + conversation. </p> + <h2>Keeping and deleting messages</h2> <p> - The purpose of feedback processing is to handle your requested - communication; security processing protects the service from abuse. - Optional embeds load based on your choice. Any legal basis and - obligations depend on the applicable circumstances. This notice does not - claim an exemption from Israeli or European privacy law or certify legal - compliance. For general information, the European Commission explains{' '} - <a href="https://commission.europa.eu/law/law-topic/data-protection/information-business-and-organisations/dealing-requests-individuals_en"> - requests to exercise data-protection rights - </a> - . You may also contact the competent data-protection authority. + Direct correspondence remains in the relevant mailbox or conversation + until deleted. There is no automatic deletion schedule. I review + deletion requests and retain information only where it remains needed + for the conversation or an applicable legal obligation. Provider records + and backups follow the provider's own policies; immediate deletion from + independent provider systems cannot be promised. </p> - - <h2>Contact WizardLoop</h2> + <h2>Your choices</h2> <p> - For a privacy question or deletion request, email{' '} - <a href="mailto:wizard@wizardloop.com">wizard@wizardloop.com</a> or{' '} - <a href="https://t.me/wizardloop">contact me on Telegram</a>. Include - enough context to locate your message, without sending unrelated - personal information. + You can browse without contacting me, clear local preferences, and ask + about information you have sent. Where applicable law grants access, + correction, deletion or other data-protection rights, contact me to make + a request. Include enough context to locate the conversation without + sending unrelated personal information. You may also contact the + relevant data-protection authority. </p> <p> - This notice will be updated if the website's services or data practices - change. + For privacy questions, use <a href="/feedback/">the contact page</a>, + email <a href="mailto:wizard@wizardloop.com">wizard@wizardloop.com</a>{' '} + or <a href="https://t.me/wizardloop">message me on Telegram</a>. This + notice describes the website's current behavior; it is not a + certification of legal compliance. </p> </div> </section> diff --git a/src/pages/projects.astro b/src/pages/projects.astro index f976dd2..cddbf0c 100644 --- a/src/pages/projects.astro +++ b/src/pages/projects.astro @@ -1,59 +1,101 @@ --- import Base from '../layouts/Base.astro'; import Icon from '../components/Icon.astro'; -import MotionArt from '../components/MotionArt.astro'; -import projects from '../content/projects.json'; import { site } from '../config/site'; +import '../styles/destinations.css'; --- <Base title="Projects & builds" - description="Explore WizardLoop's verified open-source projects, including MagicLottie, Shabbat and CreationDate." + description="Explore WizardLoop's public GitHub repositories. Software, Telegram bots and open-source experiments from an independent creative developer." > - <section class="page-intro wrap"> - <p class="eyebrow">CODE IS ANOTHER CREATIVE MEDIUM</p> - <h1> - Curiosity. - <br /> - <span class="outlined-word">With a source code.</span> - </h1> - <p> - Open-source tools, Telegram bots and small software experiments. Built by - WizardLoop, shared on GitHub. - </p> - </section> - <section class="project-gallery wrap" aria-label="Open-source projects"> - {projects.map((project, i) => ( - <article class={`project-feature ${project.color}-card`}> - <div class="project-feature-art"> - <MotionArt - name={project.art} - alt={`WizardLoop illustration accompanying ${project.name}`} - /> - <span class="art-source"> - Original WizardLoop illustration. - <br /> - Not a product screenshot. - </span> - </div> - <div class="project-feature-copy"> - <p class="eyebrow"> - 0{i + 1} / {project.category} - </p> - <h2>{project.name}</h2> - <p>{project.description}</p> - <p class="small-text">{project.role}</p> - <a class="button" href={project.url}> - <Icon name="github" /> - View the repository <Icon name="northeast" size={18} /> + <section class="destination-page destination-projects wrap"> + <header class="destination-heading"> + <p class="eyebrow">PROJECTS / SOFTWARE & OPEN SOURCE</p> + <h1> + Curiosity. + <br /> + <em>With source code.</em> + </h1> + <p> + Code is another creative medium. Find my software, Telegram bots and + experiments on GitHub. + </p> + </header> + <section + class="destination-stage destination-repositories" + aria-labelledby="repositories-heading" + > + <div class="destination-copy"> + <span class="destination-index">OPEN THE WORKBENCH</span> + <h2 id="repositories-heading"> + Follow the idea. + <br /> + Explore the code. + </h2> + <p> + Browse my public repositories to see what I'm building and explore the + source, documentation and activity of each project. + </p> + <div class="destination-actions"> + <a class="button" href={site.links.repositories}> + <Icon name="github" size={21} /> View all repositories + <Icon name="northeast" size={19} /> </a> </div> - </article> - ))} + <p class="destination-address">Opens the repository list on GitHub.</p> + </div> + <div class="destination-art code-drawing" aria-hidden="true"> + <svg viewBox="0 0 460 400" fill="none" focusable="false"> + <path + class="drawing-stroke drawing-dotted" + d="M61 191V87h129m131 223h66V180" + /> + <circle + class="drawing-peach drawing-stroke" + cx="61" + cy="200" + r="12" + /> + <circle + class="drawing-violet drawing-stroke" + cx="387" + cy="169" + r="12" + /> + <path class="drawing-shadow" d="M103 123h256v193H103z" /> + <path class="drawing-paper drawing-stroke" d="M91 111h256v193H91z" /> + <path class="drawing-stroke" d="M91 150h256" /> + <circle class="drawing-ink" cx="110" cy="131" r="3" /> + <circle class="drawing-ink" cx="123" cy="131" r="3" /> + <circle class="drawing-ink" cx="136" cy="131" r="3" /> + <path + class="drawing-accent-stroke drawing-code" + d="m172 190-35 33 35 33m94-66 35 33-35 33m-34-78-26 91" + /> + <path + class="drawing-yellow drawing-stroke" + d="m238 62 17-34 17 34 37 5-27 25 7 37-34-18-34 18 7-37-27-25z" + /> + <path + class="drawing-stroke" + d="m50 284 17-7m-12 27h18m258-238 15-11m-8 31 18-3" + /> + <path + class="drawing-accent-stroke" + d="M151 344c30 27 99 30 133 2m-13-9 15 8-8 15" + /> + <circle class="drawing-ink" cx="97" cy="53" r="4" /> + <circle class="drawing-ink" cx="376" cy="352" r="4" /> + </svg> + <span class="destination-handnote">An idea, made tangible.</span> + </div> + </section> + <div class="destination-after"> + <p>Have a question about something I'm building?</p> + <a class="text-link" href="/feedback/"> + Get in touch <Icon name="arrow" size={19} /> + </a> + </div> </section> - <div class="page-end wrap"> - <a class="text-link" href={site.links.github}> - Explore all public repositories <Icon name="northeast" /> - </a> - </div> </Base> diff --git a/src/pages/security.astro b/src/pages/security.astro index 3880fa0..89032ae 100644 --- a/src/pages/security.astro +++ b/src/pages/security.astro @@ -4,7 +4,7 @@ import Base from '../layouts/Base.astro'; <Base title="Security" - description="How WizardLoop separates its static website from feedback services, protects credentials and receives private vulnerability reports." + description="WizardLoop is a static GitHub Pages website with local assets, direct contact links and no public submission endpoint." > <section class="wrap prose-page"> <header class="page-intro"> @@ -15,78 +15,58 @@ import Base from '../layouts/Base.astro'; Clear boundaries. </h1> <p> - Security is part of maintaining this personal website. This page - describes the design and its practical limits. - </p> - <p> - Reviewed <time datetime="2026-10-27">27 October 2026</time>. + Updated <time datetime="2026-10-28">28 October 2026</time>. </p> </header> <div class="prose"> - <h2>The website and its services</h2> - <p> - The main website is a static site hosted on GitHub Pages. It does not - require visitor accounts or passwords. Fonts and core visual assets are - served with the site. Optional third-party content is separated from the - main reading experience. - </p> + <h2>Simple by design</h2> <p> - Feedback uses a separate Cloudflare Worker when configured. That service - validates form fields on the server, verifies Turnstile, checks the - exact request origin and challenge hostname, limits request size and - submission rate, and escapes content before sending it to Telegram. A - missing required security configuration makes the service unavailable. + This website is static HTML, CSS, JavaScript and local media, hosted on + GitHub Pages. Browsing does not require an account, a password, a bot or + a backend service. Contact links open Telegram or your email + application. No form submissions, CAPTCHA requests or profile + synchronization are processed here. </p> <p> - Bot credentials, secret keys and private destination identifiers belong - in server-side secret storage. They are not included in the public - website build. The local feedback mock does not send Telegram messages - and identifies its results as local tests. - </p> - - <h2>What these measures do not guarantee</h2> - <p> - No website can promise complete protection. Third-party services have - their own availability, security and data-handling rules. A feedback - request can fail, and a network timeout can leave delivery uncertain. - The form reports that uncertainty instead of claiming success. + Fonts and visual assets are served with the site. Repository checks + validate the build, local links, public metadata and basic secret + patterns. Credentials and private information must never be committed to + the public repository. </p> + <h2>Practical limits</h2> <p> - GitHub Pages does not provide arbitrary per-site HTTP response-header - configuration. Controls that require response headers cannot all be - enabled through site source alone. This page does not claim that a - configurable CSP, Permissions Policy or other CDN-level protection is - active on the main site. Additional controls would require a separately - reviewed hosting or proxy configuration. + Third-party websites you open have their own availability and security + practices. The website does not control messages after they enter an + email or Telegram conversation. A link opening successfully does not + confirm that a message has been delivered. </p> <p> - The separate feedback API sets its own restrictive response headers. - Those headers do not change the headers served by GitHub Pages. - Production deployment and infrastructure changes require a separate - review. + GitHub Pages does not expose arbitrary per-site response-header + configuration. This source sets a referrer policy, but does not claim a + custom HTTP Content Security Policy, Permissions Policy or other + proxy-level protection is active. No additional proxy service is + required by this version. </p> - <h2>Report a vulnerability privately</h2> <p> Email <a href="mailto:wizard@wizardloop.com">wizard@wizardloop.com</a>{' '} - with a concise description, the affected URL or component, and safe - steps to reproduce the issue. You can also{' '} + with the affected URL, a short description and safe reproduction steps. + You can also{' '} <a href="https://t.me/wizardloop">contact me on Telegram</a> to arrange a private report. </p> <p> - Please do not post secrets, personal data or exploit details in a public - issue. Use your own test data, avoid service disruption and stop if a - test exposes someone else's information. There is no promised - response-time guarantee or paid bounty program. + Please do not publish credentials, personal information or exploit + details in a public issue. Use your own test data and avoid disruption. + There is no promised response time or paid bounty program. </p> <p> - The source and security guidance are available in the{' '} + The{' '} <a href="https://github.com/WizardLoop/wizardloop.github.io"> - website repository - </a> - . Public code is available for review; access to source is not - permission to test third-party infrastructure. + website source + </a>{' '} + is available for review. Access to that source does not authorize + testing third-party infrastructure. </p> </div> </section> diff --git a/src/pages/work.astro b/src/pages/work.astro index 24148ce..d9f98f8 100644 --- a/src/pages/work.astro +++ b/src/pages/work.astro @@ -1,141 +1,118 @@ --- import Base from '../layouts/Base.astro'; -import MotionArt from '../components/MotionArt.astro'; import Icon from '../components/Icon.astro'; -import artworks from '../content/artworks.json'; -import flags from '../content/flags.json'; import { site } from '../config/site'; +import '../styles/destinations.css'; --- <Base title="Selected work" - description="Explore WizardLoop's original Infinity Atelier animation collection and animated country flags. Illustration, character design and motion, in a loop." + description="Discover WizardLoop's design, illustration and motion work on the public Telegram channel. A personal creative practice, shared in the loop." > - <section class="page-intro wrap"> - <p class="eyebrow">DESIGN / ILLUSTRATION / ANIMATION</p> - <h1> - A little strange. - <br /> - <span class="outlined-word">A lot of character.</span> - </h1> - <p> - Original WizardLoop assets, brought out of the workbench and into the - light. A collection of expressive worlds, all made to move. - </p> - </section> - <section class="collection section wrap" id="infinity-atelier"> - <div class="section-heading"> - <div> - <p class="eyebrow">01 / CHARACTER DESIGN & MOTION</p> - <h2> - Infinity Atelier<span class="accent-dot">.</span> + <section class="destination-page destination-work wrap"> + <header class="destination-heading"> + <p class="eyebrow">WORK / DESIGN, ILLUSTRATION & MOTION</p> + <h1> + A little strange. + <br /> + <em>A lot of character.</em> + </h1> + <p> + Expressive characters. Playful visuals. Unexpected loops. Explore the + work I share on Telegram. + </p> + </header> + <section + class="destination-stage destination-portfolio" + aria-labelledby="portfolio-heading" + > + <div class="destination-copy"> + <span class="destination-index">THE PORTFOLIO</span> + <h2 id="portfolio-heading"> + A window into + <br /> + the making. </h2> <p> - {artworks.length} animated studies. One unmistakably curious wizard. + My channel is the place to explore my visual work, from design and + illustration to motion. Open the public feed, or join me there. </p> + <div class="destination-actions"> + <a class="button" href={site.links.channelPreview}> + View my work <Icon name="northeast" size={19} /> + </a> + <a class="text-link" href={site.links.channel}> + <Icon name="telegram" size={19} /> Join the channel + </a> + </div> + <p class="destination-address">Opens the public Telegram feed.</p> </div> - <span class="collection-stamp"> - ORIGINAL - <br /> - WIZARDLOOP - </span> - </div> - <div class="collection-grid"> - {artworks.map((art, i) => ( - <figure - class:list={['collection-item', { 'extra-artwork': i >= 9 }]} - hidden={i >= 9} - > - <div class="collection-art"> - <MotionArt name={art.slug} alt={art.alt} /> - <span class="small-number">{String(i + 1).padStart(2, '0')}</span> - </div> - <figcaption> - <h3>{art.title}</h3> - <span>Illustration & animation</span> - </figcaption> - </figure> - ))} - </div> - <noscript> - <style is:inline> - .extra-artwork[hidden] { - display: block !important; - } - </style> - </noscript> - <button - class="button secondary-button collection-more" - data-show-collection - hidden - > - Explore all {artworks.length} animations <Icon name="arrow" /> - </button> - <p class="collection-note"> - Original vector animations from the Infinity Atelier collection. Up to - three visible pieces play at once. Use the motion button in the header to - pause everything. Reduced-motion preferences show still frames. - </p> - </section> - <section class="collection section wrap" id="flags"> - <div class="section-heading"> - <div> - <p class="eyebrow">02 / SMALL DETAILS. A WORLD OF MOTION.</p> - <h2>Flags, with a little life.</h2> - <p>Selected animated flags from the WizardLoop country collection.</p> + <div class="destination-art portfolio-drawing" aria-hidden="true"> + <svg viewBox="0 0 460 400" fill="none" focusable="false"> + <path class="drawing-shadow" d="m111 79 261 21-20 265-261-21z" /> + <path + class="drawing-paper drawing-stroke" + d="m95 64 261 21-20 265-261-21z" + /> + <path + class="drawing-peach drawing-stroke" + d="m126 43 246 51-50 252-246-51z" + /> + <path class="drawing-paper drawing-stroke" d="M101 73h252v266H101z" /> + <path class="drawing-stroke" d="M101 117h252M120 95h50m143 0h20" /> + <circle + class="drawing-mint drawing-stroke" + cx="226" + cy="219" + r="76" + /> + <g stroke-linecap="round" stroke-linejoin="round"> + <path + d="M226 219c-19-20-30-33-45-30-18 3-22 18-15 34 8 18 24 22 39 9l42-34c17-14 32-10 39 4 9 17-1 37-18 37-15 0-28-7-42-20Z" + stroke="var(--ink)" + stroke-width="18" + /> + <path + d="M226 219c-19-20-30-33-45-30-18 3-22 18-15 34 8 18 24 22 39 9l42-34c17-14 32-10 39 4 9 17-1 37-18 37-15 0-28-7-42-20Z" + stroke="var(--accent)" + stroke-width="14" + /> + <path d="m211 227 30-24" stroke="var(--mint)" stroke-width="24" /> + <path d="m205 232 42-34" stroke="var(--ink)" stroke-width="18" /> + <path d="m205 232 42-34" stroke="var(--accent)" stroke-width="14" /> + <path + d="M173 198c9-7 17-1 24 5m70 29c10 0 16-8 15-17m-68 8 23-19" + stroke="#f9f6ff" + stroke-width="2.5" + /> + </g> + <path + class="drawing-accent drawing-stroke" + d="m226 153 4 11 11 4-11 4-4 11-4-11-11-4 11-4z" + /> + <path + class="drawing-yellow drawing-stroke" + d="m319 28 11 23 25-5-13 22 17 19-26-1-7 25-8-25-26 1 17-19-13-22 25 5z" + /> + <path + class="drawing-stroke" + d="m47 242 15-11m-7 29 17-5m-12 26 17-1m299-112 17-11m-11 30 18-4" + /> + <path + class="drawing-accent-stroke" + d="M130 369c56 17 114 18 176 3m-147 9c40 5 80 4 119 0" + /> + <circle class="drawing-ink" cx="62" cy="118" r="4" /> + <circle class="drawing-ink" cx="388" cy="285" r="4" /> + </svg> + <span class="destination-handnote">Made with curiosity.</span> </div> + </section> + <div class="destination-after"> + <p>Something here sparks an idea?</p> + <a class="text-link" href="/feedback/"> + Let's talk <Icon name="arrow" size={19} /> + </a> </div> - <div class="flags-grid"> - {flags.map((flag) => ( - <figure> - <MotionArt name={flag.slug} alt={flag.alt} /> - <figcaption>{flag.title}</figcaption> - </figure> - ))} - </div> - <p class="collection-note"> - Animation by WizardLoop. Flag source and rights information is preserved - in the <a href="/media/licenses/FLAGS-NOTICES.txt">asset notices</a>. The - source-code MIT license does not cover branding and media. - </p> - </section> - <section class="contact-cta wrap"> - <p class="eyebrow">EXPRESSIVE CHARACTERS. UNEXPECTED LOOPS.</p> - <h2> - Something on - <br /> - your <span>mind?</span> - </h2> - <a class="button" href={site.links.telegram}> - <Icon name="telegram" /> - Let's talk about it <Icon name="arrow" /> - </a> </section> - <script> - const extras = document.querySelectorAll<HTMLElement>('.extra-artwork'); - const show = document.querySelector<HTMLButtonElement>( - '[data-show-collection]', - ); - if (show) { - show.addEventListener('click', () => { - extras.forEach((el) => { - el.hidden = false; - }); - show.hidden = true; - const firstHeading = extras[0]?.querySelector<HTMLElement>('h3'); - if (firstHeading) { - firstHeading.tabIndex = -1; - firstHeading.focus({ preventScroll: true }); - } - extras[0]?.scrollIntoView({ - behavior: - document.documentElement.dataset.motion === 'paused' - ? 'instant' - : 'smooth', - block: 'start', - }); - }); - show.hidden = false; - } - </script> </Base> diff --git a/src/scripts/feedback.ts b/src/scripts/feedback.ts deleted file mode 100644 index 527318d..0000000 --- a/src/scripts/feedback.ts +++ /dev/null @@ -1,244 +0,0 @@ -export {}; -interface TurnstileAPI { - render: (target: string, options: Record<string, unknown>) => string; - reset: (id?: string) => void; -} -declare global { - interface Window { - turnstile?: TurnstileAPI; - } -} -const form = document.querySelector<HTMLFormElement>('[data-feedback-form]')!; -const controls = form.querySelector<HTMLFieldSetElement>( - '[data-feedback-controls]', -)!; -const submit = form.querySelector<HTMLButtonElement>('[data-feedback-submit]')!; -const verificationButton = form.querySelector<HTMLButtonElement>( - '[data-verify-start]', -)!; -const verificationState = form.querySelector<HTMLElement>( - '[data-verification-state]', -)!; -const status = form.querySelector<HTMLElement>('[data-feedback-status]')!; -const local = ['127.0.0.1', 'localhost'].includes(location.hostname); -const endpoint = - form.dataset.endpoint || (local ? 'http://127.0.0.1:8787/feedback' : ''); -const sitekey = form.dataset.sitekey || ''; -const mock = local && !form.dataset.endpoint; -let ready = mock; -try { - const target = new URL(endpoint); - const localTarget = - local && - ['127.0.0.1', 'localhost'].includes(target.hostname) && - target.protocol === 'http:'; - ready ||= - (target.protocol === 'https:' || localTarget) && - !target.username && - !target.password && - !target.hash && - !target.search && - /^[A-Za-z0-9_-]{1,200}$/.test(sitekey); -} catch { - /* The static disabled form remains safe when configuration is invalid. */ -} -let token = '', - widget: string | undefined, - busy = false; -function setStatus(message: string, alert = false) { - status.hidden = false; - status.setAttribute('role', alert ? 'alert' : 'status'); - status.textContent = message; -} -if (mock) { - form.querySelector<HTMLElement>('[data-local-demo]')!.hidden = false; - form.querySelector<HTMLElement>('[data-captcha-description]')!.textContent = - 'Explicit local verification for testing only. This does not run a real CAPTCHA or send a message.'; - verificationButton.textContent = 'Use local mock verification'; -} else if (!ready) { - form.querySelector<HTMLElement>('[data-feedback-unavailable]')!.hidden = - false; - submit.disabled = true; - verificationButton.disabled = true; -} -verificationButton.addEventListener('click', async () => { - if (!ready) return; - if (mock) { - token = 'local-mock-token'; - verificationState.textContent = - 'Local mock verification ready. Nothing will be sent.'; - return; - } - verificationButton.disabled = true; - verificationState.textContent = 'Loading secure verification...'; - try { - if (widget !== undefined && window.turnstile) { - window.turnstile.reset(widget); - verificationState.textContent = - 'Complete the Cloudflare verification below.'; - return; - } - if (!window.turnstile) - await new Promise<void>((resolve, reject) => { - const script = document.createElement('script'); - script.src = - 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; - script.async = true; - const cleanup = () => { - window.clearTimeout(timeout); - script.onload = null; - script.onerror = null; - }; - const fail = (message: string) => { - cleanup(); - script.remove(); - reject(new Error(message)); - }; - const timeout = window.setTimeout( - () => fail('Verification timeout'), - 12000, - ); - script.onload = () => { - cleanup(); - resolve(); - }; - script.onerror = () => fail('Verification unavailable'); - document.head.append(script); - }); - widget = window.turnstile!.render('#turnstile-container', { - sitekey, - action: 'feedback', - theme: document.documentElement.dataset.theme, - 'refresh-expired': 'auto', - callback: (value: string) => { - token = value; - verificationState.textContent = - 'Verification complete. You can send your feedback.'; - }, - 'expired-callback': () => { - token = ''; - verificationState.textContent = - 'Verification expired. Please verify again.'; - }, - 'error-callback': () => { - token = ''; - verificationState.textContent = - 'Verification could not complete. Please retry or contact me on Telegram.'; - verificationButton.disabled = false; - }, - }); - verificationState.textContent = - 'Complete the Cloudflare verification below.'; - } catch { - verificationState.textContent = - 'Verification could not load. Retry or use the Telegram or email link.'; - verificationButton.disabled = false; - } -}); -form.addEventListener('submit', async (event) => { - event.preventDefault(); - if (busy || !ready) return; - const fields = { - name: form.elements.namedItem('name') as HTMLInputElement, - message: form.elements.namedItem('message') as HTMLTextAreaElement, - email: form.elements.namedItem('email') as HTMLInputElement, - }; - let firstInvalid: HTMLInputElement | HTMLTextAreaElement | null = null; - for (const [name, field] of Object.entries(fields)) { - const value = field.value.trim(); - const message = - name !== 'email' && !value - ? `Please enter ${name === 'name' ? 'your name' : 'a message'}.` - : name === 'email' && value && !field.validity.valid - ? 'Please enter a valid email address, or leave this field blank.' - : value.length > field.maxLength - ? `Please keep this field within ${field.maxLength} characters.` - : ''; - field.setAttribute('aria-invalid', String(Boolean(message))); - form.querySelector(`#${name}-error`)!.textContent = message; - if (message && !firstInvalid) firstInvalid = field; - } - if (firstInvalid) { - firstInvalid.focus(); - setStatus('Please review the highlighted fields.', true); - return; - } - if (!token) { - setStatus('Please complete verification before sending.', true); - verificationButton.focus(); - return; - } - busy = true; - submit.disabled = true; - submit.textContent = mock ? 'Testing locally...' : 'Sending...'; - form.setAttribute('aria-busy', 'true'); - try { - const response = await fetch(endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'omit', - body: JSON.stringify({ - name: fields.name.value.trim(), - message: fields.message.value.trim(), - email: fields.email.value.trim(), - website: (form.elements.namedItem('website') as HTMLInputElement).value, - turnstileToken: token, - sourcePath: location.pathname, - }), - signal: AbortSignal.timeout(20000), - }); - const data = await response.json(); - if (!response.ok || !data.ok) { - if (data.mayHaveBeenDelivered || data.code === 'delivery_unconfirmed') - throw new Error( - 'Delivery could not be confirmed. Please wait before retrying with new verification. Your message is still here.', - ); - if (response.status === 429) - throw new Error( - 'Please wait a minute before trying again. Your message is still here.', - ); - if (data.code === 'captcha') - throw new Error( - 'Verification could not be confirmed. Please verify again.', - ); - throw new Error( - 'Your message could not be delivered. Please try again, or use Telegram.', - ); - } - setStatus( - data.mock - ? 'Local test complete. No message was sent to Telegram.' - : 'Thank you. Your feedback was sent successfully.', - ); - form.reset(); - } catch (error) { - setStatus( - error instanceof TypeError || - (error instanceof Error && error.name === 'TimeoutError') - ? mock - ? 'The local mock is not running. Start npm run dev:feedback, then retry. No message was sent.' - : 'The connection ended before delivery could be confirmed. Your message is still here. Wait before retrying or contact me directly.' - : error instanceof Error - ? error.message - : 'Something went wrong. Please try again.', - true, - ); - } finally { - busy = false; - token = ''; - submit.disabled = false; - submit.textContent = 'Send feedback'; - form.removeAttribute('aria-busy'); - if (widget !== undefined) window.turnstile?.reset(widget); - verificationState.textContent = mock - ? 'Use the local verification control before another test.' - : 'Please verify again before sending another message.'; - } -}); -// Enable only after every handler is installed. The server-rendered form stays -// disabled if JavaScript is blocked, fails to load, or configuration is absent. -if (ready) { - controls.disabled = false; - submit.disabled = false; - verificationButton.disabled = false; -} diff --git a/src/scripts/posts.ts b/src/scripts/posts.ts deleted file mode 100644 index 7c97407..0000000 --- a/src/scripts/posts.ts +++ /dev/null @@ -1,125 +0,0 @@ -export {}; -const feed = document.querySelector<HTMLElement>('[data-telegram-feed]'); -const load = document.querySelector<HTMLButtonElement>('[data-telegram-load]'); -const reset = document.querySelector<HTMLButtonElement>( - '[data-telegram-reset]', -); -const status = document.querySelector<HTMLElement>('[data-telegram-status]'); -const slots = [ - ...document.querySelectorAll<HTMLElement>('[data-telegram-slot]'), -]; -const consentInput = - document.querySelector<HTMLInputElement>('#telegram-consent'); -const cards = [...document.querySelectorAll<HTMLElement>('.telegram-card')]; -const more = document.querySelector<HTMLButtonElement>('[data-posts-more]'); -let visibleCount = 6; -cards.forEach((card, index) => { - card.hidden = index >= visibleCount; -}); -if (more) more.hidden = cards.length <= visibleCount; -let consent = false; -try { - consent = localStorage.getItem('wizardloop-telegram-consent') === 'yes'; -} catch {} -let generation = 0; -const unload = () => { - generation++; - slots.forEach((slot) => { - slot.replaceChildren(); - slot.dataset.loaded = 'false'; - }); -}; -function render(resetExisting = true) { - if (resetExisting) unload(); - const paused = document.documentElement.dataset.motion === 'paused'; - if (status) - status.textContent = !consent - ? 'Telegram content is off. You can still open every original post.' - : paused - ? 'Telegram embeds are unloaded while motion is paused. Open a post in Telegram or resume motion to load it here.' - : 'Loading original Telegram posts. Their media and formatting are provided by Telegram.'; - if (load) load.hidden = consent; - if (reset) reset.hidden = !consent; - if (!consent || paused) return; - const current = generation; - slots.forEach((slot) => { - if (slot.closest('[hidden]')) return; - if (slot.dataset.loaded === 'true' || slot.dataset.loaded === 'loading') - return; - const id = slot.dataset.telegramSlot; - if (!id || !/^wizard_loop\/\d+$/i.test(id)) return; - const script = document.createElement('script'); - script.async = true; - script.src = 'https://telegram.org/js/telegram-widget.js?22'; - script.dataset.telegramPost = id; - script.dataset.width = '100%'; - script.dataset.userpic = 'true'; - if (document.documentElement.dataset.theme === 'dark') - script.dataset.dark = '1'; - script.addEventListener('error', () => { - if (generation === current) { - slot.dataset.loaded = 'failed'; - slot.textContent = - 'Telegram could not load. Use the original post link below.'; - } - }); - slot.dataset.loaded = 'loading'; - slot.append(script); - const observer = new MutationObserver(() => { - const iframe = slot.querySelector('iframe'); - if (iframe) { - iframe.title = `Original Telegram post ${id.split('/')[1]}`; - iframe.loading = 'lazy'; - slot.dataset.loaded = 'true'; - observer.disconnect(); - } - }); - observer.observe(slot, { childList: true }); - window.setTimeout(() => { - observer.disconnect(); - if (generation === current && !slot.querySelector('iframe')) { - slot.textContent = - 'The embed is taking longer than expected. Open the original post or retry.'; - } - }, 15000); - }); -} -function setConsent(value: boolean) { - consent = value; - try { - localStorage.setItem('wizardloop-telegram-consent', value ? 'yes' : 'no'); - } catch {} - if (consentInput) consentInput.checked = value; - render(); -} -load?.addEventListener('click', () => setConsent(true)); -reset?.addEventListener('click', () => setConsent(false)); -document - .querySelector('[data-telegram-retry]') - ?.addEventListener('click', () => render()); - -more?.addEventListener('click', () => { - const firstNew = cards[visibleCount]; - visibleCount += 6; - cards.forEach((card, index) => { - card.hidden = index >= visibleCount; - }); - more.hidden = cards.length <= visibleCount; - render(false); - const heading = firstNew?.querySelector<HTMLElement>('h2'); - heading?.setAttribute('tabindex', '-1'); - heading?.focus({ preventScroll: true }); - firstNew?.scrollIntoView({ - behavior: - document.documentElement.dataset.motion === 'paused' - ? 'instant' - : 'smooth', - block: 'start', - }); -}); -document.addEventListener('wizardloop:consent', () => { - consent = consentInput?.checked || false; - render(); -}); -document.addEventListener('wizardloop:preferences', () => render()); -if (feed) render(); diff --git a/src/scripts/site.ts b/src/scripts/site.ts index c44be15..eb259a3 100644 --- a/src/scripts/site.ts +++ b/src/scripts/site.ts @@ -1,12 +1,5 @@ export {}; const root = document.documentElement; -const read = (key: string) => { - try { - return localStorage.getItem(key); - } catch { - return null; - } -}; const save = (key: string, value: string) => { try { localStorage.setItem(key, value); @@ -30,12 +23,6 @@ function updateControls() { motionButton?.setAttribute('aria-label', label); motionButton?.setAttribute('data-tooltip', label); motionButton?.setAttribute('aria-pressed', String(paused)); - document - .querySelectorAll<HTMLVideoElement>('video[data-profile-video]') - .forEach((video) => { - if (paused) video.pause(); - else video.play().catch(() => {}); - }); document.dispatchEvent(new CustomEvent('wizardloop:preferences')); } themeButton?.addEventListener('click', () => { @@ -56,6 +43,33 @@ motionMedia.addEventListener('change', () => { }); updateControls(); +const copyEmail = + document.querySelector<HTMLButtonElement>('[data-copy-email]'); +const copyStatus = document.querySelector<HTMLElement>('[data-copy-status]'); +if (copyEmail && copyStatus && navigator.clipboard?.writeText) { + let copying = false; + copyEmail.hidden = false; + copyEmail.addEventListener('click', async () => { + const address = copyEmail.dataset.copyEmail; + if (!address || copying) return; + copying = true; + copyEmail.setAttribute('aria-disabled', 'true'); + copyEmail.setAttribute('aria-busy', 'true'); + copyStatus.textContent = ''; + try { + await navigator.clipboard.writeText(address); + copyStatus.textContent = + 'Email address copied. Paste it into your message.'; + } catch { + copyStatus.textContent = `Copy unavailable. Select: ${address}`; + } finally { + copying = false; + copyEmail.removeAttribute('aria-disabled'); + copyEmail.removeAttribute('aria-busy'); + } + }); +} + const tooltipTriggers = [ ...document.querySelectorAll<HTMLElement>('[data-tooltip]'), ]; @@ -141,53 +155,14 @@ wireDialog( matchMedia('(min-width:901px)').addEventListener('change', (e) => { if (e.matches && menu?.open) menu.close(); }); -const privacyDialog = - document.querySelector<HTMLDialogElement>('#privacy-dialog'); -wireDialog( - privacyDialog, - document.querySelectorAll<HTMLElement>('[data-privacy-open]'), - document.querySelector('[data-privacy-close]'), -); -const consent = document.querySelector<HTMLInputElement>('#telegram-consent'); -if (consent) consent.checked = read('wizardloop-telegram-consent') === 'yes'; -document.querySelector('[data-privacy-save]')?.addEventListener('click', () => { - save('wizardloop-telegram-consent', consent?.checked ? 'yes' : 'no'); - document.dispatchEvent(new CustomEvent('wizardloop:consent')); - privacyDialog?.close(); -}); - -document - .querySelectorAll<HTMLVideoElement>('video[data-profile-video]') - .forEach((video) => { - video.addEventListener( - 'error', - () => { - video.pause(); - video.hidden = true; - const image = video.parentElement?.querySelector<HTMLImageElement>( - 'img[data-profile-image]', - ); - if (image) image.hidden = false; - }, - true, - ); - }); - document .querySelectorAll<HTMLImageElement>('img[data-profile-image]') .forEach((img) => { - const sources = [ - img.dataset.githubFallback, - '/media/profile/fallback.svg', - ].filter(Boolean) as string[]; - let next = 0; + let usedFallback = false; function fallback() { - if (next < sources.length) { - img.src = sources[next++]; - const label = document.querySelector('[data-profile-source]'); - if (label) - label.textContent = - next === 1 ? 'GitHub profile fallback' : 'Local WizardLoop artwork'; + if (!usedFallback) { + usedFallback = true; + img.src = '/media/profile/fallback.svg'; } } img.addEventListener('error', fallback); @@ -201,5 +176,3 @@ const hero = document.querySelector<HTMLElement>('[data-native-hero]'); if (hero) import('./hero').then(({ mountHero }) => mountHero(hero)); if (artNodes.length) import('./motion').then(({ mountMotion }) => mountMotion(artNodes)); -if (document.querySelector('[data-telegram-feed]')) import('./posts'); -if (document.querySelector('[data-feedback-form]')) import('./feedback'); diff --git a/src/styles/destinations.css b/src/styles/destinations.css new file mode 100644 index 0000000..d69cb3c --- /dev/null +++ b/src/styles/destinations.css @@ -0,0 +1,420 @@ +.destination-page { + padding-top: 160px; + padding-bottom: 92px; +} +.destination-heading { + margin-bottom: 54px; + max-width: 990px; +} +.destination-heading .eyebrow { + margin-bottom: 24px; +} +.destination-heading h1 { + font-size: clamp(48px, 6.7vw, 92px); + font-weight: 500; + line-height: 1.02; +} +.destination-heading h1 em { + color: var(--accent); + font-family: Journal, Georgia, serif; + font-weight: 500; + letter-spacing: -0.04em; +} +.destination-heading > p:last-child { + max-width: 590px; + margin-top: 28px; + font-size: 15px; + line-height: 1.85; +} +.destination-stage { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr); + align-items: center; + gap: 24px; + padding: clamp(28px, 4vw, 58px); + background: var(--panel); + border: 1.5px solid var(--ink); + box-shadow: 7px 7px 0 var(--ink); + position: relative; +} +.destination-portfolio { + border-radius: 4px 82px 4px 4px; +} +.destination-repositories { + background: var(--mint); +} +.destination-dispatch { + background: var(--peach); + border-radius: 4px 4px 82px 4px; +} +.destination-copy { + position: relative; + z-index: 1; +} +.destination-index { + display: block; + margin-bottom: 27px; + font: 500 10px/1.5 var(--body); + letter-spacing: 0.16em; +} +.destination-copy h2, +.destination-contact-intro h2 { + font-size: clamp(31px, 3.5vw, 48px); + font-weight: 500; + line-height: 1.1; + letter-spacing: -0.045em; +} +.destination-copy > p:not(.destination-address), +.destination-contact-intro > p { + max-width: 440px; + margin-top: 23px; + font-size: 14px; + line-height: 1.9; +} +.destination-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 22px 25px; + margin-top: 30px; +} +.destination-actions .button { + gap: 16px; + text-align: center; +} +.destination-address { + margin-top: 23px; + color: var(--muted); + font: 11px/1.7 var(--body); + letter-spacing: 0.035em; + overflow-wrap: anywhere; +} +.destination-art { + min-width: 0; + width: 100%; + max-width: 465px; + justify-self: center; + padding-bottom: 8px; + color: var(--ink); +} +.destination-art > svg, +.destination-contact-drawing > svg { + display: block; + width: 100%; + height: auto; + overflow: visible; +} +.drawing-stroke, +.drawing-accent-stroke { + stroke: var(--ink); + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} +.drawing-accent-stroke { + stroke: var(--accent); +} +.drawing-dotted { + stroke-dasharray: 3 8; +} +.drawing-code { + stroke-width: 7; +} +.drawing-paper { + fill: var(--paper); +} +.drawing-ink, +.drawing-shadow { + fill: var(--ink); +} +.drawing-violet { + fill: var(--violet); +} +.drawing-mint { + fill: var(--mint); +} +.drawing-peach { + fill: var(--peach); +} +.drawing-yellow { + fill: var(--yellow); +} +.drawing-accent { + fill: var(--accent); +} +.destination-handnote { + display: block; + margin-top: -1px; + color: var(--accent); + font: + italic 25px/1.2 Journal, + Georgia, + serif; + text-align: center; + transform: rotate(-3deg); +} +.destination-after { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px 25px; + margin-top: 37px; +} +.destination-after > p { + font-size: 13px; +} +.destination-contact-layout { + display: grid; + grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); + align-items: start; + gap: clamp(35px, 7vw, 110px); + padding-top: 40px; + border-top: 1px solid var(--line); +} +.destination-contact-intro { + padding-top: 4px; +} +.destination-contact-drawing { + max-width: 300px; + margin-top: 27px; + margin-left: -10px; +} +.destination-contact-drawing .destination-handnote { + font-size: 23px; +} +.destination-contact-links { + min-width: 0; +} +.destination-contact-link { + display: grid; + grid-template-columns: 58px minmax(0, 1fr) 24px; + align-items: center; + gap: 23px; + min-height: 124px; + padding: 24px 29px; + border: 1.5px solid var(--ink); + background: var(--panel); + box-shadow: 5px 5px 0 var(--ink); + margin-bottom: 22px; +} +.destination-contact-link:first-child { + background: var(--violet); +} +.destination-contact-icon { + display: grid; + place-items: center; + width: 58px; + height: 58px; + border: 1px solid var(--ink); + border-radius: 50%; + background: var(--paper); +} +.destination-contact-label { + display: grid; + gap: 6px; + min-width: 0; +} +.destination-contact-label > strong { + font: 500 clamp(22px, 2.5vw, 30px)/1.2 var(--display); + letter-spacing: -0.035em; +} +.destination-contact-label > span { + color: var(--muted); + font-size: 12px; + overflow-wrap: anywhere; +} +.destination-contact-link:hover { + transform: translate(-2px, -2px); + box-shadow: 7px 7px 0 var(--ink); +} +.destination-contact-link:hover .destination-link-arrow { + transform: translate(2px, -2px); +} +.destination-email-copy { + display: grid; + justify-items: start; + gap: 6px; +} +.destination-copy-button { + min-height: 44px; + max-width: 100%; + padding: 10px 16px; + border: 1px solid var(--ink); + border-radius: 3px; + background: var(--paper); + color: var(--ink); + font-size: 12px; + line-height: 1.5; + font-weight: 600; + text-align: left; + cursor: pointer; +} +.destination-copy-button:hover { + background: var(--violet); +} +.destination-copy-status { + min-height: 3em; + max-width: 460px; + margin: 0; + font-size: 12px; + line-height: 1.5; + overflow-wrap: anywhere; +} +.destination-contact-note { + margin-top: 36px; + margin-bottom: 13px; + max-width: 330px; + font-size: 13px; + line-height: 1.8; +} +.destination-contact-help { + margin-top: 18px; + font-size: 12px; + line-height: 1.8; + max-width: 460px; +} +.destination-small-link { + display: grid; + grid-template-columns: 22px minmax(0, 1fr) 20px; + align-items: center; + gap: 16px; + min-height: 57px; + padding: 14px 0; + border-bottom: 1px solid var(--line); + font-size: 13px; +} +.destination-small-link:hover { + color: var(--accent); + border-color: var(--accent); +} +@media (max-width: 900px) { + .destination-stage { + grid-template-columns: minmax(0, 1fr) minmax(0, 0.8fr); + gap: 8px; + padding: 35px; + } + .destination-contact-layout { + gap: 35px; + } + .destination-contact-link { + grid-template-columns: 42px minmax(0, 1fr) 20px; + gap: 17px; + padding: 22px 20px; + } + .destination-contact-icon { + width: 42px; + height: 42px; + } + .destination-contact-icon > svg { + width: 23px; + height: 23px; + } +} +@media (max-width: 700px) { + .destination-page { + padding-top: 121px; + padding-bottom: 65px; + } + .destination-heading { + margin-bottom: 35px; + } + .destination-heading .eyebrow { + margin-bottom: 19px; + font-size: 9px; + letter-spacing: 0.13em; + } + .destination-heading h1 { + font-size: clamp(44px, 9.4vw, 65px); + } + .destination-heading > p:last-child { + margin-top: 22px; + font-size: 13px; + } + .destination-stage { + grid-template-columns: minmax(0, 1fr); + gap: 19px; + padding: 28px 25px 27px; + box-shadow: 5px 5px 0 var(--ink); + } + .destination-portfolio { + border-top-right-radius: 57px; + } + .destination-dispatch { + border-bottom-right-radius: 57px; + } + .destination-index { + font-size: 9px; + margin-bottom: 20px; + } + .destination-copy h2, + .destination-contact-intro h2 { + font-size: clamp(30px, 7vw, 40px); + } + .destination-copy > p:not(.destination-address), + .destination-contact-intro > p { + font-size: 13px; + margin-top: 20px; + } + .destination-actions { + margin-top: 25px; + gap: 18px; + } + .destination-actions .button { + gap: 13px; + padding: 13px 18px; + font-size: 12px; + } + .destination-art { + max-width: 330px; + } + .destination-handnote { + font-size: 23px; + } + .destination-after { + margin-top: 29px; + } + .destination-contact-layout { + grid-template-columns: minmax(0, 1fr); + gap: 31px; + padding-top: 28px; + } + .destination-contact-drawing { + display: none; + } + .destination-contact-link { + min-height: 105px; + gap: 16px; + padding: 22px; + } + .destination-contact-note { + margin-top: 29px; + } +} +@media (max-width: 359px) { + .destination-heading h1 { + font-size: 42px; + } + .destination-stage { + padding-inline: 21px; + } + .destination-actions .button { + gap: 10px; + padding-inline: 12px; + } + .destination-repositories .button > svg:first-child { + display: none; + } + .destination-contact-link { + padding-inline: 17px; + gap: 12px; + grid-template-columns: 36px minmax(0, 1fr) 18px; + } + .destination-contact-icon { + width: 36px; + height: 36px; + } + .destination-contact-label > span { + font-size: 11px; + } +} diff --git a/src/styles/global.css b/src/styles/global.css index aaac3f5..eca5027 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -433,17 +433,24 @@ dialog::backdrop { max-height: calc(100dvh - 24px); padding: 22px 25px; overflow: auto; + overscroll-behavior: contain; + scroll-padding-block: 100px 24px; border-radius: 8px; } #mobile-nav[open] { animation: menu-in 0.2s ease-out; } .mobile-top { + position: sticky; + top: 0; + z-index: 2; + background: var(--paper); display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); - padding-bottom: 18px; + padding-block: 18px; + margin-top: -18px; font-size: 18px; font-family: var(--display); } @@ -463,6 +470,13 @@ dialog::backdrop { margin-left: auto; width: 21px; } +#mobile-nav nav a[aria-current='page'] { + background: var(--violet); + border-radius: 5px; + box-shadow: inset 3px 0 0 var(--accent); + padding-inline: 12px; + margin-inline: -12px; +} .menu-number { font: 10px monospace; color: var(--accent); @@ -481,45 +495,6 @@ dialog::backdrop { min-height: 44px; font-size: 13px; } -.privacy-dialog { - width: min(600px, calc(100% - 32px)); - padding: 30px; - box-shadow: var(--soft-shadow); -} -.dialog-heading { - display: flex; - justify-content: space-between; - align-items: start; - gap: 18px; -} -.dialog-heading h2 { - font-size: 32px; - margin: 0 0 18px; -} -.privacy-dialog p { - font-size: 13px; - margin-bottom: 20px; -} -.consent-option { - display: flex; - align-items: start; - gap: 15px; - margin: 25px 0; - font-size: 13px; - cursor: pointer; -} -.consent-option input { - width: 22px; - height: 22px; - accent-color: var(--accent); - flex-shrink: 0; -} -.privacy-dialog .text-link { - margin-top: 20px; -} -.privacy-dialog .button { - width: 100%; -} .hero { padding-top: 140px; } @@ -777,20 +752,6 @@ dialog::backdrop { .section-heading > .text-link { flex-shrink: 0; } -.art-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 27px; -} -.card-art { - position: relative; - padding: 28px 12px 15px; - overflow: hidden; - border: 1.5px solid var(--ink); - background: var(--card); - box-shadow: 4px 4px 0 var(--ink); - border-radius: 4px; -} .violet-card { --card: var(--violet); } @@ -800,58 +761,6 @@ dialog::backdrop { .peach-card { --card: var(--peach); } -.card-art .motion-art { - aspect-ratio: 1.02; -} -.card-tag { - position: absolute; - top: 16px; - left: 16px; - z-index: 2; - font: 8px var(--body); - letter-spacing: 0.13em; - background: var(--paper); - padding: 5px 9px; - border: 1px solid var(--ink); - transform: rotate(-3deg); -} -.card-index { - position: absolute; - bottom: 15px; - right: 15px; - font: 11px monospace; -} -.card-caption { - display: flex; - justify-content: space-between; - gap: 12px; - align-items: center; - padding-top: 23px; -} -.card-caption h3 { - font-size: 23px; -} -.card-caption p { - font-size: 11px; - margin-top: 8px; -} -.round-arrow { - display: grid; - place-items: center; - border: 1px solid var(--ink); - width: 38px; - height: 38px; - border-radius: 50%; - flex-shrink: 0; -} -.art-card:hover .round-arrow { - background: var(--ink); - color: var(--paper); - transform: rotate(12deg); -} -.art-card:hover .card-art { - box-shadow: 7px 7px 0 var(--ink); -} .about-teaser { display: grid; grid-template-columns: 1fr 1fr; @@ -906,41 +815,63 @@ dialog::backdrop { .builds-section { border-top: 1px solid var(--line); } -.project-list { - border-top: 1px solid var(--ink); -} -.project-row { +.home-paths { display: grid; - grid-template-columns: 55px 1fr 180px 30px; - align-items: center; - gap: 30px; - padding: 30px 0; - border-bottom: 1px solid var(--line); + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 22px; } -.project-number { - font: 11px monospace; - color: var(--accent); +.home-path { + display: flex; + flex-direction: column; + align-items: flex-start; + min-width: 0; + padding: clamp(24px, 3vw, 38px); + border: 1.5px solid var(--ink); + background: var(--violet); + text-decoration: none; + box-shadow: 5px 5px 0 var(--ink); + transition: + transform 180ms ease, + box-shadow 180ms ease; } -.project-row h3 { - font-size: 30px; - margin-bottom: 12px; +.home-path:nth-child(2) { + background: var(--mint); } -.project-row p { - font-size: 12px; - max-width: 600px; +.home-path:nth-child(3) { + background: var(--peach); } -.project-category { - font-size: 10px; - text-align: right; +.home-path-icon { + display: grid; + place-items: center; + width: 58px; + height: 58px; + margin-bottom: 32px; + border: 1.5px solid var(--ink); + border-radius: 50%; + background: var(--paper); +} +.home-path h3 { + font-size: clamp(28px, 3vw, 40px); + margin: 16px 0; +} +.home-path p:not(.eyebrow) { + margin: 0 0 30px; color: var(--muted); } -.project-row:hover { - background: var(--paper-2); - padding-inline: 15px; +.home-path .text-link { + margin-top: auto; } -.project-row:hover > svg { - transform: rotate(10deg); - color: var(--accent); +.home-path:hover { + transform: translate(-2px, -2px); + box-shadow: 7px 7px 0 var(--ink); +} +@media (max-width: 900px) { + .home-paths { + grid-template-columns: 1fr; + } + .home-path h3 { + font-size: 36px; + } } .posts-teaser { display: grid; @@ -1274,50 +1205,6 @@ dialog::backdrop { .practice-grid p { font-size: 14px; } -.project-gallery { - display: flex; - flex-direction: column; - gap: 35px; -} -.project-feature { - display: grid; - grid-template-columns: 1fr 1.2fr; - border: 1.5px solid var(--ink); - background: var(--card); - box-shadow: 5px 5px 0 var(--ink); -} -.project-feature-art { - padding: 20px 30px 15px; - display: flex; - flex-direction: column; - align-items: center; - border-right: 1px solid var(--ink); -} -.project-feature-art .motion-art { - max-width: 360px; -} -.project-feature-art .art-source { - text-align: center; -} -.project-feature-copy { - padding: 55px; - align-self: center; -} -.project-feature-copy .eyebrow { - margin-bottom: 20px; -} -.project-feature-copy h2 { - font-size: 48px; - margin-bottom: 25px; -} -.project-feature-copy > p:not(.eyebrow) { - font-size: 14px; - max-width: 430px; - margin-bottom: 20px; -} -.project-feature-copy .button { - margin-top: 10px; -} .community-hero { padding-top: 150px; display: grid; @@ -1375,115 +1262,6 @@ dialog::backdrop { .community-links > a:hover { transform: translateY(-4px); } -.collection { - padding-top: 10px; -} -.collection-stamp { - font: 700 12px/1.4 var(--display); - padding: 20px 13px; - border: 2px solid var(--accent); - border-radius: 50%; - color: var(--accent); - transform: rotate(12deg); - text-align: center; - flex-shrink: 0; -} -.collection-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 32px 25px; -} -.collection-art { - padding: 12px; - background: var(--violet); - border: 1px solid var(--ink); - position: relative; - border-radius: 5px; -} -.collection-item:nth-child(3n + 2) .collection-art { - background: var(--mint); -} -.collection-item:nth-child(3n + 3) .collection-art { - background: var(--peach); -} -.small-number { - position: absolute; - bottom: 13px; - right: 13px; - font: 10px monospace; -} -.collection-item figcaption { - padding-top: 18px; -} -.collection-item h3 { - font-size: 21px; -} -.collection-item figcaption > span { - display: block; - font-size: 10px; - color: var(--muted); - margin-top: 6px; -} -.collection-more { - margin-top: 40px; -} -.collection-note { - font-size: 12px; - max-width: 800px; - margin-top: 32px; -} -.collection-note a { - text-decoration: underline; -} -.flags-grid { - display: grid; - grid-template-columns: repeat(6, 1fr); - gap: 20px; -} -.flags-grid figure { - border: 1px solid var(--line); - padding: 13px; - background: var(--panel); -} -.flags-grid figcaption { - font-size: 11px; - text-align: center; - padding-bottom: 7px; -} -.posts-shell { - padding-bottom: 90px; - max-width: 1000px; -} -.embed-consent { - display: grid; - grid-template-columns: 1fr auto; - gap: 20px 30px; - margin-bottom: 22px; -} -.consent-actions { - display: flex; - flex-direction: column; - align-items: start; - gap: 10px; - justify-content: center; -} -.consent-actions .button { - font-size: 11px; - min-height: 48px; - padding-inline: 17px; - gap: 10px; -} -.consent-actions .text-link, -.consent-actions .text-button { - font-size: 11px; -} -.embed-status { - grid-column: 1/-1; - border-top: 1px solid var(--line); - padding-top: 14px; - font-size: 12px; - margin-bottom: 0 !important; -} .feed-source { font-size: 11px; margin: 22px 0 30px; @@ -1494,176 +1272,6 @@ dialog::backdrop { gap: 5px; text-decoration: underline; } -.post-list { - display: grid; - grid-template-columns: 1fr 1fr; - align-items: start; - gap: 25px; -} -.telegram-card { - padding: 26px; - border: 1px solid var(--line); - border-radius: 12px; - background: var(--panel); -} -.post-heading { - display: flex; - align-items: center; - gap: 12px; -} -.post-heading h2 { - font-size: 20px; - line-height: 1.2; - letter-spacing: -0.5px; -} -.post-heading > div > span { - font-size: 9px; - color: var(--muted); -} -.post-heading > a { - margin-left: auto; - flex-shrink: 0; - width: 36px; - height: 36px; -} -.post-avatar { - display: grid; - place-items: center; - border-radius: 50%; - background: var(--violet); - width: 42px; - height: 42px; - flex-shrink: 0; -} -.telegram-card > p { - font-size: 12px; - margin-block: 22px 15px; -} -.telegram-card .text-link { - font-size: 11px; -} -.telegram-slot:not(:empty) { - margin-top: 20px; - min-height: 100px; - font-size: 12px; -} -.telegram-slot iframe { - width: 100%; - max-width: 100%; - border: 0; -} -.feedback-grid { - display: grid; - grid-template-columns: 0.8fr 1.2fr; - gap: 80px; - padding-bottom: 90px; - align-items: start; -} -.feedback-aside .motion-art { - max-width: 320px; - margin-inline: auto; -} -.feedback-aside p { - font-size: 14px; - margin-block: 20px; -} -.feedback-form { - border: 1.5px solid var(--ink); - padding: 35px; - background: var(--panel); - box-shadow: 5px 5px 0 var(--ink); -} -.field { - margin-bottom: 22px; -} -.field label { - display: flex; - justify-content: space-between; - gap: 15px; - font-size: 13px; - font-weight: 650; - margin-bottom: 9px; -} -.field label span { - font-size: 10px; - font-weight: 400; - color: var(--muted); -} -.field input, -.field textarea { - width: 100%; - background: var(--paper); - border: 1px solid var(--line); - padding: 12px 14px; - color: var(--ink); - min-height: 48px; - font-size: 14px; -} -.field textarea { - resize: vertical; - min-height: 160px; - line-height: 1.6; -} -.field [aria-invalid='true'] { - border-color: #b02b32; -} -.field-error { - color: #b02b32; - font-size: 12px; - min-height: 0; - margin-top: 5px; -} -html[data-theme='dark'] .field-error { - color: #ffadad; -} -.field-hint { - font-size: 10px; - margin-top: 6px; -} -.feedback-form .button { - margin-top: 20px; - min-width: 190px; -} -.form-disclosure { - font-size: 11px; - margin-top: 20px; -} -.form-disclosure a { - text-decoration: underline; -} -.form-status { - font-size: 13px; - margin-top: 22px; - padding: 15px; - border: 1px solid var(--line); -} -.local-demo { - font-size: 11px; - background: var(--yellow); - color: var(--ink); - padding: 15px; - margin-bottom: 22px; - border: 1px solid var(--ink); -} -.captcha-info { - font-size: 11px; - margin-bottom: 12px; -} -.captcha-start { - font-size: 12px; -} -.verification-state { - font-size: 12px; - margin-top: 12px; -} -.honeypot { - position: absolute; - left: -10000px; - opacity: 0; - width: 1px; - height: 1px; - overflow: hidden; -} .not-found { padding-block: 130px 90px; text-align: center; @@ -1759,15 +1367,6 @@ html[data-theme='dark'] .field-error { .art-source { font-size: 7px; } - .card-caption h3 { - font-size: 20px; - } - .card-caption p { - font-size: 10px; - } - .art-grid { - gap: 22px; - } .about-teaser, .posts-teaser { gap: 50px; @@ -1778,19 +1377,9 @@ html[data-theme='dark'] .field-error { .footer-top { gap: 28px; } - .project-row { - grid-template-columns: 35px 1fr 130px 28px; - gap: 22px; - } .about-page-grid { gap: 50px; } - .project-feature-copy { - padding: 35px; - } - .project-feature-copy h2 { - font-size: 40px; - } .community-links > a { padding: 25px; gap: 18px; @@ -1802,12 +1391,6 @@ html[data-theme='dark'] .field-error { width: 48px; height: 48px; } - .feedback-grid { - gap: 45px; - } - .feedback-form { - padding: 28px; - } .cta-doodle { right: 4%; } @@ -1928,37 +1511,6 @@ html[data-theme='dark'] .field-error { .section-heading > .text-link { font-size: 11px; } - .art-grid { - gap: 18px; - } - .card-art { - padding: 30px 3px 12px; - } - .card-tag { - font-size: 6px; - letter-spacing: 0.07em; - padding: 4px 6px; - top: 12px; - left: 10px; - } - .card-caption { - gap: 8px; - padding-top: 18px; - } - .card-caption h3 { - font-size: 18px; - } - .card-caption p { - font-size: 9px; - line-height: 1.5; - } - .round-arrow { - width: 30px; - height: 30px; - } - .round-arrow svg { - width: 18px; - } .about-teaser, .posts-teaser { gap: 40px; @@ -1993,20 +1545,6 @@ html[data-theme='dark'] .field-error { .post-comic .comic-label { font-size: 8px; } - .project-row { - grid-template-columns: 25px 1fr 25px; - gap: 20px; - padding-block: 25px; - } - .project-category { - display: none; - } - .project-row h3 { - font-size: 27px; - } - .project-row p { - font-size: 12px; - } .contact-cta { margin-block: 40px 70px; padding: 65px 25px; @@ -2088,18 +1626,6 @@ html[data-theme='dark'] .field-error { .practice-grid p { font-size: 12px; } - .project-feature-copy { - padding: 30px; - } - .project-feature-copy h2 { - font-size: 36px; - } - .project-feature-copy > p:not(.eyebrow) { - font-size: 13px; - } - .project-feature-art { - padding-inline: 15px; - } .community-hero { gap: 35px; padding-top: 135px; @@ -2125,49 +1651,6 @@ html[data-theme='dark'] .field-error { .community-links > a > svg:last-child { display: none; } - .collection-grid { - gap: 25px 20px; - } - .collection-item h3 { - font-size: 18px; - } - .collection-item figcaption > span { - font-size: 9px; - } - .flags-grid { - grid-template-columns: repeat(3, 1fr); - } - .embed-consent { - grid-template-columns: 1fr; - } - .consent-actions { - flex-direction: row; - flex-wrap: wrap; - align-items: center; - gap: 14px; - } - .post-list { - gap: 20px; - } - .telegram-card { - padding: 20px; - } - .post-heading h2 { - font-size: 18px; - } - .post-avatar { - display: none; - } - .feedback-grid { - gap: 35px; - grid-template-columns: 0.8fr 1.2fr; - } - .feedback-form { - padding: 24px; - } - .feedback-aside p { - font-size: 12px; - } .prose-page { padding-top: 125px; } @@ -2335,42 +1818,6 @@ html[data-theme='dark'] .field-error { gap: 15px; min-height: 40px; } - .art-grid { - grid-template-columns: 1fr; - gap: 35px; - } - .card-art { - padding: 24px 20px 12px; - border-radius: 4px; - } - .card-art .motion-art { - aspect-ratio: 1.16; - max-width: 420px; - margin-inline: auto; - } - .card-tag { - font-size: 8px; - padding: 6px 9px; - top: 16px; - left: 16px; - } - .card-caption { - padding-top: 23px; - } - .card-caption h3 { - font-size: 25px; - } - .card-caption p { - font-size: 12px; - margin-top: 6px; - } - .round-arrow { - width: 40px; - height: 40px; - } - .round-arrow svg { - width: 22px; - } .about-teaser { grid-template-columns: 1fr; gap: 37px; @@ -2400,28 +1847,6 @@ html[data-theme='dark'] .field-error { .text-link { font-size: 12px; } - .project-row { - grid-template-columns: 23px 1fr 23px; - gap: 13px; - padding-block: 27px; - } - .project-row h3 { - font-size: 27px; - margin-bottom: 9px; - } - .project-row p { - font-size: 12px; - line-height: 1.75; - } - .project-row > svg { - width: 22px; - } - .project-row:hover { - padding-inline: 5px; - } - .project-number { - font-size: 9px; - } .posts-teaser { grid-template-columns: 1fr; gap: 46px; @@ -2615,39 +2040,6 @@ html[data-theme='dark'] .field-error { .practice-grid p { font-size: 14px; } - .project-gallery { - gap: 30px; - } - .project-feature { - grid-template-columns: 1fr; - box-shadow: 4px 4px 0 var(--ink); - } - .project-feature-art { - border-right: 0; - border-bottom: 1px solid var(--ink); - padding: 18px 20px; - } - .project-feature-art > .motion-art { - max-width: 260px; - } - .project-feature-copy { - padding: 28px 23px 32px; - } - .project-feature-copy h2 { - font-size: 38px; - margin-bottom: 21px; - } - .project-feature-copy > p:not(.eyebrow) { - font-size: 14px; - } - .project-feature-copy .eyebrow { - font-size: 9px; - } - .project-feature-copy .button { - font-size: 12px; - padding-inline: 16px; - gap: 16px; - } .page-end { padding-block: 30px 60px; } @@ -2701,154 +2093,10 @@ html[data-theme='dark'] .field-error { .community-links a span:not(.social-badge) { font-size: 11px; } - .collection { - padding-top: 0; - } - .collection .section-heading { - flex-wrap: nowrap; - gap: 15px; - align-items: start; - } - .collection .section-heading h2 { - font-size: 36px; - } - .collection .section-heading p:not(.eyebrow) { - font-size: 12px; - } - .collection-stamp { - font-size: 8px; - padding: 14px 10px; - margin-top: 45px; - } - .collection-grid { - grid-template-columns: 1fr 1fr; - gap: 25px 17px; - } - .collection-art { - padding: 7px; - } - .collection-item h3 { - font-size: 18px; - line-height: 1.15; - } - .collection-item figcaption { - padding-top: 13px; - } - .collection-item figcaption > span { - font-size: 8px; - line-height: 1.6; - } - .small-number { - font-size: 8px; - bottom: 7px; - right: 8px; - } - .collection-note { - font-size: 11px; - line-height: 1.8; - margin-top: 28px; - } - .flags-grid { - gap: 15px; - } - .flags-grid figure { - padding: 8px; - } - .flags-grid figcaption { - font-size: 9px; - } - .embed-consent { - padding: 21px 18px; - gap: 14px; - } - .embed-consent h2 { - font-size: 24px; - } - .embed-consent p { - font-size: 12px; - line-height: 1.8; - } - .embed-consent .small-text { - font-size: 10px; - } - .consent-actions { - gap: 10px 20px; - } - .consent-actions .button { - font-size: 11px; - } - .embed-status { - font-size: 11px !important; - } .feed-source { font-size: 10px; line-height: 1.8; } - .post-list { - grid-template-columns: 1fr; - gap: 22px; - } - .telegram-card { - padding: 23px; - } - .post-heading h2 { - font-size: 23px; - } - .post-heading > div > span { - font-size: 10px; - } - .telegram-card > p { - font-size: 13px; - } - .telegram-card .text-link { - font-size: 12px; - } - .posts-shell { - padding-bottom: 60px; - } - .feedback-grid { - grid-template-columns: 1fr; - gap: 25px; - padding-bottom: 60px; - } - .feedback-aside > .motion-art { - max-width: 240px; - display: none; - } - .feedback-aside p { - font-size: 13px; - margin-top: 0; - } - .feedback-form { - padding: 25px 20px; - box-shadow: 4px 4px 0 var(--ink); - } - .field label { - font-size: 12px; - } - .field input, - .field textarea { - font-size: 16px; - } - .feedback-form .button { - font-size: 12px; - width: 100%; - } - .captcha-info, - .form-disclosure { - font-size: 10px; - line-height: 1.8; - } - .privacy-dialog { - padding: 24px 20px; - } - .dialog-heading h2 { - font-size: 28px; - } - .privacy-dialog p, - .consent-option { - font-size: 12px; - } .prose-page { padding-top: 112px; padding-bottom: 60px; @@ -2936,21 +2184,6 @@ html[data-theme='dark'] .field-error { .contact-cta h2 { font-size: 42px; } - .card-caption h3 { - font-size: 23px; - } - .collection .section-heading h2 { - font-size: 31px; - } - .collection-stamp { - display: none; - } - .collection-item h3 { - font-size: 16px; - } - .collection-grid { - gap: 22px 12px; - } .community-links > a { padding: 22px 18px; gap: 14px; @@ -2968,9 +2201,6 @@ html[data-theme='dark'] .field-error { .footer-nav a { font-size: 10px; } - .privacy-dialog { - padding: 20px 16px; - } } html[data-motion='paused'] *, html[data-motion='paused'] *::before, @@ -2997,8 +2227,7 @@ html[data-motion='paused'] #mobile-nav[open] { } .button, .icon-button, - .icon-link, - .art-card { + .icon-link { forced-color-adjust: auto; } .motion-art { diff --git a/tests/site.spec.ts b/tests/site.spec.ts index 71d689d..0052894 100644 --- a/tests/site.spec.ts +++ b/tests/site.spec.ts @@ -18,10 +18,6 @@ const routes = [ ] as const; const widths = [320, 360, 390, 768, 1024, 1440]; const themes = ['light', 'dark'] as const; -const provider = (url: string) => - /(^|\.)(telegram\.org|t\.me|telegram-cdn\.org|telesco\.pe|cloudflare\.com)$/.test( - new URL(url).hostname, - ); const screenshotDirectory = resolve('artifacts/final'); async function settle(page: Page) { @@ -83,6 +79,14 @@ for (const [name, path] of routes) { await page.emulateMedia({ reducedMotion: 'reduce' }); const errors: string[] = []; const missing: string[] = []; + const outbound: string[] = []; + const origin = new URL(baseURL!).origin; + await page.route('**/*', async (route) => { + if (new URL(route.request().url()).origin !== origin) { + outbound.push(route.request().url()); + await route.abort('blockedbyclient'); + } else await route.continue(); + }); page.on('pageerror', (error) => errors.push(error.message)); page.on('response', (response) => { if ( @@ -108,6 +112,11 @@ for (const [name, path] of routes) { ); await expect(page.locator('[data-theme-toggle]')).toBeVisible(); await expect(page.locator('[data-motion-toggle]')).toBeVisible(); + await expect( + page.locator( + 'form, iframe, [data-feedback-form], [data-telegram-feed], [data-profile-video], #privacy-dialog, [data-privacy-open]', + ), + ).toHaveCount(0); await settle(page); expect(await page.locator('body').innerText()).not.toContain( String.fromCharCode(0x2014), @@ -121,11 +130,27 @@ for (const [name, path] of routes) { expect(header!.y).toBeLessThan(50); expect(errors, 'Uncaught browser errors').toEqual([]); expect(missing, 'Missing local resources').toEqual([]); + expect( + outbound, + 'Every route must load without any third-party request.', + ).toEqual([]); + const scriptSources = await page + .locator('script[src]') + .evaluateAll((scripts) => + scripts.map((script) => (script as HTMLScriptElement).src), + ); + expect( + scriptSources.every((source) => new URL(source).origin === origin), + 'Executable scripts must be served locally.', + ).toBe(true); }); test(`${name}: six widths in light and dark @matrix`, async ({ page, }, info) => { + // This case includes 12 viewport checks and four full-page captures. + // Keep individual action deadlines while allowing the complete matrix to finish. + test.setTimeout(120_000); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto(path); await settle(page); @@ -408,6 +433,60 @@ test('manually paused motion keeps the mobile menu visible and keyboard usable @ await expect(trigger).toHaveAttribute('aria-expanded', 'false'); }); +test('short mobile navigation keeps the current page distinct and close control reachable after scrolling @keyboard', async ({ + page, +}) => { + await page.setViewportSize({ width: 390, height: 320 }); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto('/about/'); + const trigger = page.locator('[data-menu-toggle]'); + await trigger.click(); + const dialog = page.locator('#mobile-nav'); + const current = dialog.locator('a[aria-current="page"]'); + await expect(current).toHaveCount(1); + await expect(current).toHaveAttribute('href', '/about/'); + await expect(current).toBeInViewport(); + const colors = await current.evaluate((element) => ({ + current: getComputedStyle(element).backgroundColor, + adjacent: getComputedStyle( + element.parentElement!.querySelector('a:not([aria-current])')!, + ).backgroundColor, + cue: getComputedStyle(element).boxShadow, + })); + expect(colors.current).not.toBe(colors.adjacent); + expect(colors.cue).not.toBe('none'); + const overflow = await dialog.evaluate((element) => { + element.scrollTop = element.scrollHeight; + return element.scrollHeight > element.clientHeight; + }); + expect( + overflow, + 'The short-viewport check must exercise a genuinely scrolling menu.', + ).toBe(true); + await expect + .poll(() => dialog.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(0); + const close = dialog.locator('[data-menu-close]'); + await expect(close).toBeInViewport(); + const buttonBox = await close.boundingBox(); + const dialogBox = await dialog.boundingBox(); + expect(buttonBox).not.toBeNull(); + expect(dialogBox).not.toBeNull(); + expect(buttonBox!.y).toBeGreaterThanOrEqual(dialogBox!.y); + expect(buttonBox!.y + buttonBox!.height).toBeLessThanOrEqual( + dialogBox!.y + dialogBox!.height, + ); + await close.click(); + await expect(dialog).not.toBeVisible(); + await expect(trigger).toBeFocused(); + await expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect( + await page + .locator('body') + .evaluate((element) => getComputedStyle(element).overflow), + ).not.toBe('hidden'); +}); + test('desktop tooltips stay hoverable, dismiss with Escape and reset after leaving focus @keyboard', async ({ page, }) => { @@ -491,6 +570,7 @@ test('theme and motion choices persist between reloads and pages @preferences', test('reduced motion avoids animation payloads and external providers @motion', async ({ page, + baseURL, }) => { const requests: string[] = []; await page.emulateMedia({ reducedMotion: 'reduce' }); @@ -499,7 +579,7 @@ test('reduced motion avoids animation payloads and external providers @motion', await settle(page); await page.evaluate(() => scrollTo(0, document.documentElement.scrollHeight)); await page.waitForLoadState('networkidle'); - await page.goto('/work/'); + await page.goto('/about/'); await page.locator('[data-animation]').first().scrollIntoViewIfNeeded(); await page.waitForLoadState('networkidle'); await expect(page.locator('html')).toHaveAttribute('data-motion', 'paused'); @@ -510,7 +590,7 @@ test('reduced motion avoids animation payloads and external providers @motion', 'Reduced motion must avoid hero video, TGS and the Lottie player', ).toEqual([]); expect( - requests.filter(provider), + requests.filter((url) => new URL(url).origin !== new URL(baseURL!).origin), 'No external content is needed for the initial page', ).toEqual([]); const activeMedia = await page @@ -538,10 +618,23 @@ test('native hero verifies loop support, controls and real fallback @motion', as .canPlayType(`video/mp4; codecs="${codec}"`), manifest.codec, ); - test.skip( - !supportsH264, - 'This browser installation does not advertise H.264 playback.', - ); + if (!supportsH264) { + info.annotations.push({ + type: 'platform', + description: + 'This browser does not advertise the supplied H.264 codec; verify the real still fallback instead of claiming native playback.', + }); + await page.goto('/'); + const art = page.locator('[data-native-hero]'); + await expect(art).toHaveAttribute('data-state', 'unavailable', { + timeout: 15_000, + }); + await expect(art).toHaveAttribute('data-playing', 'false'); + await expect(art).toHaveAttribute('aria-label', /Motion unavailable/); + await expect(art.locator('video')).toBeHidden(); + await expect(art.locator('.art-poster')).toBeVisible(); + return; + } // Probe the real clip without site code before deciding whether this native // media backend can sustain its loop. A decoded first frame is insufficient. @@ -966,72 +1059,13 @@ test('failed native hero media retains its poster across theme retries @motion', await expect(video).toBeHidden(); }); -test('Work progressively reveals original artwork with focus and deferred assets @keyboard', async ({ - page, -}) => { - await page.emulateMedia({ reducedMotion: 'reduce' }); - const requested: string[] = []; - page.on('request', (request) => requested.push(request.url())); - await page.goto('/work/'); - const figures = page.locator('.collection-grid > figure'); - await expect(figures).toHaveCount(30); - await expect(page.locator('.collection-grid > figure:visible')).toHaveCount( - 9, - ); - await expect(page.locator('.extra-artwork[hidden]')).toHaveCount(21); - await expect(page.locator('[data-show-collection]')).toBeVisible(); - await page.waitForLoadState('networkidle'); - const deferredPosters = await page - .locator('.extra-artwork img') - .evaluateAll((images) => - images.map((image) => (image as HTMLImageElement).src), - ); - expect( - requested.filter((url) => deferredPosters.includes(url)), - 'Initially hidden gallery posters should not download.', - ).toEqual([]); - await page.locator('[data-show-collection]').focus(); - await page.keyboard.press('Enter'); - await expect(page.locator('.collection-grid > figure:visible')).toHaveCount( - 30, - ); - await expect( - page.locator('.extra-artwork').first().locator('h3'), - ).toBeFocused(); - await expect(page.locator('[data-show-collection]')).toBeHidden(); - await noOverflow(page, 'Expanded original artwork gallery'); -}); - -test('Work retains every original artwork when JavaScript is unavailable @smoke', async ({ - browser, - baseURL, -}) => { - const context = await browser.newContext({ - javaScriptEnabled: false, - baseURL, - }); - try { - const page = await context.newPage(); - await page.goto('/work/'); - await expect(page.locator('.collection-grid > figure:visible')).toHaveCount( - 30, - ); - await expect(page.locator('[data-show-collection]')).toBeHidden(); - await expect(page.locator('.art-player canvas')).toHaveCount(0); - await expect(page.locator('.collection-grid h3')).toHaveCount(30); - await noOverflow(page, 'Artwork collection without JavaScript'); - } finally { - await context.close(); - } -}); - test('original Canvas animation changes, freezes on pause and bounds active artwork @motion', async ({ page, }) => { const requests: string[] = []; page.on('request', (resource) => requests.push(resource.url())); - await page.goto('/work/'); - const art = page.locator('.collection-grid [data-animation]').first(); + await page.goto('/about/'); + const art = page.locator('[data-animation]').first(); await art.scrollIntoViewIfNeeded(); await expect(art).toHaveAttribute('data-state', 'ready', { timeout: 25_000 }); await expect(art).toHaveAttribute('data-playing', 'true'); @@ -1107,15 +1141,15 @@ test('original Canvas animation changes, freezes on pause and bounds active artw expect(dimensions.right).toBeLessThanOrEqual(391); await noOverflow(page, 'Resized active Canvas artwork'); await page.setViewportSize({ width: 1440, height: 2000 }); - await page.goto('/work/'); - await page.locator('.collection-grid').scrollIntoViewIfNeeded(); + await page.goto('/about/'); + await page.locator('[data-animation]').first().scrollIntoViewIfNeeded(); await expect .poll(() => page.locator('[data-animation][data-playing="true"]').count()) .toBeGreaterThan(0); await page.waitForLoadState('networkidle'); expect( await page.locator('[data-animation][data-playing="true"]').count(), - 'The long collection must bound concurrent artwork playback.', + 'Visible original artwork must respect the concurrent playback limit.', ).toBeLessThanOrEqual(3); await page.locator('[data-motion-toggle]').click(); await expect( @@ -1129,8 +1163,8 @@ test('failed original animation keeps its poster and readable status @motion', a await page.route('**/media/animations/*.tgs', (route) => route.abort('failed'), ); - await page.goto('/work/'); - const art = page.locator('.collection-grid [data-animation]').first(); + await page.goto('/about/'); + const art = page.locator('[data-animation]').first(); await art.scrollIntoViewIfNeeded(); await expect(art).toHaveAttribute('data-state', 'unavailable'); await expect(art).toHaveAttribute( @@ -1149,538 +1183,297 @@ test('failed original animation keeps its poster and readable status @motion', a .toBeGreaterThan(0); }); -test('video pause/resume and reduced motion using a local canvas-stream fixture @motion', async ({ - page, -}, info) => { - await page.goto('/about/'); - const supported = await page.evaluate( - () => typeof HTMLCanvasElement.prototype.captureStream === 'function', - ); - test.skip( - !supported, - 'This browser does not support the local canvas media fixture; real Telegram video remains an integration test.', - ); - await page.evaluate(() => { - const fixture = document.createElement('div'); - fixture.id = 'qa-profile-media'; - fixture.style.cssText = - 'position:fixed;bottom:0;right:0;z-index:9999;background:white;padding:8px'; - const canvas = document.createElement('canvas'); - canvas.width = 16; - canvas.height = 16; - fixture.append(canvas); - document.body.append(fixture); - const context = canvas.getContext('2d')!; - const stream = canvas.captureStream(10); - const track = stream.getVideoTracks()[0] as CanvasCaptureMediaStreamTrack; - const video = document.createElement('video'); - video.id = 'qa-profile-video-fixture'; - video.dataset.profileVideo = ''; - video.muted = true; - video.playsInline = true; - video.width = 16; - video.height = 16; - video.setAttribute('aria-label', 'Local QA media fixture'); - video.srcObject = stream; - video.dataset.qaPlay = 'pending'; - fixture.append(video); - // Capture starts before drawing. A static pre-capture canvas can produce - // no frames on WebKit, leaving its play() promise pending indefinitely. - let frame = 0; - const paint = () => { - context.fillStyle = frame++ % 2 ? '#7751d4' : '#d7c6ff'; - context.fillRect(0, 0, 16, 16); - canvas.dataset.frames = String(frame); - track.requestFrame?.(); - }; - paint(); - const timer = window.setInterval(paint, 100); - ( - window as Window & { qaProfileFixtureCleanup?: () => void } - ).qaProfileFixtureCleanup = () => { - window.clearInterval(timer); - stream.getTracks().forEach((source) => source.stop()); - }; - const start = document.createElement('button'); - start.textContent = 'Start local media fixture'; - start.addEventListener('click', () => { - void video.play().then( - () => { - video.dataset.qaPlay = 'resolved'; - }, - (error: DOMException) => { - video.dataset.qaPlay = `rejected:${error.name}`; - }, - ); - }); - fixture.append(start); +for (const [path, destination] of [ + ['/work/', 'https://t.me/wizard_loop'], + ['/projects/', 'https://github.com/WizardLoop?tab=repositories'], + ['/posts/', 'https://t.me/s/wizard_loop'], +] as const) { + test(`${path} offers the requested direct destination without a local collection @smoke @links`, async ({ + page, + }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto(path); + const links = page.locator(`main a[href="${destination}"]`); + expect(await links.count()).toBeGreaterThan(0); + await expect(links.first()).toBeVisible(); + await expect(links.first()).toHaveAccessibleName(/\S/); + await expect( + page.locator( + 'main form, main iframe, .collection-grid, [data-show-collection], [data-post-id], [data-telegram-feed]', + ), + ).toHaveCount(0); + if (path === '/work/') { + await expect( + page.locator('main [data-animation], main figure'), + ).toHaveCount(0); + } + if (path === '/projects/') { + await expect( + page.locator('main a[href^="https://github.com/WizardLoop/"]'), + ).toHaveCount(0); + } }); - await page.getByRole('button', { name: 'Start local media fixture' }).click(); - const video = page.locator('#qa-profile-video-fixture'); - try { - await expect(video).toHaveAttribute('data-qa-play', 'resolved', { - timeout: 10_000, - }); - } finally { - const state = await video.evaluate((element) => { - const media = element as HTMLVideoElement; - const stream = media.srcObject as MediaStream; - return { - play: media.dataset.qaPlay, - readyState: media.readyState, - time: media.currentTime, - paused: media.paused, - visible: !document.hidden, - paintedFrames: document.querySelector<HTMLCanvasElement>( - '#qa-profile-media canvas', - )?.dataset.frames, - active: stream.active, - tracks: stream - .getVideoTracks() - .map((track) => ({ - readyState: track.readyState, - enabled: track.enabled, - muted: track.muted, - })), - }; - }); - console.info('Local canvas fixture:', JSON.stringify(state)); - await info.attach('canvas-media-capability', { - body: JSON.stringify(state), - contentType: 'application/json', - }); - } - await expect - .poll(() => - video.evaluate((element) => (element as HTMLVideoElement).readyState), - ) - .toBeGreaterThanOrEqual(2); - await expect - .poll(() => - video.evaluate((element) => (element as HTMLVideoElement).paused), - ) - .toBe(false); - const firstTime = await video.evaluate( - (element) => (element as HTMLVideoElement).currentTime, - ); - await expect - .poll(() => - video.evaluate((element) => (element as HTMLVideoElement).currentTime), - ) - .toBeGreaterThan(firstTime + 0.05); - await page.locator('[data-motion-toggle]').click(); - await expect - .poll(() => - video.evaluate((element) => (element as HTMLVideoElement).paused), - ) - .toBe(true); - await page.locator('[data-motion-toggle]').click(); - await expect - .poll(() => - video.evaluate((element) => (element as HTMLVideoElement).paused), - ) - .toBe(false); - const resumedTime = await video.evaluate( - (element) => (element as HTMLVideoElement).currentTime, - ); - await expect - .poll(() => - video.evaluate((element) => (element as HTMLVideoElement).currentTime), - ) - .toBeGreaterThan(resumedTime + 0.05); - await page.emulateMedia({ reducedMotion: 'reduce' }); - await expect - .poll(() => - video.evaluate((element) => (element as HTMLVideoElement).paused), - ) - .toBe(true); - await page.evaluate(() => - ( - window as Window & { qaProfileFixtureCleanup?: () => void } - ).qaProfileFixtureCleanup?.(), - ); -}); - -async function fixtureTelegram(page: Page) { - await page.route('https://telegram.org/js/telegram-widget.js*', (route) => - route.fulfill({ - contentType: 'application/javascript', - body: `(() => { const script = document.currentScript; if (!script) return; const frame = document.createElement('iframe'); frame.src = 'https://t.me/' + script.dataset.telegramPost + '?embed=1'; frame.dataset.qaFixture = 'true'; frame.style.width = '100%'; frame.style.height = '180px'; frame.style.border = '0'; script.parentElement.append(frame); })();`, - }), - ); - await page.route('https://t.me/**', (route) => - route.fulfill({ - contentType: 'text/html', - body: '<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Telegram QA fixture

Deterministic test fixture. This is not a live Telegram post.

', - }), - ); } -test('Telegram requests wait for consent; pause and revoke remove embedded frames @privacy', async ({ +test('contact keeps the verified Telegram and email links keyboard accessible without collecting fields @privacy @keyboard', async ({ page, }) => { - const requests: string[] = []; - await fixtureTelegram(page); - page.on('request', (request) => { - if (provider(request.url())) requests.push(request.url()); - }); - await page.goto('/posts/'); - await page.waitForLoadState('networkidle'); - expect(requests).toEqual([]); - await expect(page.locator('[data-telegram-slot] iframe')).toHaveCount(0); - const count = Math.min(6, await page.locator('[data-telegram-slot]').count()); - expect(count).toBeGreaterThan(0); - await page.locator('[data-telegram-load]').click(); - await expect(page.locator('[data-telegram-slot] iframe')).toHaveCount(count); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto('/feedback/'); + await page.keyboard.press('Tab'); + for (const href of [ + 'https://t.me/wizardloop', + 'mailto:wizard@wizardloop.com', + ]) { + const link = page.locator(`main a[href="${href}"]`).first(); + await expect(link).toBeVisible(); + await expect(link).toHaveAccessibleName(/\S/); + await link.focus(); + await expect(link).toBeFocused(); + expect( + await link.evaluate((element) => getComputedStyle(element).outlineStyle), + ).not.toBe('none'); + } await expect( - page.locator('[data-telegram-slot] iframe').first(), - ).toHaveAttribute('title', /Original Telegram post/); - expect( - await page.evaluate(() => - localStorage.getItem('wizardloop-telegram-consent'), + page.locator( + 'form, input, textarea, select, iframe, [data-feedback-submit], [data-verify-start]', ), - ).toBe('yes'); - expect(requests.length).toBeGreaterThan(0); - await page.locator('[data-motion-toggle]').click(); - await expect(page.locator('[data-telegram-slot] iframe')).toHaveCount(0); - await expect(page.locator('[data-telegram-status]')).toContainText( - 'motion is paused', - ); - await page.locator('[data-motion-toggle]').click(); - await expect(page.locator('[data-telegram-slot] iframe')).toHaveCount(count); - await page.locator('[data-telegram-reset]').click(); - await expect(page.locator('[data-telegram-slot] iframe')).toHaveCount(0); + ).toHaveCount(0); expect( - await page.evaluate(() => - localStorage.getItem('wizardloop-telegram-consent'), - ), - ).toBe('no'); - requests.length = 0; - await page.reload(); - await page.waitForLoadState('networkidle'); - expect(requests).toEqual([]); + new URL(page.url()).search, + 'Contact details must not be entered into a local submission URL.', + ).toBe(''); }); -test('privacy preferences dialog can grant and withdraw Telegram permission @privacy @keyboard', async ({ - page, +test('direct contact and gateway links work without JavaScript @smoke @privacy', async ({ + browser, + baseURL, }) => { - await page.setViewportSize({ width: 390, height: 844 }); - await fixtureTelegram(page); - await page.goto('/posts/'); - await page.waitForLoadState('networkidle'); - const opener = page.locator('[data-privacy-open]').first(); - await opener.click(); - const dialog = page.locator('#privacy-dialog'); - await expect(dialog).toBeVisible(); - await expect(page.locator('#telegram-consent')).not.toBeChecked(); - await page.locator('#telegram-consent').check(); - await page.locator('[data-privacy-save]').click(); - await expect(dialog).not.toBeVisible(); - await expect(opener).toBeFocused(); - await expect( - page.locator('[data-telegram-slot] iframe').first(), - ).toBeAttached(); - await opener.click(); - await page.locator('#telegram-consent').uncheck(); - await page.locator('[data-privacy-save]').click(); - await expect(page.locator('[data-telegram-slot] iframe')).toHaveCount(0); - await expect(opener).toBeFocused(); + const context = await browser.newContext({ + javaScriptEnabled: false, + baseURL, + }); + try { + const page = await context.newPage(); + for (const [path, hrefs] of [ + [ + '/feedback/', + ['https://t.me/wizardloop', 'mailto:wizard@wizardloop.com'], + ], + ['/work/', ['https://t.me/wizard_loop']], + ['/projects/', ['https://github.com/WizardLoop?tab=repositories']], + ['/posts/', ['https://t.me/s/wizard_loop']], + ] as const) { + await page.goto(path); + await expect(page.locator('h1')).toBeVisible(); + for (const href of hrefs) + await expect( + page.locator(`main a[href="${href}"]`).first(), + ).toBeVisible(); + await expect(page.locator('form, iframe')).toHaveCount(0); + if (path === '/feedback/') + await expect(page.locator('[data-copy-email]')).toBeHidden(); + } + } finally { + await context.close(); + } }); -test('Telegram failure leaves original links and a working retry path @privacy', async ({ - page, -}) => { - await page.route('https://telegram.org/js/telegram-widget.js*', (route) => - route.abort('failed'), - ); - await page.goto('/posts/'); - await page.waitForLoadState('networkidle'); - await page.locator('[data-telegram-load]').click(); - await expect(page.locator('[data-telegram-slot]').first()).toContainText( - 'could not load', - ); - expect( - await page.locator('.telegram-card a[href^="https://t.me/"]').count(), - ).toBeGreaterThan(0); - await page.unroute('https://telegram.org/js/telegram-widget.js*'); - await fixtureTelegram(page); - await page.locator('[data-telegram-retry]').click(); - await expect( - page.locator('[data-telegram-slot] iframe').first(), - ).toBeAttached(); -}); +for (const outcome of ['success', 'denied'] as const) { + test(`copy email ${outcome} preserves focus, prevents duplicate writes and keeps the narrow layout stable @privacy @keyboard`, async ({ + page, + }) => { + await page.setViewportSize({ width: 320, height: 844 }); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.addInitScript(() => { + const fixture = { + calls: [] as string[], + finish: (_success: boolean) => {}, + }; + (window as Window & { qaClipboard?: typeof fixture }).qaClipboard = + fixture; + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + writeText(address: string) { + fixture.calls.push(address); + return new Promise((resolve, reject) => { + fixture.finish = (success) => + success + ? resolve() + : reject( + new DOMException( + 'Local permission-denied fixture', + 'NotAllowedError', + ), + ); + }); + }, + }, + }); + }); + await page.goto('/feedback/'); + await page.evaluate(async () => { + await document.fonts.ready; + }); + const button = page.locator('[data-copy-email]'); + const status = page.locator('[data-copy-status]'); + const help = page.locator('.destination-contact-help').first(); + await expect(button).toBeVisible(); + await expect(button).toHaveAttribute( + 'data-copy-email', + 'wizard@wizardloop.com', + ); + await expect(status).toHaveAttribute('role', 'status'); + await expect(status).toHaveAttribute('aria-live', 'polite'); + await page.keyboard.press('Tab'); + await button.focus(); + const helpPosition = () => + help.evaluate( + (element) => element.getBoundingClientRect().top + window.scrollY, + ); + const before = await helpPosition(); + await page.keyboard.press('Enter'); + await expect(button).toBeFocused(); + await expect(button).toHaveAttribute('aria-busy', 'true'); + await expect(button).toHaveAttribute('aria-disabled', 'true'); + await page.keyboard.press('Enter'); + expect( + await page.evaluate( + () => + (window as Window & { qaClipboard?: { calls: string[] } }) + .qaClipboard!.calls, + ), + ).toEqual(['wizard@wizardloop.com']); + await page.evaluate((success) => { + ( + window as Window & { + qaClipboard?: { finish: (success: boolean) => void }; + } + ).qaClipboard!.finish(success); + }, outcome === 'success'); + await expect(status).toHaveText( + outcome === 'success' + ? 'Email address copied. Paste it into your message.' + : 'Copy unavailable. Select: wizard@wizardloop.com', + ); + await expect(button).toBeFocused(); + await expect(button).not.toHaveAttribute('aria-busy'); + await expect(button).not.toHaveAttribute('aria-disabled'); + await expect(button).toBeEnabled(); + expect( + Math.abs((await helpPosition()) - before), + 'The reserved status area must not shift the following help text at 320px.', + ).toBeLessThanOrEqual(1); + await expect( + page.locator('main a[href="mailto:wizard@wizardloop.com"]'), + ).toBeVisible(); + await noOverflow(page, `Copy email ${outcome} at 320px`); + }); +} -test('long-feed fixture reveals six posts at a time without restarting existing embeds @privacy @keyboard', async ({ +test('copy email stays hidden when the Clipboard API is unavailable @privacy', async ({ page, }) => { - await fixtureTelegram(page); - await page.route('**/posts/', async (route) => { - const response = await route.fetch(); - let html = await response.text(); - const articles = - html.match(/
]*>[\s\S]*?<\/article>/g) || - []; - expect( - articles.length, - 'The fixture extends the real static post markup.', - ).toBeGreaterThan(0); - const original = articles[0]; - const fixtureArticles = Array.from({ length: 13 }, (_, index) => - original - .replace(/wizard_loop\/\d+/gi, `wizard_loop/${7000 + index}`) - .replace( - /]*>[\s\S]*?<\/h2>/, - `

Local QA post fixture ${index + 1}

`, - ), - ).join(''); - let inserted = false; - html = html.replace( - /
]*>[\s\S]*?<\/article>/g, - () => { - if (inserted) return ''; - inserted = true; - return fixtureArticles; - }, - ); - await route.fulfill({ response, body: html }); + await page.addInitScript(() => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }); }); - await page.goto('/posts/'); - // Readiness is the rendered page size, not global network quiescence. The - // routed HTML fixture can keep Firefox's network-idle heuristic pending. - await expect(page.locator('.telegram-card:visible')).toHaveCount(6); - await expect(page.locator('[data-telegram-slot] iframe')).toHaveCount(0); - await page.locator('[data-telegram-load]').click(); - await expect(page.locator('[data-telegram-slot] iframe')).toHaveCount(6); - await page.locator('[data-telegram-slot] iframe').evaluateAll((frames) => - frames.forEach((frame) => { - (frame as HTMLElement).dataset.qaOriginalFrame = 'true'; - }), - ); - const more = page.locator('[data-posts-more]'); - await more.focus(); - await page.keyboard.press('Enter'); - await expect(page.locator('.telegram-card:visible')).toHaveCount(12); - await expect( - page.locator('.telegram-card').nth(6).locator('h2'), - ).toBeFocused(); - await expect(page.locator('[data-telegram-slot] iframe')).toHaveCount(12); - await expect( - page.locator('iframe[data-qa-original-frame]'), - 'Already-loaded frames retain their DOM identity.', - ).toHaveCount(6); - await more.focus(); - await page.keyboard.press('Enter'); - await expect(page.locator('.telegram-card:visible')).toHaveCount(13); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto('/feedback/'); + await expect(page.locator('[data-copy-email]')).toBeHidden(); + await expect(page.locator('[data-copy-status]')).toBeEmpty(); await expect( - page.locator('.telegram-card').nth(12).locator('h2'), - ).toBeFocused(); - await expect(more).toBeHidden(); - await expect(page.locator('[data-telegram-slot] iframe')).toHaveCount(13); - await expect(page.locator('iframe[data-qa-original-frame]')).toHaveCount(6); + page.locator('main a[href="mailto:wizard@wizardloop.com"]'), + ).toBeVisible(); }); -test('profile falls back from Telegram to GitHub while retaining dimensions @profile', async ({ +test('the profile is a dimensioned local still with no provider or synchronization dependency @profile', async ({ page, + baseURL, }) => { + const outbound: string[] = []; + await page.route('**/*', async (route) => { + if (new URL(route.request().url()).origin !== new URL(baseURL!).origin) { + outbound.push(route.request().url()); + await route.abort('blockedbyclient'); + } else await route.continue(); + }); await page.emulateMedia({ reducedMotion: 'reduce' }); - await page.route('**/media/profile/avatar.jpg', (route) => - route.abort('failed'), - ); await page.goto('/about/'); const image = page.locator('img[data-profile-image]'); - await expect(image).toHaveAttribute('src', '/media/profile/github.jpg'); - await expect(page.locator('[data-profile-source]')).toContainText('GitHub'); + await expect(image).toHaveAttribute('src', '/media/profile/avatar.jpg'); + await expect(image).toHaveAttribute('width', '96'); + await expect(image).toHaveAttribute('height', '96'); + await expect(image).toHaveAttribute('alt', /WizardLoop/); + await expect( + page.locator('[data-profile-video], [data-profile-source]'), + ).toHaveCount(0); await expect .poll(() => - image.evaluate((element) => (element as HTMLImageElement).naturalWidth), + image.evaluate((element) => { + const photo = element as HTMLImageElement; + return photo.complete && photo.naturalWidth > 0; + }), ) - .toBeGreaterThan(0); - await expect(image).toHaveAttribute('width', '96'); - await expect(image).toHaveAttribute('height', '96'); + .toBe(true); + expect(outbound).toEqual([]); }); -test('both profile providers can fail without losing the local fallback @profile', async ({ +test('a missing local profile image uses the local artwork fallback @profile', async ({ page, }) => { - await page.emulateMedia({ reducedMotion: 'reduce' }); + const requested: string[] = []; + page.on('request', (request) => requested.push(request.url())); await page.route('**/media/profile/avatar.jpg', (route) => route.abort('failed'), ); - await page.route('**/media/profile/github.jpg', (route) => - route.abort('failed'), - ); - await page.goto('/about/'); - const image = page.locator('img[data-profile-image]'); - await expect(image).toHaveAttribute('src', '/media/profile/fallback.svg'); - await expect(page.locator('[data-profile-source]')).toContainText('Local'); - await expect - .poll(() => - image.evaluate((element) => (element as HTMLImageElement).naturalWidth), - ) - .toBeGreaterThan(0); -}); - -test('unavailable profile video fixture retains a usable image and respects reduced motion @profile @motion', async ({ - page, -}) => { await page.emulateMedia({ reducedMotion: 'reduce' }); - await page.route('**/about/', async (route) => { - const response = await route.fetch(); - const html = (await response.text()).replace( - /]*data-profile-image)[^>]*>/, - (image) => - `${image.replace(' - route.fulfill({ - status: 404, - contentType: 'text/plain', - body: 'Local QA missing-media fixture.', - }), - ); await page.goto('/about/'); - await page.waitForLoadState('networkidle'); - const video = page.locator('#qa-unavailable-profile-video'); - await expect(video).not.toHaveAttribute('autoplay'); - expect( - await video.evaluate((element) => (element as HTMLVideoElement).paused), - ).toBe(true); - await video.evaluate((element) => { - const media = element as HTMLVideoElement; - media.src = '/media/profile/qa-unavailable.mp4'; - media.load(); - }); - await expect(video).toBeHidden(); const image = page.locator('img[data-profile-image]'); - await expect(image).toBeVisible(); + await expect(image).toHaveAttribute('src', '/media/profile/fallback.svg'); + await expect(image).toHaveAttribute('width', '96'); + await expect(image).toHaveAttribute('height', '96'); await expect .poll(() => image.evaluate((element) => (element as HTMLImageElement).naturalWidth), ) .toBeGreaterThan(0); -}); - -test('mocked Turnstile retry resets the existing widget without duplicate renders @privacy', async ({ - page, -}) => { - const externalRequests: string[] = []; - page.on('request', (request) => { - if ( - /challenges\.cloudflare\.com|feedback-fixture\.invalid/.test( - request.url(), - ) - ) - externalRequests.push(request.url()); - }); - await page.route('**/feedback/', async (route) => { - const response = await route.fetch(); - const html = (await response.text()).replace( - /]*data-feedback-form[^>]*>/, - (form) => - form - .replace( - /data-endpoint(?:="[^"]*")?/, - 'data-endpoint="https://feedback-fixture.invalid/feedback"', - ) - .replace( - /data-sitekey(?:="[^"]*")?/, - 'data-sitekey="qa-turnstile-fixture"', - ), - ); - await route.fulfill({ response, body: html }); - }); - await page.route('https://feedback-fixture.invalid/**', (route) => - route.abort('blockedbyclient'), - ); - await page.route('https://challenges.cloudflare.com/turnstile/**', (route) => - route.fulfill({ - contentType: 'application/javascript', - body: `(() => { - const state = window.__qaTurnstile = { renders: 0, resets: 0, ids: [] }; - let callbacks; - window.turnstile = { - render(target, options) { state.renders++; callbacks = options; setTimeout(() => callbacks['error-callback']('local-fixture-error'), 0); return 'qa-widget-1'; }, - reset(id) { state.resets++; state.ids.push(id); setTimeout(() => callbacks.callback('local-fixture-token'), 0); } - }; - })();`, - }), - ); - await page.goto('/feedback/'); - await page.waitForLoadState('networkidle'); - expect(externalRequests).toEqual([]); - await expect(page.locator('[data-local-demo]')).toBeHidden(); - const verify = page.locator('[data-verify-start]'); - await verify.click(); - await expect(page.locator('[data-verification-state]')).toContainText( - 'could not complete', - ); - await expect(verify).toBeEnabled(); - await verify.click(); - await expect(page.locator('[data-verification-state]')).toContainText( - 'Verification complete', - ); - const state = await page.evaluate( - () => - ( - window as unknown as { - __qaTurnstile: { renders: number; resets: number; ids: string[] }; - } - ).__qaTurnstile, - ); - expect(state).toEqual({ renders: 1, resets: 1, ids: ['qa-widget-1'] }); expect( - externalRequests.filter((url) => url.includes('challenges.cloudflare.com')), - ).toHaveLength(1); - expect( - externalRequests.filter((url) => url.includes('feedback-fixture.invalid')), + requested.filter((url) => + /github\.com|githubusercontent\.com|t\.me|telegram\.org/.test( + new URL(url).hostname, + ), + ), ).toEqual([]); }); -test('feedback cannot submit personal fields through a URL when JavaScript is unavailable @privacy', async ({ - browser, - baseURL, +test('Home introduces the creator without listing individual projects or collections @smoke', async ({ + page, }) => { - const context = await browser.newContext({ - javaScriptEnabled: false, - baseURL, - }); - const page = await context.newPage(); - try { - await page.goto('/feedback/'); - await expect(page.locator('[data-feedback-controls]')).toHaveAttribute( - 'disabled', - ); - await expect(page.locator('[data-feedback-submit]')).toBeDisabled(); - await expect(page.locator('[data-verify-start]')).toBeDisabled(); - for (const id of ['feedback-name', 'feedback-message', 'feedback-email']) - await expect(page.locator(`#${id}`)).toBeDisabled(); - await expect(page.locator('[data-feedback-form]')).toHaveAttribute( - 'method', - /post/i, - ); - const notice = page.locator('noscript .notice'); - await expect(notice).toBeVisible(); - await expect(notice).toContainText( - 'The form needs JavaScript for verification.', - ); - expect( - new URL(page.url()).search, - 'Form content must never enter URL query parameters.', - ).toBe(''); - await expect( - page.locator('noscript a[href="mailto:wizard@wizardloop.com"]'), - ).toBeVisible(); - await expect( - page.locator('noscript a[href="https://t.me/wizardloop"]'), - ).toBeVisible(); - } finally { - await context.close(); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto('/'); + await expect(page.locator('[data-native-hero]')).toBeVisible(); + await expect( + page.locator( + 'main .collection-grid, main .project-row, main .project-feature, main a[href^="https://github.com/WizardLoop/"]', + ), + ).toHaveCount(0); + for (const href of ['/about/', '/work/', '/projects/', '/posts/']) { + expect(await page.locator(`a[href="${href}"]`).count()).toBeGreaterThan(0); } }); -for (const path of ['/', '/about/', '/work/', '/posts/', '/feedback/']) { +for (const path of [ + '/', + '/about/', + '/work/', + '/projects/', + '/posts/', + '/feedback/', +]) { for (const currentTheme of themes) { for (const width of [390, 1440]) { test(`axe ${path} ${currentTheme} ${width}px @axe`, async ({ diff --git a/tsconfig.json b/tsconfig.json index dee05b4..a2e4bef 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,5 @@ { "extends": "astro/tsconfigs/strict", "include": [".astro/types.d.ts", "src/**/*", "astro.config.mjs"], - "exclude": ["dist", "node_modules", "services", "concepts", "artifacts"] + "exclude": ["dist", "node_modules", "concepts", "artifacts"] }