diff --git a/.agents/skills/ci-and-devcontainer/SKILL.md b/.agents/skills/ci-and-devcontainer/SKILL.md index ed9a4e04..ac61aac4 100644 --- a/.agents/skills/ci-and-devcontainer/SKILL.md +++ b/.agents/skills/ci-and-devcontainer/SKILL.md @@ -7,8 +7,8 @@ description: Use when changing Rust toolchain setup, Linux GUI dependencies, CI, - **One complete devcontainer.** `.devcontainer/Dockerfile` installs Study's build, rendering and PDF dependencies, the browser desktop, Rust tools, and the agent - tools (Node, pinned Claude Code, Herdr and reviewr, Codex, basedpyright and - GitHub CLI). `.devcontainer/devcontainer.json` is the only configuration. + tools (Node, Bun for the website, pinned Claude Code, Herdr and reviewr, Codex, + basedpyright and GitHub CLI). `.devcontainer/devcontainer.json` is the only configuration. `just agents` must work there after a rebuild. Linux CI and Linux releases use it too. Add tools to the Dockerfile, never in a recipe. - **`post-create.py` configures mounted host credentials** at runtime: @@ -24,22 +24,28 @@ description: Use when changing Rust toolchain setup, Linux GUI dependencies, CI, release. `.github/actions/setup` installs the build subset of the Dockerfile's apt packages, Rust from `rust-toolchain.toml` and pinned `just`, and restores the build cache (every branch saves its own; a pull request starts from main's). Keep its packages and pins aligned with the Dockerfile. - - CI (`ci.yml`): lint (`check-shell fmt-check deny lint docs`) and `just test` run as - parallel jobs, with dependencies built unoptimized. `Test and build` is the one required - check; it passes when both jobs passed. The runner's own Docker runs the tests that need + - CI (`ci.yml`): lint (`check-shell fmt-check deny lint docs`), `just test` and the + website's type check (`landing-check`, with Bun alone) run as parallel jobs, with + dependencies built unoptimized. `Test and build` is the one required check; it passes + when every job passed. The runner's own Docker runs the tests that need real services. Ubuntu 24.04's glibc 2.39 is enough for the prebuilt onnxruntime. - Devcontainer (`devcontainer.yml`): when `.devcontainer/` changes, builds the image, starts the container and runs its lifecycle commands, so a broken environment fails before merge. - - Demo (`demo.yml`): by hand only. It runs `just demo` and opens a pull request with the - new `assets/demo.gif`. + - Landing (`landing.yml`): on every push to main, or by hand, runs `just landing-build` + and deploys `apps/landing/dist` to GitHub Pages under `/study/`. A fresh checkout has + no captures of the app, so the recipe first makes all of them (`just docs-media`, + `just demo`) on the showcase's private desktop with software Vulkan, as the setup + action's extra packages provide, then builds the site with Bun, pinned by + `packageManager` in `apps/landing/package.json` to the Dockerfile's `BUN_VERSION`. The + README's demo GIF is published with the site; nothing it makes is committed. - Releases: Linux x64 and ARM64 build inside the devcontainer; macOS Intel/Apple Silicon and Windows x64 use native GitHub runners. They install Rust and pinned cargo-packager; they must never require a second devcontainer. - Linux AppImages still need glibc 2.41 or newer. - **One release pipeline.** `.github/workflows/release.yml` runs on a pushed `vX.Y.Z` tag that matches the workspace version, then publishes only when all five signed packages exist. - `packaging/packager.json` and `packaging/release.sh` own packaging and the + `apps/desktop/packaging/packager.json` and `apps/desktop/packaging/release.sh` own packaging and the static `latest.json` updater feed; keep the platform keys aligned with cargo-packager-updater. Keep `cargo-packager` pinned identically in the Dockerfile and release workflow. Never put signing keys in the image or repository. Releases require the public-key diff --git a/.agents/team/fleet.toml b/.agents/team/fleet.toml index 8ee23e2a..883ccee1 100644 --- a/.agents/team/fleet.toml +++ b/.agents/team/fleet.toml @@ -1,8 +1,8 @@ # One project-wide team. Named sessions isolate unrelated work. session = "study" workspace = "study-team" -# Seven regular roles plus one shared slot for scenario preparation, upgrade review or -# PR delivery. +# Seven regular roles plus one shared slot for scenario preparation, upgrade review, PR +# delivery or keeping the website in sync. max_agents = 8 # The Herdr plugin that opens the review panel beside the lead. reviewr = "persiyanov.reviewr" @@ -37,3 +37,4 @@ pushback = "build" upgrade-reviewer = "build" pr-maker = "build" scenario = "build" +site = "build" diff --git a/.agents/team/roles/lead.md b/.agents/team/roles/lead.md index a39d0718..e71986df 100644 --- a/.agents/team/roles/lead.md +++ b/.agents/team/roles/lead.md @@ -40,3 +40,6 @@ slot before delivery, give it the diff and base, then stop it after its one resu For missing test or demo data, give `scenario` the required state and acceptance criteria. Use the shared optional slot as described in the team brief; pass its verified handoff to QA/student and later PR maker. Do not carry stale fixture assumptions into a new request. + +When a delivered change or release alters what the website says or shows, start `site` +in the shared optional slot as the team brief describes, then stop it after its result. diff --git a/.agents/team/roles/site.md b/.agents/team/roles/site.md new file mode 100644 index 00000000..0f5e2f00 --- /dev/null +++ b/.agents/team/roles/site.md @@ -0,0 +1,73 @@ +# Site keeper + +## Required skills + +Before the matching work, you MUST read: + +- `.agents/skills/graphify/SKILL.md` before tracing what an app change touches. +- `.agents/skills/see-the-app/SKILL.md` before writing or changing a scene of the app. +- The installed `impeccable` plugin skill before changing the website's layout or styling, + with the project's `PRODUCT.md` and `DESIGN.md`. + +You keep the public website in `apps/landing/` (Astro, static, published to GitHub Pages by +`.github/workflows/landing.yml`) true to the app as it is now. Stay idle until the lead +assigns a scope: a merged change, a release, a diff or a request to audit the whole site. +You write only files under `apps/landing/` and the docs' scenes in +`crates/study-showcase/src/docs.rs`; app code, README and copy changes belong to others. + +## What to watch + +Read the assigned diff, or `git log` and `git diff` since the last site update, for what +a visitor would notice: + +- new features, removed features and changed behaviour in the app; +- renamed or reworded UI copy in `crates/study-localization` (the site uses the app's + words for buttons, pages and settings); +- `README.md` and `PRODUCT.md`, which state the pitch and positioning the site follows; +- releases: version, supported platforms and download links (`.github/workflows/release.yml`, + `apps/desktop/packaging/`). + +Then update what they affect: + +- the landing page: `apps/landing/src/pages/index.astro` and `apps/landing/src/pages/it/index.astro`, with + their strings in `apps/landing/src/i18n/`; +- the docs: `apps/landing/src/content/docs/{en,it}/`, one page per feature, the same file + name in both languages (`.mdx` when it shows captures, `.md` otherwise); +- the captures of the app: scenes in `crates/study-showcase/src/docs.rs`, made by + `just docs-media` into `apps/landing/src/assets/shots/` and `clips/` as + `{scene}-{en|it}-{light|dark}`. They are never committed; the Landing workflow makes them + again on every deploy, so change the scene, never the file. + +## Rules + +- **English and Italian stay equal.** Every page, section, string and screenshot exists in + both, saying the same thing. Write Italian as a native speaker would, not word for word. +- **Never invent claims.** No customers, testimonials, user counts, benchmarks, prices, + dates or features the code does not have. Every statement must be traceable to the + code, `README.md` or `PRODUCT.md`; when you cannot verify one, leave it out and tell the + lead. Describe what the app does today, not plans. +- Remove or rewrite what a change made false rather than adding next to it. +- Links work under the `/study` base path; use the helpers the site already has. + +## Captures + +Every picture of the app on the site is a scene, made from the showcase sample on a private +desktop: never a screenshot taken by hand, and never the development data. A page shows one +as `![What it shows](shot:name "Caption")` or `clip:name`; a test in `study-showcase` +fails when a page names a scene that does not exist, or a scene no page shows. To change a +capture, change its scene's steps; for a new one, add a scene. Then make only those with +`just docs-media ` and look at every variant before using it: no hover highlights, +tooltips or half-rendered content. Tell the lead when a page needs state the showcase +sample does not have; seeding belongs to `scenario`. + +## Hand off + +Before reporting, check it and build it as the Landing workflow does: + +```sh +just check-crate study-showcase && just landing-check && just landing-build +``` + +They must pass with no warnings you introduced. Report to the lead once: what app change +you followed, the files changed in both languages, scenes changed, claims you left +out because you could not verify them, and the build result. Do not commit or push. diff --git a/.agents/team/team.md b/.agents/team/team.md index 8fa9c491..f7c39cf2 100644 --- a/.agents/team/team.md +++ b/.agents/team/team.md @@ -8,7 +8,8 @@ One team works across the project: lead, developer, PM, QA, student, reviewer an The lead coordinates; the developer primarily implements; the other roles contribute on assigned questions. Crates are architecture boundaries, not separate workspaces or teams. The seven regular roles share one extra slot for scenario preparation, upgrade -review or PR delivery. Do not create nested agents, per-crate teams, or duplicate sessions for the same task. +review, PR delivery or the website. Do not create nested agents, per-crate teams, or +duplicate sessions for the same task. Each role has a different lens. Availability does not require every role to inspect every change: the lead assigns only useful work, and unassigned agents stay idle. Product and @@ -132,3 +133,13 @@ information as unknown; never invent a model response and report it as observed Keep task-specific sample content and assumptions out of permanent agent memory. Store reproducible setup and limitations in the session handoff, and do not load old scenarios merely because their files survived a reset. Durable fixtures belong in code and tests. + +## Keeping the website in sync + +The website in `apps/landing/` follows the app. When a delivered change or a release alters +what a visitor would see or read (features, behaviour, UI copy, the README pitch, platforms), +the lead starts `site` in the shared optional slot with +`just agents site --session "$session" --no-attach`, sends it the change and base through +`agent prompt site` using the report-file pattern above, and stops it after its one result. +It writes only under `apps/landing/` and the docs' scenes, keeps English and Italian equal +and invents no claims. diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index a5278eea..66979b6b 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -43,6 +43,7 @@ USER root ARG GH_VERSION=2.102.0 ARG NODE_VERSION=22.23.3 +ARG BUN_VERSION=1.4.2 ARG HERDR_VERSION=0.9.3 ARG CLAUDE_VERSION=2.1.287 ARG CODEX_VERSION=0.159.3 @@ -66,6 +67,22 @@ rm /tmp/gh.deb gh pr create --help | grep -q -- --attach SH +# Bun builds and serves the website in apps/landing. +RUN <<'SH' +set -eu +case "$(uname -m)" in + aarch64) arch=aarch64; checksum=54328bbc2d9c8e0c9f892c544d66c57a83b84139e34909e5ee81758f1ac8fda7 ;; + x86_64) arch=x64; checksum=36368faef7527875d5ffa52e53cd48021741f2a83eb6208a8dd64068d422a913 ;; + *) echo "Unsupported Bun architecture: $(uname -m)" >&2; exit 1 ;; +esac +curl -fsSL -o /tmp/bun.zip "https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-${arch}.zip" +printf '%s /tmp/bun.zip\n' "$checksum" | sha256sum --check +python3 -c "import zipfile; zipfile.ZipFile('/tmp/bun.zip').extract('bun-linux-${arch}/bun', '/tmp')" +install -m 755 "/tmp/bun-linux-${arch}/bun" /usr/local/bin/bun +rm -r /tmp/bun.zip "/tmp/bun-linux-${arch}" +bun --version +SH + # Node for the npm-installed agents. RUN case "$(uname -m)" in aarch64) node_arch=arm64 ;; *) node_arch=x64 ;; esac \ && curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${node_arch}.tar.xz" \ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 203f1b05..6ee75a26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,6 @@ on: push: branches: [main] pull_request: - # The Demo workflow starts this on the pull request it opens, which starts nothing by itself. workflow_dispatch: permissions: @@ -20,8 +19,8 @@ concurrency: env: CARGO_TERM_COLOR: always -# Straight on the runner, no devcontainer (see .github/actions/setup). Lint and tests run in -# parallel, each with its own cache. +# Straight on the runner, no devcontainer (see .github/actions/setup). Lint, tests and the +# website run in parallel, each with its own cache. jobs: lint: name: Format, Clippy, docs and dependencies @@ -50,10 +49,24 @@ jobs: - name: Test run: just test + site: + name: Website + runs-on: ubuntu-24.04 + steps: + - name: Check out + uses: actions/checkout@v7 + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: apps/landing/package.json + # Types and content only: the captures it shows are made when it is published. + - name: Check + run: cd apps/landing && bun install --frozen-lockfile && bun run check + done: # The one required check: its name is in the `main` ruleset, so keep it. name: Test and build - needs: [lint, test] + needs: [lint, test, site] if: always() runs-on: ubuntu-24.04 steps: diff --git a/.github/workflows/demo.yml b/.github/workflows/demo.yml deleted file mode 100644 index 424f890e..00000000 --- a/.github/workflows/demo.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Demo - -# By hand only: records the README demo (`just demo`) from the branch it runs on and opens a -# pull request with the new assets/demo.gif. -on: workflow_dispatch - -permissions: - contents: read - -jobs: - demo: - name: Record and open a pull request - runs-on: ubuntu-24.04 - permissions: - contents: write - pull-requests: write - # To start CI on the pull request: one opened with this token starts nothing by itself. - actions: write - steps: - - name: Check out - uses: actions/checkout@v7 - - name: Set up - uses: ./.github/actions/setup - with: - # The private headless desktop the showcase records on, with software Vulkan. - packages: >- - sway grim wtype wf-recorder ffmpeg - libvulkan1 mesa-vulkan-drivers libgl1-mesa-dri libegl-mesa0 - # The app runs for real here, so it is built as in development. - optimize-dependencies: 'true' - - name: Record the demo - run: just demo - - name: Open a pull request - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - if git diff --quiet -- assets/demo.gif; then - echo "::notice::The demo did not change." - exit 0 - fi - branch="demo/$GITHUB_RUN_ID" - git switch -c "$branch" - git add assets/demo.gif - git -c user.name='github-actions[bot]' \ - -c user.email='41898282+github-actions[bot]@users.noreply.github.com' \ - commit -m "Update the demo" - git push origin "$branch" - gh pr create --base "$GITHUB_REF_NAME" --head "$branch" --title "Update the demo" \ - --body "Recorded by the Demo workflow from $GITHUB_SHA." - gh workflow run ci.yml --ref "$branch" diff --git a/.github/workflows/landing.yml b/.github/workflows/landing.yml new file mode 100644 index 00000000..57a24b04 --- /dev/null +++ b/.github/workflows/landing.yml @@ -0,0 +1,64 @@ +name: Landing + +# Builds the website in apps/landing/ (Astro, static) and publishes it to GitHub Pages at +# https://nicksan222.github.io/study/, so astro.config sets `base: '/study'`. +# +# Every capture of the app on the site is made here, from the code being deployed: the docs' +# stills and clips (`just docs-media`) and the README's demo GIF (`just demo`), which the +# site publishes at /study/demo.gif. None of them is committed, so any change to the app +# runs this to keep them current. +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +# One deployment at a time; a newer push waits instead of cancelling one in progress. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build + runs-on: ubuntu-24.04 + steps: + - name: Check out + uses: actions/checkout@v7 + - name: Set up + uses: ./.github/actions/setup + with: + # The private headless desktop the showcase records on, with software Vulkan. + packages: >- + sway grim wtype wf-recorder ffmpeg + libvulkan1 mesa-vulkan-drivers libgl1-mesa-dri libegl-mesa0 + # The app runs for real here, so it is built as in development. + optimize-dependencies: 'true' + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: apps/landing/package.json + # A fresh checkout has no captures, so this makes every one, then builds. + - name: Capture the app and build + run: just landing-build + - name: Upload + uses: actions/upload-pages-artifact@v5 + with: + path: apps/landing/dist + + deploy: + name: Deploy + needs: build + runs-on: ubuntu-24.04 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9aecebad..14dc3ba2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -107,7 +107,7 @@ jobs: - name: Test and package run: | cargo test --locked -p study-ai --lib updates - packaging/release.sh package ${{ matrix.platform }} + apps/desktop/packaging/release.sh package ${{ matrix.platform }} - name: Upload package uses: actions/upload-artifact@v7 with: @@ -133,6 +133,6 @@ jobs: path: dist merge-multiple: true - name: Build update feed - run: packaging/release.sh feed "$GITHUB_REF_NAME" + run: apps/desktop/packaging/release.sh feed "$GITHUB_REF_NAME" - name: Publish release run: gh release create "$GITHUB_REF_NAME" dist/* --verify-tag --title "$GITHUB_REF_NAME" --generate-notes diff --git a/.gitignore b/.gitignore index 6e9e28e4..88bfb18f 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,18 @@ core.* *.sqlite *.sqlite-* /dist/ + +# Design tool working files; its settings in .impeccable/config.json are kept. +**/.impeccable/* +!/.impeccable/config.json + +# Website dependencies and build output. +/apps/landing/node_modules/ +/apps/landing/dist/ +/apps/landing/.astro/ + +# Captures of the app, made from sample data on every website build (`just docs-media`, +# `just demo`), never committed. +/apps/landing/src/assets/shots/ +/apps/landing/src/assets/clips/ +/apps/landing/public/demo.gif diff --git a/.impeccable/config.json b/.impeccable/config.json index 91a24265..f0f2ad78 100644 --- a/.impeccable/config.json +++ b/.impeccable/config.json @@ -5,5 +5,17 @@ "maxFindings": 5, "maxChars": 8000 } + }, + "detector": { + "ignoreRules": [], + "ignoreFiles": [], + "ignoreValues": [ + { + "rule": "overused-font", + "value": "inter", + "createdAt": "2026-10-04T13:22:48.963Z", + "reason": "design decision: DESIGN.md pins Inter as the app's UI and display face; the website extends the app's world" + } + ] } } diff --git a/AGENTS.md b/AGENTS.md index 5e6f1779..40c07024 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,14 +68,16 @@ it and the app from zero, and `just desktop-watch` restarts the app on every cha | 3 | `study-pipeline` | `crates/study-pipeline` | One implementation per processor kind (fetchers, extractors, refiners, stages, enhancers), and the `Pipeline` that hands them to the jobs engine | | 4 | `study-app` | `crates/study-app` | The `App` service: the one store, runtime and bus, registering the pipeline, conversation and practice agents (titles, answers, quiz questions and grades), search, reviews, installs | | 5 | `study` | `apps/desktop` | The desktop app: windows, pages, the microphone | +| web | `study-landing` | `apps/landing` | The website (Astro on Bun, GitHub Pages): the landing page and one docs page per feature, in English and Italian, with captures of the app on the showcase sample; no Rust | | dev | `study-testkit` | `crates/study-testkit` | Real services for tests, run in Docker (a web server, …); a dev-dependency only | | dev | `study-seed` | `crates/study-seed` | Sample courses, sessions, files and study material written into a database, for `just reset-data`, tests and the showcase; never a normal dependency of the app | -| dev | `study-showcase` | `crates/study-showcase` | Records the README demo GIF (`just demo`): the app on sample data, driven on a private virtual desktop | +| dev | `study-showcase` | `crates/study-showcase` | Records the README demo GIF (`just demo`) and the docs' stills and clips (`just docs-media`): the app on sample data, driven on a private virtual desktop | ## Commands -- `just check`: fmt, Clippy with `-D warnings`, every test and doctest, and the API docs with - broken links denied. Run it before you call a change done. +- `just check`: fmt, Clippy with `-D warnings`, every test and doctest, the API docs with + broken links denied, and the website's type check (`just landing-check`). Run it before + you call a change done. - `just run`: starts the app and browser desktop in the container, keeping the development database. VS Code opens the host viewer; plain container shells print its URL. In the devcontainer it connects to the virtual desktop automatically. @@ -84,9 +86,12 @@ it and the app from zero, and `just desktop-watch` restarts the app on every cha (headless sway, a 1080p monitor by default, other screens by name), with `desktop-shot`, `desktop-click`, `desktop-record` and `wtype` to see and use it. The `.agents/skills/see-the-app` checklist says how. -- `just demo`: records the README's `assets/demo.gif` from sample data on its own private - desktop (no sign-in, no model, not the shared desktop). Edit the tour in - `crates/study-showcase/src/tour.rs`. +- `just demo`: records the README's demo GIF from sample data on its own private desktop + (no sign-in, no model, not the shared desktop), into `apps/landing/public/demo.gif`; the + site publishes it and the README shows it from there. `just docs-media` makes the docs' + stills and clips the same way. None of them is committed: the Landing workflow makes all + of them on every deploy, and `just landing` makes any that are missing. Edit the tour in + `crates/study-showcase/src/tour.rs`, the docs' scenes in `src/docs.rs`. - `just agents`: maintainer tooling in the default devcontainer. Herdr runs one project-wide team: lead, developer, PM, QA, student, reviewer and pushback, using Claude Code with the mounted subscription login. Select roles for smaller work @@ -98,6 +103,8 @@ it and the app from zero, and `just desktop-watch` restarts the app on every cha Herdr workspace menu offers the same reset and a stop action. Code and logins are kept. `just agents-doctor` checks setup and `just agents-usage` reports recorded local tokens. Keep the handoff in `target/agents//task.md`, not tracked design documents. +- `just landing`: serves the website with live reload; `just landing-build` builds it as the + Landing workflow publishes it. It uses Bun, never npm. - `just check-crate `: `just check` for one package, a focused development loop. - `just test`, `just lint`, `just fmt`, `just docs`: the individual steps. Tests use real files and real services where CI can run them: services run in Docker through diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c1b50e2..9243d788 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,8 @@ Thank you for helping with Study. `src/lib.rs`. - **One branch per change.** Open a pull request to `main`; nothing goes to `main` directly. - **Run `just check` before you push.** It is what CI runs: formatting, Clippy with warnings - denied, every test and the API docs. Some tests run real services in Docker. + denied, every test, the API docs and the website's type check. Some tests run real + services in Docker. - **Every visible word comes from `study-localization`, in English and Italian.** If you can't write the Italian, say so in the pull request. - **Report a vulnerability privately**, as [SECURITY.md](SECURITY.md) says, never in an @@ -80,11 +81,14 @@ Close Study before `just reset-data`; a shared data lock keeps app runs and rese Both follow `XDG_DATA_HOME` and `XDG_CACHE_HOME`. `just run` uses `target/dev-data` instead. -## The README demo +## Captures of the app -The demo GIF is recorded by hand. Run the Demo workflow from the Actions tab; it records -`assets/demo.gif` with `just demo` from sample data and opens a pull request with the new GIF. -Edit the tour in `crates/study-showcase/src/tour.rs`. +The README's demo GIF and every still and clip on the website are made from the code, never +committed. The Landing workflow makes them all on every push to main, from sample data on a +private virtual desktop, and publishes them with the site; the README shows the GIF from +there. Locally, `just landing` and `just landing-build` make any that are missing, and +`just docs-media` and `just demo` make them again after a change. Edit the GIF's tour in +`crates/study-showcase/src/tour.rs` and the docs' scenes in `crates/study-showcase/src/docs.rs`. ## The agent team @@ -103,7 +107,8 @@ maintainers: lead, developer, PM, QA, student, reviewer and pushback. Give the lead the task. It splits the change into small slices, each built and reviewed on its own; QA and `just check` run once on the result. On demand it starts `scenario` (seeded test states), `upgrade-reviewer` (does an existing install still work?) and `pr-maker` -(commits, pushes and opens the pull request, never merges). These three share one slot. +(commits, pushes and opens the pull request, never merges), and `site` keeps the website in +`apps/landing/` in step with the app, in English and Italian. These four share one slot. Models and roles live in `.agents/team/fleet.toml`, the working agreement in `.agents/team/team.md`, and each role's checklist in `.agents/team/roles/`. The handoff is diff --git a/Cargo.lock b/Cargo.lock index 081489c3..e73efe33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9274,6 +9274,7 @@ name = "study-ui" version = "0.1.0" dependencies = [ "gpui-kit", + "image", "study-diagram", ] diff --git a/DESIGN.md b/DESIGN.md index be90a588..edb1d0a0 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -30,6 +30,36 @@ colors: danger-light: "#c4302b" highlighter-fill: "#f2d24b" on-highlighter: "#1a1600" + site-blue-bg-light: "#ebf3f8" + site-blue-chip-light: "#d3e5ef" + site-blue-ink-light: "#24516e" + site-green-bg-light: "#edf4ee" + site-green-chip-light: "#d5e9d8" + site-green-ink-light: "#2c5a37" + site-orange-bg-light: "#fbf0e4" + site-orange-chip-light: "#f7dcc2" + site-orange-ink-light: "#7a4519" + site-purple-bg-light: "#f4f0f8" + site-purple-chip-light: "#e3d8ee" + site-purple-ink-light: "#57397a" + site-pink-bg-light: "#faeff4" + site-pink-chip-light: "#f2d6e3" + site-pink-ink-light: "#7d3256" + site-blue-bg-dark: "#1b252c" + site-blue-chip-dark: "#23394a" + site-blue-ink-dark: "#a9cde3" + site-green-bg-dark: "#1c261f" + site-green-chip-dark: "#25392b" + site-green-ink-dark: "#a8d3b1" + site-orange-bg-dark: "#2a2219" + site-orange-chip-dark: "#45311f" + site-orange-ink-dark: "#eec39c" + site-purple-bg-dark: "#241f2b" + site-purple-chip-dark: "#392d48" + site-purple-ink-dark: "#cfb8ea" + site-pink-bg-dark: "#2a1e24" + site-pink-chip-dark: "#45293a" + site-pink-ink-dark: "#ebb5cd" typography: display: fontFamily: "Inter, system-ui, sans-serif" @@ -66,6 +96,33 @@ typography: fontSize: "16px" fontWeight: 400 lineHeight: "22px" + site-display: + fontFamily: "Inter, system-ui, sans-serif" + fontSize: "clamp(44px, 5.4vw, 76px)" + fontWeight: 600 + lineHeight: 1.02 + letterSpacing: "-0.035em" + site-heading: + fontFamily: "Inter, system-ui, sans-serif" + fontSize: "clamp(32px, 3.6vw, 48px)" + fontWeight: 600 + lineHeight: 1.08 + letterSpacing: "-0.03em" + site-lead: + fontFamily: "Inter, system-ui, sans-serif" + fontSize: "19px" + fontWeight: 400 + lineHeight: 1.55 + site-body: + fontFamily: "Inter, system-ui, sans-serif" + fontSize: "17px" + fontWeight: 400 + lineHeight: 1.6 + site-hand: + fontFamily: "Excalifont, Inter, system-ui, sans-serif" + fontSize: "23px" + fontWeight: 400 + lineHeight: 1.25 rounded: sm: "6px" md: "8px" @@ -154,6 +211,35 @@ components: width: "680px" page-column: width: "960px" + site-tile: + rounded: "{rounded.xl}" + padding: "clamp(16px, 2vw, 32px)" + site-paper-light: + backgroundColor: "{colors.canvas-light}" + rounded: "{rounded.lg}" + padding: "24px" + site-paper-dark: + backgroundColor: "{colors.canvas-dark}" + rounded: "{rounded.lg}" + padding: "24px" + site-tab-chip: + rounded: "{rounded.md}" + size: "40px" + site-button-large: + backgroundColor: "{colors.highlighter-fill}" + textColor: "{colors.on-highlighter}" + rounded: "{rounded.md}" + height: "48px" + padding: "0 24px" + site-frame: + width: "1200px" + site-docs-sidebar: + rounded: "{rounded.xl}" + width: "260px" + site-docs-page: + width: "760px" + site-reading-measure: + width: "680px" --- # Design System: Study @@ -180,7 +266,7 @@ six sizes; diagrams keep their hand-drawn Excalifont. The interface scales as on **Key Characteristics:** - Neutral tonal surfaces: sidebar, canvas and raised layers, with hairlines only where tone can't separate. -- One accent, the highlighter, used for learning moments, never for decoration or brand. +- One accent, the highlighter, used for learning moments, never for decoration or brand; the logo is its one exception. - A notebook, not a chat: the learner's notes are the page, read at a comfortable measure. - Background work folds into one quiet line that opens on demand. - Six type sizes, a 4-point spacing scale, four radii. @@ -226,6 +312,14 @@ failure. countdowns, `@study`, citations, practice and focus. If a use isn't about remembering, understanding or practising, it's neutral. At most one highlighter-filled control per screen. +**The Highlighter's Tile Rule.** The logo is the highlighter's own tile: a `highlighter-fill` +rounded square (radius 15 on a 64 grid) holding an open book in `#1a1600` ink on `#fffdf4` +pages, one line of the right page washed yellow. It is the one place the highlighter means +the brand, and it stays yellow in both themes. The masters are +`assets/logo/study.svg` and, under 40px (title bars, favicons), `study-small.svg`, the +same book without text lines; the app, the website, the favicon and the installers' icons +are all made from them. It is never recoloured, outlined or set on a yellow ground. + **The Quiet Success Rule.** Done is normal, so it isn't coloured: finished work is an `ink-3` line with a check. Running is a shimmer, waiting is `ink-3` text. Only failure gets colour, in `danger`, always with what to do next. Recording is running work too: its live dot and @@ -510,3 +604,129 @@ bubble. - **Don't** colour a learner's wrong answer `danger`: it is a result, not a failure. Failure is the app's, and comes with a fix. - **Don't** introduce a type size, radius or colour outside this file; change this file first. + +## Website + +The website (`apps/landing`, landing and docs, English and Italian) is the Highlighted +Notebook at marketing scale: the app's tokens, fonts, radii and spacing in both themes +(`src/styles/global.css`), warmed by two things the app does not have, notebook-tab tints +and a hand-drawn margin. Everything above holds on the site except where this section says +otherwise. The site follows the reader's `prefers-color-scheme`. + +### Colour +- **Notebook tabs** (`site--bg`, `-chip`, `-ink`, blue, green, orange, purple, pink, in + both themes): one soft tint per story section, set by a `tint-` class. The `bg` is the + section's tile, the `chip` its icon chip (40px, medium radius, at the section's lead; 24 to + 32px inside its tiles), the `ink` the chip's glyph and short labels in that tile. Never on a + paragraph, a heading or a button. Each tint means one thing wherever it appears, in its own + section and in the hero's previews of it: green is Answers, purple is Quizzes, orange is + Flashcards, pink is Diagrams, blue is the material and its home (the Material and Yours + sections). A tint names a section, not a kind of thing: the files in the material tile all + take the section's blue. This is the site's one exception to the No Rainbow Rule; the app + keeps it. +- **Highlighter** yellow is not a tab. It keeps its reserved uses: learning moments (the + phrase washes in the hero passage, the `@study` mention, cited passages, a right answer, a + pressed rating, the caret), focus, and Download. + Download is the one section without a hue: its tile is `sidebar`, so the only yellow in it + is the app's icon (the logo, large and tilted a little, as it sits in a dock) and the + Download button. The other platforms are one line of links, never a table. One + highlighter button per screen: Download in the hero; the nav's Download is quiet there. +- A diagram's node fills use tab chip tones, as content (the No Rainbow Rule's drawing + exemption). + +### Type +Inter at display scale with tight tracking: `site-display` for the one hero headline, +`site-heading` for section titles (docs H1 is 34 to 48px, same tracking), `site-lead` for +section and docs leads in `ink-2`, `site-body` (17/1.6) for page text and docs prose (17/1.7). +Tile titles are 19px 600; product pieces inside tiles keep the app's sizes (13 to 17px). +Excalifont (`site-hand`, 21 to 24px, `ink-2`) is for margin notes and inside diagrams only. +Sentence case throughout. + +### Layout +- A 1200px frame with a `clamp(16px, 4vw, 48px)` gutter. Sections sit 96 to 168px apart + (`clamp(96px, 11vw, 168px)`); no rules between them. +- **Hero:** five columns of headline, lead and Download beside seven of the source passage + (`5fr 7fr`), stacking under 1080px. +- **Story section:** a 12-column grid, 24px gaps: the lead (tab chip, heading, body, + `arrow-right` links to its docs pages) over columns 1 to 7 at the 680px measure, a margin + note in 8 to 12, then tiles. Paired tiles split 5/7 or 7/5 and stretch to the same height. + Under 900px everything takes the full width and wide captures drop out. +- **Docs:** a 260px tonal sidebar (`sidebar` tone, extra-large radius, sticky under the bar) + beside a page of at most 760px whose lead, prose and previous/next links stop at the 680px + measure. Groups are caption `ink-3` labels; the current page is a `selected` pill in 500 + weight. Under 900px the sidebar becomes a disclosure above the page. Tables are the only + ruled thing. +- **Bar and footer:** a 64px sticky bar on canvas at 88% with a background blur; the footer + is a `sidebar` band. + +### Tiles and captures +- **Tile** (`site-tile`): extra-large radius, the section's tint (or `fill`), no border, no + shadow. Product pieces sit on it as `site-paper-*`: `canvas`, large radius, 24px padding. + The passage and the data sketch use `sidebar` instead of a tint. +- **Built pieces, not pictures:** the passage, cited answer, quiz question, flashcard and + diagram are working HTML in the app's components (citation chip, mention, quiet rating + buttons, choices on `hover`). +- **Captures** (`Shot.astro`) are real app pages, 1440 by 900 points, in the reader's theme + and language. Each is cropped in those points to the part that proves the point and shown + at no less than the app's own size, so no app text renders under about 11px; a crop sits in + a large-radius `canvas` frame with an `ink-3` caption, the first naming the sample course. A rough mark (bracket, loop) may + sit on a crop where a learner would circle it. + +### Hand-drawn margin +- **Notes** are `.hand .hand-note`: Excalifont in `ink-2`, rotated within -3 to +3 degrees + (set per note with `--r`), short, pointing at the product with a stroke. They annotate; + they never carry a label or a heading. +- **Strokes** come from `rough.ts`'s seeded pen (each build draws the same): arrows with + open two-flick heads, loose rings, drifting underlines, brackets, checks, a star, a pencil. + Every line bows slightly and is drawn twice; the second `.thin` pass is 1.1px at 55%, + 0.8px off the 2px first. Round caps and joins, `currentColor`, usually `ink-2`. + +### Anchored ink +Arrows and marks that point at something are empty `svg`s inside a `[data-ink-scope]`: +`data-arrow` with `data-from` (the note) and `data-to` (the target), or `data-mark` (`ring`, +`underline`, `double`, `bracket`, `check`, `star`) with `data-to`. `scripts/anchor.ts` measures +and draws them, and redraws them on resize, font load and `relayout`; they are never placed by +hand. A ring is sized to its letters' ink, not the line box, and stops halfway to the next +line, so it never crosses a neighbouring line. An arrow takes the cleanest route that crosses +no text or picture; when there is none (or the ends are missing, hidden, overlapping or too +close) the runtime hides it and says why in `data-ink-hidden`. + +### Motion +- **Every sequence finishes within about 1.2s** of coming into view, its first ink to its last; + slower ones read as far too long. The text in one piece shares a budget of `PIECE_MS` (800ms) + and types faster when it has more units. +- **Durations come from `scripts/pace.ts`** (`DRAW_MS` 300, `HEAD_MS` 90, `FADE_MS` 160, + `STAGGER_MS` 50, `STEP_MS` 120, `PIECE_MS` 800, `CHAR_MS` 10 to 14, `WORD_MS` 20 to 28, + `HAND_WORD_MS` 20), or in CSS from the `--pace-draw`, `--pace-fade` and `--pace-step` + properties it sets on the root. That covers every inked or typed sequence. Interface feedback + (hover, press, a tab or panel switching, 100 to 260ms) and ambient loops (the typing dots, + the listening bars, the caret) keep their own short durations in the component's CSS. +- A piece (`[data-ink]`) inks once, when 15% of it is in view; pieces arriving together start + 100ms apart. Strokes draw along their length in 300ms ease-out, 50ms apart (the last by + 240ms); the text starts within 120ms, while they finish. Anchored arrows draw two at a time + after their note has written, the shaft then a 90ms head. +- **Handwriting** reveals word by word, 20ms apart, each word wiping in from the left; the + note settles into its rotation over 300ms. **Typing** is what the app writes: 10 to 14ms a + letter or 20 to 28ms a word, with the `highlighter-ink` caret on the newest unit only. + Citations and steps fade or pop in 160ms. +- Loops run only while in view and stop once touched: the hero's marks change every 3.5s, the + Learn tabs every 9s. +- **Reduced motion and no script show the final state.** Text is always in the DOM; the + `js-ink` class, set before first paint only with script and motion allowed, hides what has + not been set down. Without it every piece, text and anchored mark is complete at once. + +### Named Rules +**The Tab Not Paint Rule.** A section's tint lives on its tiles and chips. If it touches a +paragraph, heading or button, it's decoration; take it off. + +**The Margin Is Not Chrome Rule.** Excalifont and rough strokes annotate the product. They are +never a label, a heading, a button or an icon. + +**The Measured Ink Rule.** An arrow or mark that points at something is anchored and measured +by the runtime. If it is positioned by hand, it will miss at some width; anchor it. + +**The Quick Ink Rule.** A sequence is done within about 1.2s, on `pace.ts` durations. If it +needs a number of its own, it is too slow or off the pace. + +**The Readable Capture Rule.** A capture shows app text at the app's size or not at all: crop +it, don't shrink it. diff --git a/Justfile b/Justfile index 9140f85e..1c70ba28 100644 --- a/Justfile +++ b/Justfile @@ -162,12 +162,34 @@ test: trim docs: RUSTDOCFLAGS="-D warnings" cargo doc --locked --workspace --no-deps --document-private-items -# Record the README's demo GIF (assets/demo.gif, or `output`): the built app on sample data, on a private virtual desktop. Needs no sign-in and runs no model. +# Record the README's demo GIF (apps/landing/public/demo.gif, which the website publishes, or `output`): the built app on sample data, on a private virtual desktop. Needs no sign-in and runs no model. [positional-arguments] -demo output="assets/demo.gif": +demo output="apps/landing/public/demo.gif": cargo build --locked -p study cargo run --locked -p study-showcase -- "$1" +# Make the website's docs stills and clips (apps/landing/src/assets/shots and clips) in every language and theme, the same way; name scenes or variants (`search it-dark`) to make only those, or `missing` for those not made yet. +docs-media *only: + cargo build --locked -p study + cargo run --locked -p study-showcase -- docs {{only}} + +# Make the website's captures of the app that are missing: none is committed, so a fresh checkout (and every Landing workflow run) makes them all, and a new scene only its own. `just docs-media` and `just demo` make them again after a change. +landing-media: + just docs-media missing + [ -s apps/landing/public/demo.gif ] || just demo + +# Serve the website (apps/landing: the landing page and docs) with live reload at localhost:4321/study/. +landing: landing-media + cd apps/landing && bun install --frozen-lockfile && bun run dev --host + +# Build the website into apps/landing/dist, as the Landing workflow publishes it, with every capture of the app. +landing-build: landing-media + cd apps/landing && bun install --frozen-lockfile && bun run build + +# Type-check the website's pages, components and content without building it, so it needs no captures. +landing-check: + cd apps/landing && bun install --frozen-lockfile && bun run check + # Build the distributable desktop binary. build: cargo build --locked --release -p study @@ -175,14 +197,14 @@ build: # Build and sign the native installer/AppImage (see CONTRIBUTING.md for signing setup). [positional-arguments] package platform: - packaging/release.sh package "$1" + apps/desktop/packaging/release.sh package "$1" # Check dependencies for known vulnerabilities, licenses and sources (deny.toml). deny: cargo deny --locked check # Run checks required for pull requests. -check: check-shell fmt-check lint deny test docs +check: check-shell fmt-check lint deny test docs landing-check # Run the same checks for one package only, e.g. `just check-crate study-core`: a focused development loop. [positional-arguments] @@ -208,7 +230,7 @@ test-transcription-e2e: # Both spellings start the same team; neither resets existing conversations. alias agent := agents -# Start the project-wide seven-role team, or selected roles, in Herdr's study session. Options: --session NAME, --fresh, --no-attach, --dry-run. +# Start the project-wide seven-role team, or selected roles (the on-demand scenario, upgrade-reviewer, pr-maker and site start only by name), in Herdr's study session. Options: --session NAME, --fresh, --no-attach, --dry-run. [positional-arguments] agents *args: python3 .agents/team/agents.py up "$@" @@ -235,7 +257,7 @@ agents-usage *args: # Lint the devcontainer and packaging shell scripts. check-shell: - shellcheck -x .devcontainer/initialize.sh .devcontainer/shell-env.sh .devcontainer/desktop/*.sh packaging/release.sh + shellcheck -x .devcontainer/initialize.sh .devcontainer/shell-env.sh .devcontainer/desktop/*.sh apps/desktop/packaging/release.sh # Prepare a recording for a PR: first 15 seconds, no audio, at most 8 MB. Use .mp4 or .gif. [positional-arguments] diff --git a/README.md b/README.md index 215b1716..597f18f2 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@
+ + # Study **A calm place for everything you learn.** @@ -11,7 +13,7 @@ citations, and turns them into flashcards and practice. [Contribute](CONTRIBUTING.md) · [Report a bug](https://github.com/nicksan222/study/issues) -![A short tour of Study: Home, an answer citing the lecture recording, a diagram, a flashcard review, a quiz and search](assets/demo.gif) +![A short tour of Study: Home, an answer citing the lecture recording, a diagram, a flashcard review, a quiz and search](https://nicksan222.github.io/study/demo.gif)
diff --git a/packaging/Entitlements.plist b/apps/desktop/packaging/Entitlements.plist similarity index 100% rename from packaging/Entitlements.plist rename to apps/desktop/packaging/Entitlements.plist diff --git a/packaging/Info.plist b/apps/desktop/packaging/Info.plist similarity index 100% rename from packaging/Info.plist rename to apps/desktop/packaging/Info.plist diff --git a/apps/desktop/packaging/icon.png b/apps/desktop/packaging/icon.png new file mode 100644 index 00000000..43b8db62 Binary files /dev/null and b/apps/desktop/packaging/icon.png differ diff --git a/packaging/packager.json b/apps/desktop/packaging/packager.json similarity index 68% rename from packaging/packager.json rename to apps/desktop/packaging/packager.json index a5ef1e59..e75d7ac5 100644 --- a/packaging/packager.json +++ b/apps/desktop/packaging/packager.json @@ -5,12 +5,12 @@ "homepage": "https://github.com/nicksan222/study", "publisher": "Study", "category": "Education", - "icons": ["packaging/icon.png"], + "icons": ["apps/desktop/packaging/icon.png"], "binaries": [{"path": "study", "main": true}], "macos": { "minimumSystemVersion": "12.0", - "infoPlistPath": "packaging/Info.plist", - "entitlements": "packaging/Entitlements.plist" + "infoPlistPath": "apps/desktop/packaging/Info.plist", + "entitlements": "apps/desktop/packaging/Entitlements.plist" }, "nsis": {"installMode": "currentUser"} } diff --git a/packaging/release.sh b/apps/desktop/packaging/release.sh similarity index 96% rename from packaging/release.sh rename to apps/desktop/packaging/release.sh index edf0cbed..d5f11569 100755 --- a/packaging/release.sh +++ b/apps/desktop/packaging/release.sh @@ -7,7 +7,8 @@ # # Platform keys must match cargo-packager-updater's: they are the keys of latest.json. set -euo pipefail -cd "$(dirname "$0")/.." +# From the repository root, where Cargo and dist/ live. +cd "$(dirname "$0")/../../.." PLATFORMS=(linux-x86_64 linux-aarch64 macos-aarch64 windows-x86_64) @@ -39,7 +40,7 @@ package() { --arg identity "${APPLE_SIGNING_IDENTITY:-}" \ '. + {version: $version, targetTriple: $target, binariesDir: $bins, outDir: $out} | if $identity != "" then .macos.signingIdentity = $identity else . end' \ - packaging/packager.json) + apps/desktop/packaging/packager.json) local formats=("$format") [[ $format == app ]] && formats=(app dmg) export APPIMAGE_EXTRACT_AND_RUN=1 diff --git a/apps/desktop/src/ui/screens/shell/page/pages/onboarding/components/art.rs b/apps/desktop/src/ui/screens/shell/page/pages/onboarding/components/art.rs index 57af663b..57aad5fb 100644 --- a/apps/desktop/src/ui/screens/shell/page/pages/onboarding/components/art.rs +++ b/apps/desktop/src/ui/screens/shell/page/pages/onboarding/components/art.rs @@ -1,4 +1,4 @@ -//! The welcome tour's pictures: the brand tile, the three things Study does, and a sample +//! The welcome tour's pictures: the app's mark, the three things Study does, and a sample //! session that shows notes being written down and one note asking the assistant. Glyphs are //! monochrome (`DESIGN.md`, the No Rainbow Rule); only the `@study` mention is highlighted. @@ -10,23 +10,8 @@ use study_localization::{Locale, Message, text}; use study_ui::units; /// The app's mark, large, for the first step. -pub(in crate::ui::screens::shell::page::pages::onboarding) fn brand_tile( - locale: Locale, - cx: &App, -) -> Div { - let unit = units(cx); - let colors = cx.theme().colors; - div() - .size(unit(76.)) - .flex() - .items_center() - .justify_center() - .rounded(unit(study_ui::scale::RADIUS_XL)) - .bg(colors.primary) - .text_color(colors.primary_foreground) - .text_size(unit(study_ui::scale::TEXT_DISPLAY)) - .font_weight(FontWeight::SEMIBOLD) - .child(text(locale, Message::BrandMark)) +pub(in crate::ui::screens::shell::page::pages::onboarding) fn brand_tile(cx: &App) -> Div { + div().child(study_ui::logo(76., cx)) } /// The step's glyph above its title, in the secondary ink. diff --git a/apps/desktop/src/ui/screens/shell/page/pages/onboarding/page.rs b/apps/desktop/src/ui/screens/shell/page/pages/onboarding/page.rs index af80d078..a68126c8 100644 --- a/apps/desktop/src/ui/screens/shell/page/pages/onboarding/page.rs +++ b/apps/desktop/src/ui/screens/shell/page/pages/onboarding/page.rs @@ -248,7 +248,7 @@ impl AppShell { ) -> Option { let step = self.onboarding.step?; let hero = match step { - Step::Welcome => brand_tile(locale, cx), + Step::Welcome => brand_tile(cx), Step::Look => hero_badge(IconName::Palette, cx), Step::Notes => hero_badge(IconName::NotebookPen, cx), Step::Ai => hero_badge(IconName::Cpu, cx), diff --git a/apps/desktop/src/ui/screens/shell/page/pages/pipelines/page.rs b/apps/desktop/src/ui/screens/shell/page/pages/pipelines/page.rs index 46f52f66..3fcebc6f 100644 --- a/apps/desktop/src/ui/screens/shell/page/pages/pipelines/page.rs +++ b/apps/desktop/src/ui/screens/shell/page/pages/pipelines/page.rs @@ -24,7 +24,7 @@ pub(super) enum Filter { #[default] All, Running, - /// Queued, or blocked until the jobs it waits for end. + /// Queued, blocked until the jobs it waits for end, or waiting for setup. Queued, Failed, /// Cancelled. @@ -65,7 +65,10 @@ impl Filter { match self { Self::All => true, Self::Running => status == JobStatus::Running, - Self::Queued => matches!(status, JobStatus::Queued | JobStatus::Blocked), + Self::Queued => matches!( + status, + JobStatus::Queued | JobStatus::Blocked | JobStatus::Waiting + ), Self::Failed => status == JobStatus::Failed, Self::Stopped => status == JobStatus::Cancelled, Self::Done => status == JobStatus::Succeeded, @@ -353,6 +356,17 @@ mod tests { use crate::ui::screens::shell::page::workers::WorkersState; use gpui_kit::TestAppContext; + #[test] + fn every_status_shows_in_one_group() { + for status in JobStatus::ALL { + let groups = Filter::GROUPS + .iter() + .filter(|group| group.matches(*status)) + .count(); + assert_eq!(groups, 1, "{status:?}"); + } + } + #[gpui_kit::test] fn the_overview_lists_every_job_and_filters_by_status(cx: &mut TestAppContext) { let app = TempApp::new(); diff --git a/apps/landing/astro.config.mjs b/apps/landing/astro.config.mjs new file mode 100644 index 00000000..4f85773b --- /dev/null +++ b/apps/landing/astro.config.mjs @@ -0,0 +1,41 @@ +// The Study website: the landing page and the docs, in English (/) and Italian (/it/), +// built to static files for GitHub Pages under /study. +// +// Docs pages that show captures of the app are MDX, so a plain Markdown image can render +// through an Astro component: `![alt](shot:name "caption")` and `![alt](clip:name "caption")` +// stay ordinary Markdown to write and translate, while `Media.astro` picks the capture in +// the page's language and the reader's theme and makes its sizes at build time, which a +// remark or rehype plugin on plain Markdown cannot do (it cannot call `getImage` or render +// a component). Pages without media stay `.md`. +import { defineConfig } from 'astro/config'; +import mdx from '@astrojs/mdx'; + +/** + * Lifts a paragraph that holds only a `shot:` or `clip:` image out to the image itself, so + * the figure it renders as never sits inside a `

`. + */ +function remarkDocsMedia() { + const media = (node) => node.type === 'image' && /^(shot|clip):/.test(node.url); + const lift = (node) => { + if (!node.children) return; + node.children = node.children.map((child) => + child.type === 'paragraph' && child.children.length === 1 && media(child.children[0]) ? child.children[0] : child, + ); + node.children.forEach(lift); + }; + return lift; +} + +export default defineConfig({ + site: 'https://nicksan222.github.io', + base: '/study', + trailingSlash: 'always', + i18n: { + defaultLocale: 'en', + locales: ['en', 'it'], + routing: { prefixDefaultLocale: false }, + }, + integrations: [mdx({ remarkPlugins: [remarkDocsMedia] })], + // The logo masters, fonts and icons are the app's own files, outside this folder. + vite: { server: { fs: { allow: ['../..'] } } }, +}); diff --git a/apps/landing/bun.lock b/apps/landing/bun.lock new file mode 100644 index 00000000..853d0736 --- /dev/null +++ b/apps/landing/bun.lock @@ -0,0 +1,1131 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "study-site", + "dependencies": { + "@astrojs/mdx": "^4", + "astro": "^5.18.2", + "satori": "^0.35.0", + "sharp": "^0.35.5", + }, + "devDependencies": { + "@astrojs/check": "^0.9.10", + "@types/node": "^26.6.4", + "typescript": "^6", + }, + }, + }, + "packages": { + "@astrojs/astro2tsx": ["@astrojs/astro2tsx@0.1.2", "", { "dependencies": { "@emnapi/core": "1.11.3", "@napi-rs/wasm-runtime": "1.2.4" } }, "sha512-Uj/M9zbrhu5cIZEvWAGTbQJRKSxXWJ/E59BZwt7F+chrKhbfttW+9rkS/l+1QxoJP1KU3UnpM62M7yszfAI0WA=="], + + "@astrojs/check": ["@astrojs/check@0.9.10", "", { "dependencies": { "@astrojs/language-server": "^2.16.7", "chokidar": "^4.0.3", "kleur": "^4.1.5", "yargs": "^18.0.0" }, "peerDependencies": { "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "astro-check": "./bin/astro-check.js" } }, "sha512-zgx/UQMozdjOa3bOxjgeCFdtpE3c9rRX6xHwa+2QXvy8z8Akifu2AtubHyv/zzC2znO8dl8fFWL4K+Ba9kS8HQ=="], + + "@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="], + + "@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.6", "", {}, "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q=="], + + "@astrojs/language-server": ["@astrojs/language-server@2.17.1", "", { "dependencies": { "@astrojs/astro2tsx": "^0.1.0", "@astrojs/yaml2ts": "^0.2.4", "@volar/kit": "~2.4.28", "@volar/language-core": "~2.4.28", "@volar/language-server": "~2.4.28", "@volar/language-service": "~2.4.28", "muggle-string": "^0.4.1", "tinyglobby": "^0.2.17", "volar-service-css": "0.0.71", "volar-service-emmet": "0.0.71", "volar-service-html": "0.0.71", "volar-service-prettier": "0.0.71", "volar-service-typescript": "0.0.71", "volar-service-typescript-twoslash-queries": "0.0.71", "volar-service-yaml": "0.0.71", "vscode-html-languageservice": "^5.6.2", "vscode-uri": "^3.2.0" }, "peerDependencies": { "prettier": "^3.0.0", "prettier-plugin-astro": ">=0.11.0" }, "optionalPeers": ["prettier", "prettier-plugin-astro"], "bin": { "astro-ls": "./bin/nodeServer.js" } }, "sha512-gG7bvMVNIXgoSe1pM9dI1HsSDLU1EIc/dSAYQ0DuReLyLkOBjsiqvaRYgQltWaldLxh0bNEqjgreLfjd9OnUxQ=="], + + "@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="], + + "@astrojs/mdx": ["@astrojs/mdx@4.3.14", "", { "dependencies": { "@astrojs/markdown-remark": "6.3.11", "@mdx-js/mdx": "^3.1.1", "acorn": "^8.15.0", "es-module-lexer": "^1.7.0", "estree-util-visit": "^2.0.0", "hast-util-to-html": "^9.0.5", "piccolore": "^0.1.3", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "remark-smartypants": "^3.0.2", "source-map": "^0.7.6", "unist-util-visit": "^5.0.0", "vfile": "^6.0.3" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-FBrqJQORVm+rkRa2TS5CjU9PBA6hkhrwLVBSS9A77gN2+iehvjq1w6yya/d0YKC7osiVorKkr3Qd9wNbl0ZkGA=="], + + "@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="], + + "@astrojs/telemetry": ["@astrojs/telemetry@3.3.0", "", { "dependencies": { "ci-info": "^4.2.0", "debug": "^4.4.0", "dlv": "^1.1.3", "dset": "^3.1.4", "is-docker": "^3.0.0", "is-wsl": "^3.1.0", "which-pm-runs": "^1.1.0" } }, "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ=="], + + "@astrojs/yaml2ts": ["@astrojs/yaml2ts@0.2.4", "", { "dependencies": { "yaml": "^2.8.3" } }, "sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/parser": ["@babel/parser@7.29.9", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="], + + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@capsizecss/unpack": ["@capsizecss/unpack@4.0.1", "", { "dependencies": { "fontkitten": "^1.0.3" } }, "sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ=="], + + "@emmetio/abbreviation": ["@emmetio/abbreviation@2.3.3", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA=="], + + "@emmetio/css-abbreviation": ["@emmetio/css-abbreviation@2.1.8", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw=="], + + "@emmetio/css-parser": ["@emmetio/css-parser@0.4.1", "", { "dependencies": { "@emmetio/stream-reader": "^2.2.0", "@emmetio/stream-reader-utils": "^0.1.0" } }, "sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ=="], + + "@emmetio/html-matcher": ["@emmetio/html-matcher@1.3.0", "", { "dependencies": { "@emmetio/scanner": "^1.0.0" } }, "sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ=="], + + "@emmetio/scanner": ["@emmetio/scanner@1.0.4", "", {}, "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA=="], + + "@emmetio/stream-reader": ["@emmetio/stream-reader@2.2.0", "", {}, "sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw=="], + + "@emmetio/stream-reader-utils": ["@emmetio/stream-reader-utils@0.1.0", "", {}, "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A=="], + + "@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.4" }, "os": "darwin", "cpu": "x64" }, "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw=="], + + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "os": "freebsd" }, "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.4", "", { "os": "linux", "cpu": "arm" }, "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.4", "", { "os": "linux", "cpu": "none" }, "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.4" }, "os": "linux", "cpu": "arm" }, "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.4" }, "os": "linux", "cpu": "none" }, "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.4" }, "os": "linux", "cpu": "s390x" }, "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "cpu": "none" }, "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.5", "", { "os": "win32", "cpu": "x64" }, "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], + + "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g=="], + + "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], + + "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.64.0", "", { "os": "android", "cpu": "arm" }, "sha512-FLLpX941CD/7aBvX3GqLGBM9OvY5PGXTryPJkG5r5/OuBpZpV5k45Uo3RxAvqd35tsyc3hH1bhtz921IKzoIzg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.64.0", "", { "os": "android", "cpu": "arm64" }, "sha512-iI32XM/jnDYJsvx5ai/za/7gWO9XoSeFFtU06/ZCaRgyilfJOjaopD3nOpk2WrTCGOyeDt4hleUiGOAnd5tHNg=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.64.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dRBZlbt2sEtF8Mn4MODThze9fIPdDoOhVSlXMAKy5dQbOYK9pOOoPY4Fat3OTtr0+8zbJo5TTZwlsfxY/kDNJg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.64.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Y3whrJOMucfkhgNkvcM93M6j8C53iEAMmPMYD99C6G1kUy4ELigLWk9ar2/4aCBSRgsql0zdlOemLgW2m5GZgQ=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.64.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-S6S1GEzPjFF6IONYJX3AKt/799+8RgBgCcwRnUo+Ep1IvBxUbw5M51iU0TDdK2NWWVnSvCQ8yi6Em0fGASMOng=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.64.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Ed1DPoFENkXj4IGzSB12SesV40iP6LxCCrbTIkSGIQBpOOxpBM+0kj62lmaeIHfMWTDoddqTOtqc7s8LYfn+Pw=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.64.0", "", { "os": "linux", "cpu": "arm" }, "sha512-J/6xhutJPtFyIkmHdf4olOdHHKH5kOJNumojdWn+Elv5ICEYTVOa362Vw27TxsK0BLzb1le3EDlO3UbvFK1lnQ=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.64.0", "", { "os": "linux", "cpu": "arm" }, "sha512-G4L/Nnx59d2LvDoV/P4S5lZ7oVLT6hodOHMacqjiq2LL2gts86KbkEBTYL53+fBt0odhf7XPIbIATGoYiJctxg=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.64.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-zF16pvJDEUksGYRbfnXne7pdklAyj2XDmJeYKgP93oq0cUyX9Y8O7lUo1R+IY1GagP/YBvCEDCe7eEcAKpw//g=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.64.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-nIggWn8n+w12+R5lsV+PYYoUY8vuObxPAQqJI7WES+9v0qspYuAHoZMfYM0yLxlipasT5BY9ht5W8t5c/rSCkg=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.64.0", "", { "os": "linux", "cpu": "none" }, "sha512-WLOTiHPzszP0dxIMsHpFeDSOSB5I49Mj8K4+yKE3H1X/D0JRG5XZ5jrctJLMjnUc/LUzaKiwq6EHWNJ8pup8PA=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.64.0", "", { "os": "linux", "cpu": "none" }, "sha512-35RK9aRhh7g7Pmeu5gQ01aCvrhXzj9LoBE6xtUcEH1ns+nyTaSzxSpADmBQINu4QiTDGvmQYwo1j5hlg1rRXFw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.64.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RqTfnpoTjuWfPAmqK5p2edt2AMCDJWm6oTn7sKfmyx+6tRga0ip05SJ977o6udTcIiDeF/CJMUfkaN4gtWt6Aw=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.64.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RQjZPigjxFvFZDijG6rjlSXxhRaTf9zLYVpGK+lVcga64oMqP1GbgVqCb46eHTTceSIgp89Ja0y5aPe3GFBYHg=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.64.0", "", { "os": "linux", "cpu": "none" }, "sha512-tjL58mgQcTRnDRpeky+Sth1MKQeYi2Ct6r3Luq9E5aTSjnM/i/JbozqZkKqp9thM5+oDSZxo+05f9kdQedfTGA=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.64.0", "", { "os": "linux", "cpu": "none" }, "sha512-ictoRyVaoZ8C67myHef+8hhCa6IplXJJ/xUCkVCxziV4zxMZh+jvhkhGsPBLqj1S72fnqD0QH5zL4lcaa4X7DQ=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.64.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-8aVvZ2hfr9RLFzBQkQNmYeHLDja8fLeQAmLt757zdKN/B+8L809KlLJKctQjS22wZotptV8G6o7fVPuAs8S1tQ=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.64.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2dEF8GAcDKwshUfydD+GhosppNyBzhVUHkdFF3CXd3FpOkzj/RZzF5aoEtzklujklT5qFVRU2GK/cFeLaTNKVg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.64.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3XHTvoo2hKkh0MiOfS7SkhHjMaaWF7P4F3drocZZaho/Z8w7h/jpnuBHm91BOQLfvrKHs1yR+zir4E8RI3XzTQ=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.64.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HKtkzodL4a3KFWDXDoO61cRynAWTR3LnAE0oGtfG9lcNFpNuVYUFOByxDgNqEvic9rvaQbAj+SFwTEWDptRaIw=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.64.0", "", { "os": "none", "cpu": "arm64" }, "sha512-JBsxqiY16uyyCujtoC3s7zL+SrYyNElptD7K4V+KUoJ2V8/XG3hPWpheK+SuPwNh1cikJvIZmIdsrYXvD6PLJw=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.64.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-S5Qrijh37qhnUYQ0ay8WN3LuWeKjow7wTUyCOPMZtnXiBXYXdsZETI6ZLfYnQAKovltckPLBFdNeD61P2rJc7g=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.64.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zbZ6iMmLFEsCtTdc2/y1F/cDFVNNCPF7Ae8PCT/wdPE//hGwYp6r+MyCLMFX+kOeIe09Pz6ASfnnfpSTN1FJQQ=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.64.0", "", { "os": "win32", "cpu": "x64" }, "sha512-L6Hw69oNpuYaOr1zCG30v0/DqKYCSRwodPGwIcVZ/6f8SOzq9rxtL/xNVI5MO4wjli+Ko0ErK9uHJL3mNPt8wQ=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.64.0", "", { "os": "win32", "cpu": "x64" }, "sha512-nl+CTWIgOGszQmUz/kanfgrfFZ4qiwxKIfT/ryl3N3Ewk5tqizmYtNXiqb7Bu/fDtNgu1kyxdFiAUJNur3iysg=="], + + "@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], + + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], + + "@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], + + "@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], + + "@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + + "@shuding/opentype.js": ["@shuding/opentype.js@1.4.0-beta.0", "", { "dependencies": { "fflate": "^0.7.3", "string.prototype.codepointat": "^0.2.1" }, "bin": { "ot": "bin/ot" } }, "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="], + + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + + "@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/mdx": ["@types/mdx@2.0.14", "", {}, "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + + "@types/nlcst": ["@types/nlcst@2.0.3", "", { "dependencies": { "@types/unist": "*" } }, "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA=="], + + "@types/node": ["@types/node@26.6.4", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="], + + "@volar/kit": ["@volar/kit@2.4.28", "", { "dependencies": { "@volar/language-service": "2.4.28", "@volar/typescript": "2.4.28", "typesafe-path": "^0.2.2", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "typescript": "*" } }, "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg=="], + + "@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="], + + "@volar/language-server": ["@volar/language-server@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "@volar/language-service": "2.4.28", "@volar/typescript": "2.4.28", "path-browserify": "^1.0.1", "request-light": "^0.7.0", "vscode-languageserver": "^9.0.1", "vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" } }, "sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw=="], + + "@volar/language-service": ["@volar/language-service@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" } }, "sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw=="], + + "@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="], + + "@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="], + + "@vscode/emmet-helper": ["@vscode/emmet-helper@2.11.0", "", { "dependencies": { "emmet": "^2.4.3", "jsonc-parser": "^2.3.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.15.1", "vscode-uri": "^3.0.8" } }, "sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw=="], + + "@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="], + + "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], + + "ajv-i18n": ["ajv-i18n@4.2.0", "", { "peerDependencies": { "ajv": "^8.0.0-beta.0" } }, "sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg=="], + + "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], + + "ansi-regex": ["ansi-regex@6.4.0", "", {}, "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="], + + "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], + + "astro": ["astro@5.18.2", "", { "dependencies": { "@astrojs/compiler": "^2.13.0", "@astrojs/internal-helpers": "0.7.6", "@astrojs/markdown-remark": "6.3.11", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^4.0.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "acorn": "^8.15.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "boxen": "8.0.1", "ci-info": "^4.3.1", "clsx": "^2.1.1", "common-ancestor-path": "^1.0.1", "cookie": "^1.1.1", "cssesc": "^3.0.0", "debug": "^4.4.3", "deterministic-object-hash": "^2.0.2", "devalue": "^5.6.2", "diff": "^8.0.3", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^1.7.0", "esbuild": "^0.27.3", "estree-walker": "^3.0.3", "flattie": "^1.1.1", "fontace": "~0.4.0", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "magic-string": "^0.30.21", "magicast": "^0.5.1", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "p-limit": "^6.2.0", "p-queue": "^8.1.1", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.3", "prompts": "^2.4.2", "rehype": "^13.0.2", "semver": "^7.7.3", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "svgo": "^4.0.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.3", "unist-util-visit": "^5.0.0", "unstorage": "^1.17.4", "vfile": "^6.0.3", "vite": "^6.4.1", "vitefu": "^1.1.1", "xxhash-wasm": "^1.1.0", "yargs-parser": "^21.1.1", "yocto-spinner": "^0.2.3", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.1", "zod-to-ts": "^1.2.0" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "astro.js" } }, "sha512-TnFwLnAXty5MXKPDGuKXqK4AMBXG+FH6RUdK7Oyc3gyfNoFIthT+4eRbzOK43bdRlLaZuxgciDSjgtggZ3OtGQ=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + + "base-64": ["base-64@1.0.0", "", {}, "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="], + + "base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "boxen": ["boxen@8.0.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^8.0.0", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "string-width": "^7.2.0", "type-fest": "^4.21.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0" } }, "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw=="], + + "camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="], + + "camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="], + + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], + + "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], + + "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + + "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], + + "common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="], + + "crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], + + "css-background-parser": ["css-background-parser@0.1.0", "", {}, "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA=="], + + "css-box-shadow": ["css-box-shadow@1.0.0-3", "", {}, "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg=="], + + "css-color-keywords": ["css-color-keywords@1.0.0", "", {}, "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="], + + "css-gradient-parser": ["css-gradient-parser@0.0.17", "", {}, "sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg=="], + + "css-select": ["css-select@6.0.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^7.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "nth-check": "^2.1.1" } }, "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw=="], + + "css-to-react-native": ["css-to-react-native@3.2.0", "", { "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", "postcss-value-parser": "^4.0.2" } }, "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ=="], + + "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + + "css-what": ["css-what@7.0.0", "", {}, "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csso": ["csso@5.0.5", "", { "dependencies": { "css-tree": "~2.2.0" } }, "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "deterministic-object-hash": ["deterministic-object-hash@2.0.2", "", { "dependencies": { "base-64": "^1.0.0" } }, "sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ=="], + + "devalue": ["devalue@5.9.4", "", {}, "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], + + "emmet": ["emmet@2.4.11", "", { "dependencies": { "@emmetio/abbreviation": "^2.3.3", "@emmetio/css-abbreviation": "^2.1.8" } }, "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "emoji-regex-xs": ["emoji-regex-xs@2.0.1", "", {}, "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g=="], + + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], + + "esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="], + + "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "estree-util-attach-comments": ["estree-util-attach-comments@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw=="], + + "estree-util-build-jsx": ["estree-util-build-jsx@3.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-walker": "^3.0.0" } }, "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ=="], + + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + + "estree-util-scope": ["estree-util-scope@1.0.1", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0" } }, "sha512-B0np3dcdxqILX5e9nEi5/Fr4K7gL4oYFVPV1zRa2e9wRCbQoZZNWOZFYyoInvXUPJXBXjss+QXlWLJChDEHDkA=="], + + "estree-util-to-js": ["estree-util-to-js@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "astring": "^1.8.0", "source-map": "^0.7.0" } }, "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg=="], + + "estree-util-visit": ["estree-util-visit@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/unist": "^3.0.0" } }, "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.8", "", {}, "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fflate": ["fflate@0.7.3", "", {}, "sha512-0Zz1jOzJWERhyhsimS54VTqOteCNwRtIlh8isdL0AXLo0g7xNTfTL7oWrkmCnPhZGocKIkWHBistBrrpoNH3aw=="], + + "flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="], + + "fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="], + + "fontkitten": ["fontkitten@1.0.3", "", { "dependencies": { "tiny-inflate": "^1.0.3" } }, "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-east-asian-width": ["get-east-asian-width@1.7.0", "", {}, "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q=="], + + "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], + + "h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], + + "harfbuzzjs": ["harfbuzzjs@0.10.0", "", {}, "sha512-SN8LVwCOzvTq3OPNd0+EAghgXugItz56wst567D1vs7LnnKGWprhi3EG58aVOBwhN8HEbQxL4I9NDWkcXUutRw=="], + + "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], + + "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], + + "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="], + + "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], + + "hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="], + + "hast-util-to-estree": ["hast-util-to-estree@3.1.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-attach-comments": "^3.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w=="], + + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + + "hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="], + + "hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], + + "hex-rgb": ["hex-rgb@4.3.0", "", {}, "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw=="], + + "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], + + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + + "http-cache-semantics": ["http-cache-semantics@4.3.0", "", {}, "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA=="], + + "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], + + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + + "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], + + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + + "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="], + + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + + "lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "magicast": ["magicast@0.5.5", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA=="], + + "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], + + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + + "mdast-util-definitions": ["mdast-util-definitions@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ=="], + + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-xPgpDNl0/OXHsI7TlaIs22lWnH3KlpvZdXG1ET/m/YT2Hhdkx8lJV4kLphE6l9simyM4KPDFBwas08BgEob6Jw=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A=="], + + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + + "mdast-util-mdx": ["mdast-util-mdx@3.0.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.2.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-character": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + + "micromark": ["micromark@4.0.3", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-edit-map": "^1.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.4", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.1.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-edit-map": "^1.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.3" } }, "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w=="], + + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.2", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + + "micromark-extension-mdx-expression": ["micromark-extension-mdx-expression@3.0.1", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q=="], + + "micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.2", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ=="], + + "micromark-extension-mdx-md": ["micromark-extension-mdx-md@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ=="], + + "micromark-extension-mdxjs": ["micromark-extension-mdxjs@3.0.0", "", { "dependencies": { "acorn": "^8.0.0", "acorn-jsx": "^5.0.0", "micromark-extension-mdx-expression": "^3.0.0", "micromark-extension-mdx-jsx": "^3.0.0", "micromark-extension-mdx-md": "^2.0.0", "micromark-extension-mdxjs-esm": "^3.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ=="], + + "micromark-extension-mdxjs-esm": ["micromark-extension-mdxjs-esm@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-mdx-expression": ["micromark-factory-mdx-expression@2.0.3", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ=="], + + "micromark-factory-space": ["micromark-factory-space@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + + "micromark-util-edit-map": ["micromark-util-edit-map@1.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-events-to-acorn": ["micromark-util-events-to-acorn@2.0.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.3", "", {}, "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg=="], + + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], + + "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], + + "neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="], + + "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="], + + "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], + + "node-mock-http": ["node-mock-http@1.0.5", "", {}, "sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + + "ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], + + "ohash": ["ohash@2.0.12", "", {}, "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw=="], + + "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="], + + "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], + + "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], + + "p-queue": ["p-queue@8.1.1", "", { "dependencies": { "eventemitter3": "^5.0.1", "p-timeout": "^6.1.2" } }, "sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ=="], + + "p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="], + + "package-manager-detector": ["package-manager-detector@1.8.0", "", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="], + + "pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], + + "parse-css-color": ["parse-css-color@0.2.1", "", { "dependencies": { "color-name": "^1.1.4", "hex-rgb": "^4.1.0" } }, "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg=="], + + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + + "parse-latin": ["parse-latin@7.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", "nlcst-to-string": "^4.0.0", "unist-util-modify-children": "^4.0.0", "unist-util-visit-children": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + + "piccolore": ["piccolore@0.1.3", "", {}, "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "prettier": ["prettier@3.9.9", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg=="], + + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], + + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], + + "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="], + + "recma-jsx": ["recma-jsx@1.0.1", "", { "dependencies": { "acorn-jsx": "^5.0.0", "estree-util-to-js": "^2.0.0", "recma-parse": "^1.0.0", "recma-stringify": "^1.0.0", "unified": "^11.0.0" }, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w=="], + + "recma-parse": ["recma-parse@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "esast-util-from-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ=="], + + "recma-stringify": ["recma-stringify@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-to-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g=="], + + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], + + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + + "rehype": ["rehype@13.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "rehype-parse": "^9.0.0", "rehype-stringify": "^10.0.0", "unified": "^11.0.0" } }, "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A=="], + + "rehype-parse": ["rehype-parse@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-html": "^2.0.0", "unified": "^11.0.0" } }, "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag=="], + + "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="], + + "rehype-recma": ["rehype-recma@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "hast-util-to-estree": "^3.0.0" } }, "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw=="], + + "rehype-stringify": ["rehype-stringify@10.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-html": "^9.0.0", "unified": "^11.0.0" } }, "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA=="], + + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + + "remark-mdx": ["remark-mdx@3.1.1", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + + "remark-smartypants": ["remark-smartypants@3.0.3", "", { "dependencies": { "retext": "^9.0.0", "retext-smartypants": "^6.0.0", "unified": "^11.0.4", "unist-util-visit": "^5.0.0" } }, "sha512-gCaK+ndZ0hYezlqFegHFCVh2CQemsi0Npdh1qVM9bxlUFknjkbP6VmojWhddOCrbK0PbbacmYLWfTULRiT1eWA=="], + + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + + "request-light": ["request-light@0.7.0", "", {}, "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "retext": ["retext@9.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "retext-latin": "^4.0.0", "retext-stringify": "^4.0.0", "unified": "^11.0.0" } }, "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA=="], + + "retext-latin": ["retext-latin@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "parse-latin": "^7.0.0", "unified": "^11.0.0" } }, "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA=="], + + "retext-smartypants": ["retext-smartypants@6.2.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "nlcst-to-string": "^4.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ=="], + + "retext-stringify": ["retext-stringify@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "nlcst-to-string": "^4.0.0", "unified": "^11.0.0" } }, "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA=="], + + "rollup": ["rollup@4.64.0", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.64.0", "@rollup/rollup-android-arm64": "4.64.0", "@rollup/rollup-darwin-arm64": "4.64.0", "@rollup/rollup-darwin-x64": "4.64.0", "@rollup/rollup-freebsd-arm64": "4.64.0", "@rollup/rollup-freebsd-x64": "4.64.0", "@rollup/rollup-linux-arm-gnueabihf": "4.64.0", "@rollup/rollup-linux-arm-musleabihf": "4.64.0", "@rollup/rollup-linux-arm64-gnu": "4.64.0", "@rollup/rollup-linux-arm64-musl": "4.64.0", "@rollup/rollup-linux-loong64-gnu": "4.64.0", "@rollup/rollup-linux-loong64-musl": "4.64.0", "@rollup/rollup-linux-ppc64-gnu": "4.64.0", "@rollup/rollup-linux-ppc64-musl": "4.64.0", "@rollup/rollup-linux-riscv64-gnu": "4.64.0", "@rollup/rollup-linux-riscv64-musl": "4.64.0", "@rollup/rollup-linux-s390x-gnu": "4.64.0", "@rollup/rollup-linux-x64-gnu": "4.64.0", "@rollup/rollup-linux-x64-musl": "4.64.0", "@rollup/rollup-openbsd-x64": "4.64.0", "@rollup/rollup-openharmony-arm64": "4.64.0", "@rollup/rollup-win32-arm64-msvc": "4.64.0", "@rollup/rollup-win32-ia32-msvc": "4.64.0", "@rollup/rollup-win32-x64-gnu": "4.64.0", "@rollup/rollup-win32-x64-msvc": "4.64.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-gjA/kFeDfffII8COoPsvLPvE16hWDTEc1+0QKQNKUkuV8iJjqsC4xXs8/FzuL0v9EV39kuxTbAgcFoTRyy2TGQ=="], + + "satori": ["satori@0.35.0", "", { "dependencies": { "@shuding/opentype.js": "1.4.0-beta.0", "css-background-parser": "^0.1.0", "css-box-shadow": "1.0.0-3", "css-gradient-parser": "^0.0.17", "css-to-react-native": "^3.0.0", "emoji-regex-xs": "^2.0.1", "escape-html": "^1.0.3", "fflate": "0.7.3", "harfbuzzjs": "0.10.0", "linebreak": "^1.1.0", "parse-css-color": "^0.2.1", "postcss-value-parser": "^4.2.0", "yoga-layout": "^3.2.1" } }, "sha512-YyoP7axF2T93XOWXQXLH4t+cvLgZ+MeYV/xruXJfPa/czpcRqaG41/8Bz3mzgYj/7NxdrqJOx3YtzWy1MLtG5g=="], + + "sax": ["sax@1.6.1", "", {}, "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="], + + "shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "smol-toml": ["smol-toml@1.9.0", "", {}, "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ=="], + + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], + + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "string-width": ["string-width@8.3.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ=="], + + "string.prototype.codepointat": ["string.prototype.codepointat@0.2.1", "", {}, "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg=="], + + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + + "svgo": ["svgo@4.1.0", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^6.0.0", "css-tree": "^3.0.1", "css-what": "^7.0.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "1.6.1" }, "bin": { "svgo": "bin/svgo.js" } }, "sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q=="], + + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + + "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + + "typesafe-path": ["typesafe-path@0.2.2", "", {}, "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "typescript-auto-import-cache": ["typescript-auto-import-cache@0.3.6", "", { "dependencies": { "semver": "^7.3.8" } }, "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ=="], + + "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], + + "ultrahtml": ["ultrahtml@1.7.0", "", {}, "sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g=="], + + "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], + + "undici": ["undici@8.11.2", "", {}, "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ=="], + + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="], + + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + + "unifont": ["unifont@0.7.5", "", { "dependencies": { "css-tree": "^3.1.0", "ohash": "^2.0.11", "undici": "^8.0.0" } }, "sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg=="], + + "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], + + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-modify-children": ["unist-util-modify-children@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "array-iterate": "^2.0.0" } }, "sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-position-from-estree": ["unist-util-position-from-estree@2.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ=="], + + "unist-util-remove-position": ["unist-util-remove-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-children": ["unist-util-visit-children@3.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + + "unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], + + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], + + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + + "volar-service-css": ["volar-service-css@0.0.71", "", { "dependencies": { "vscode-css-languageservice": "^6.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-wRRFt9BpjMKCazcgOh67MSjUjiWUCAh99DyYSDIOTuxaRjEtDC7PpB0k1Y1wbJIW/pVtMUSVbpPo3UGSm0Byxw=="], + + "volar-service-emmet": ["volar-service-emmet@0.0.71", "", { "dependencies": { "@emmetio/css-parser": "^0.4.1", "@emmetio/html-matcher": "^1.3.0", "@vscode/emmet-helper": "^2.9.3", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-zqjzt6bN95e3CUstBm0PBFAJnrfz0ZAARka87fart46/gNCLLuP3Vujy8V/J8HEziTFLnfkgIASLFYPUhonJcA=="], + + "volar-service-html": ["volar-service-html@0.0.71", "", { "dependencies": { "vscode-html-languageservice": "^5.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-e8tHPhgQ7ooLfudAEIku+kgd9pWkq3SSz8RbnQDI1+Eb8wbenkLGHqoirLqz5ORLV6wIMr2Iv08RWBG5eOcgpw=="], + + "volar-service-prettier": ["volar-service-prettier@0.0.71", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0", "prettier": "^2.2 || ^3.0" }, "optionalPeers": ["@volar/language-service", "prettier"] }, "sha512-Rz7JVH3qD108UCdmIEiZvOBNljMt2nLFdbN8AXcDfn7xD9F5I2aCIsDVqBbXw21PsnxG0b7MfwtNF+zPS/NKUg=="], + + "volar-service-typescript": ["volar-service-typescript@0.0.71", "", { "dependencies": { "path-browserify": "^1.0.1", "semver": "^7.6.2", "typescript-auto-import-cache": "^0.3.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-nls": "^5.2.0", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-yTtM/BVT6hoyEYnDtaCyAtNhdNeS/mhTTABlBOdw3NNiRBUin3IznFJpgfjer4c6RYopiPjjQjc9VFhxVl1mLw=="], + + "volar-service-typescript-twoslash-queries": ["volar-service-typescript-twoslash-queries@0.0.71", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-9K2k72s4n7rV9s4bX0MyjbX9iBribvKZbBJKuEmTCZfeWJXs6Yh7bGpY4eoc7UufAjvpheBqwyZCOIPBvxCv0A=="], + + "volar-service-yaml": ["volar-service-yaml@0.0.71", "", { "dependencies": { "vscode-uri": "^3.0.8", "yaml-language-server": "~1.23.0" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-qYGWGuVpUTnZGu5P/CR4KLK4aIR8RrcVnmfZ2eRcj9q/I8VZCoC5yy9FtEvfNvnDp4MU17yhdJcvpQPIqhJS2Q=="], + + "vscode-css-languageservice": ["vscode-css-languageservice@6.3.10", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA=="], + + "vscode-html-languageservice": ["vscode-html-languageservice@5.6.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg=="], + + "vscode-json-languageservice": ["vscode-json-languageservice@4.1.8", "", { "dependencies": { "jsonc-parser": "^3.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-nls": "^5.0.0", "vscode-uri": "^3.0.2" } }, "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg=="], + + "vscode-jsonrpc": ["vscode-jsonrpc@9.0.3", "", {}, "sha512-qETYf9PCl1lVfwR+oBmMfc4vP2nqLNA35ABxorw3j06+xlKRgDVxbLgKm4Y/xPL0YWdeDjLoI4ImICIT3nB0zQ=="], + + "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], + + "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.18.4", "", { "dependencies": { "vscode-jsonrpc": "9.0.3", "vscode-languageserver-types": "3.18.4" } }, "sha512-CCXbyr99dafRJzJH/0Ryk7ly5T8XPmSR0//OuF2w4m+9JiOin7zcAT5iET8GRE7Z9taFBejb3GbM0a0mbjdbrg=="], + + "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.15", "", {}, "sha512-Gsfr9S8LU3S6885cS+NaleasJ0n3V5y/SOTqHCifQiXWFw3G9w9/YItDfwHrlCz+dSZWWZ8ilYVMcJe7Sz7Sxw=="], + + "vscode-languageserver-types": ["vscode-languageserver-types@3.18.4", "", {}, "sha512-nQsacoiijvl0fRf2q28uFDrAVwRhdCa3QveiF7/CX0r18mLR5FU/1WskSsCKOjmEbtteKUnY8x8Yzxop+k0zwg=="], + + "vscode-nls": ["vscode-nls@5.2.0", "", {}, "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng=="], + + "vscode-uri": ["vscode-uri@3.2.0", "", {}, "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg=="], + + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + + "which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="], + + "widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="], + + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], + + "yaml-language-server": ["yaml-language-server@1.23.0", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-i18n": "^4.2.0", "prettier": "^3.8.1", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-uri": "^3.0.2", "yaml": "2.8.3" }, "bin": { "yaml-language-server": "bin/yaml-language-server" } }, "sha512-3qVyCOexLCWw06PQa5kRPwvMWMZ/eZeCRWUvgD6a0OkqL/4iCnxy2WumbWifa937Uo5xhyWJ0uxlU39ljhNh7A=="], + + "yargs": ["yargs@18.2.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], + + "yocto-spinner": ["yocto-spinner@0.2.3", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ=="], + + "yoctocolors": ["yoctocolors@2.2.0", "", {}, "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg=="], + + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.5", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ=="], + + "@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.5", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ=="], + + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "astro/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "boxen/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], + + "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + + "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + + "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + + "vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "vscode-css-languageservice/vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + + "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + + "vscode-languageserver/vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], + + "widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="], + + "yaml-language-server/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + + "yargs/yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + + "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "astro/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "astro/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "astro/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "astro/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "astro/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "astro/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "astro/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "astro/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "astro/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "astro/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "astro/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "astro/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "astro/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "astro/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "astro/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "astro/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "astro/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "astro/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "astro/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "astro/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "astro/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "astro/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "astro/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], + + "unstorage/chokidar/readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="], + + "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "vscode-languageserver/vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + + "vscode-languageserver/vscode-languageserver-protocol/vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + + "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + } +} diff --git a/apps/landing/package.json b/apps/landing/package.json new file mode 100644 index 00000000..d5f8e70a --- /dev/null +++ b/apps/landing/package.json @@ -0,0 +1,23 @@ +{ + "name": "study-landing", + "private": true, + "packageManager": "bun@1.4.2", + "type": "module", + "scripts": { + "dev": "astro dev", + "build": "astro build", + "check": "astro check", + "preview": "astro preview" + }, + "dependencies": { + "@astrojs/mdx": "^4", + "astro": "^5.18.2", + "satori": "^0.35.0", + "sharp": "^0.35.5" + }, + "devDependencies": { + "@astrojs/check": "^0.9.10", + "@types/node": "^26.6.4", + "typescript": "^6" + } +} diff --git a/apps/landing/src/brand.ts b/apps/landing/src/brand.ts new file mode 100644 index 00000000..4a6cd111 --- /dev/null +++ b/apps/landing/src/brand.ts @@ -0,0 +1,75 @@ +// The site's icons and link previews, drawn at build time from the same sources as the app: +// the logo masters in assets/logo, the app's fonts in study-ui, the hero's headline and a +// capture of a session, so none of them is a file to keep up to date by hand. +import satori from 'satori'; +import sharp from 'sharp'; +import full from '../../../assets/logo/study.svg?raw'; +import interRegular from '../../../crates/study-ui/assets/fonts/Inter-Regular.ttf?inline'; +import interSemiBold from '../../../crates/study-ui/assets/fonts/Inter-SemiBold.ttf?inline'; +import { t, type Lang } from './i18n/strings'; + +export { default as small } from '../../../assets/logo/study-small.svg?raw'; + +/** A PNG for an endpoint to answer with. */ +const png = (bytes: Uint8Array) => new Response(new Uint8Array(bytes), { headers: { 'Content-Type': 'image/png' } }); + +/** The full mark as a PNG `size` pixels square, for home screens, which round the corners + * themselves: the tile's color fills its own rounded corners. */ +export async function mark(size: number): Promise { + const tile = full.match(/]*fill="(#[0-9a-f]{6})"/i)?.[1]; + if (!tile) throw new Error('the logo master no longer starts with its tile'); + return png( + await sharp(Buffer.from(full), { density: 72 * (size / 64) }) + .resize(size, size) + .flatten({ background: tile }) + .png() + .toBuffer(), + ); +} + +const captures = import.meta.glob('./assets/shots/session-*-light.webp', { + eager: true, + query: '?inline', + import: 'default', +}); + +/** The bytes behind a `data:` URL that Vite inlined. */ +const bytes = (url: string) => Buffer.from(url.slice(url.indexOf(',') + 1), 'base64'); + +/** The 1200×630 card a link to the site shows in `lang`: the mark, the headline and a session. */ +export async function preview(lang: Lang): Promise { + const capture = captures[`./assets/shots/session-${lang}-light.webp`]; + if (!capture) throw new Error(`no session capture in ${lang}: make it with \`just docs-media session\``); + const session = await sharp(bytes(capture)).resize({ width: 1080 }).png().toBuffer(); + const words = t(lang).hero.title.split(' '); + const last = words.pop(); + + const node = (type: string, style: object, children?: unknown, extra: object = {}) => ({ + type, + props: { style, children, ...extra }, + }); + const tree = node('div', { display: 'flex', width: 1200, height: 630, background: '#ffffff', position: 'relative', fontFamily: 'Inter', color: '#111111' }, [ + node('img', { position: 'absolute', left: 660, top: 70, width: 1080, borderRadius: 12, border: '1px solid #e4e4e4' }, undefined, { + src: `data:image/png;base64,${session.toString('base64')}`, + }), + node('div', { display: 'flex', flexDirection: 'column', position: 'absolute', left: 80, top: 96, width: 520 }, [ + node('div', { display: 'flex', alignItems: 'center', gap: 20 }, [ + node('img', { width: 64, height: 64 }, undefined, { src: `data:image/svg+xml;base64,${Buffer.from(full).toString('base64')}` }), + node('div', { fontSize: 40, fontWeight: 600 }, 'Study'), + ]), + node('div', { display: 'flex', flexWrap: 'wrap', marginTop: 44, fontSize: 64, fontWeight: 600, lineHeight: 1.08, letterSpacing: -1.5 }, [ + ...words.map((word) => node('span', { marginRight: 16 }, word)), + node('span', { background: '#fff0a1', padding: '0 4px' }, last), + ]), + ]), + ]); + const svg = await satori(tree as never, { + width: 1200, + height: 630, + fonts: [ + { name: 'Inter', data: bytes(interRegular), weight: 400 }, + { name: 'Inter', data: bytes(interSemiBold), weight: 600 }, + ], + }); + return png(await sharp(Buffer.from(svg)).png().toBuffer()); +} diff --git a/apps/landing/src/components/Clip.astro b/apps/landing/src/components/Clip.astro new file mode 100644 index 00000000..6a080318 --- /dev/null +++ b/apps/landing/src/components/Clip.astro @@ -0,0 +1,116 @@ +--- +// A short real recording of the app, in the reader's theme, made by `just docs-media` like +// the stills. Both themes' videos are on the page and the reader's color scheme shows one; +// neither loads more than its first bytes until it plays. +// +// Without script, or for a reader who asks for reduced motion, a clip is its poster and +// plays only when asked. Otherwise it plays, muted and looping, while it is on screen, and +// pauses when it leaves. The browser's controls stay, so a reader can always pause it, and +// a clip the reader paused stays paused. +import { getImage } from 'astro:assets'; +import type { ImageMetadata } from 'astro'; +import type { Lang } from '../i18n/strings'; +import { pick, type Theme } from './variant'; + +const videos = import.meta.glob('../assets/clips/*.mp4', { query: '?url', import: 'default', eager: true }); +const posters = import.meta.glob<{ default: ImageMetadata }>('../assets/clips/*.webp', { eager: true }); + +interface Props { + name: string; + lang: Lang; + /** What the clip shows, for a reader who cannot see it. */ + alt: string; +} +const { name, lang, alt } = Astro.props; + +const clip = async (theme: Theme) => { + const src = pick(name, lang, theme, (file) => videos[`../assets/clips/${file}.mp4`]); + const still = pick(name, lang, theme, (file) => posters[`../assets/clips/${file}.webp`]?.default); + const poster = await getImage({ src: still, format: 'webp', quality: 82 }); + return { src, poster: poster.src, width: still.width, height: still.height }; +}; +const themes = { light: await clip('light'), dark: await clip('dark') }; +--- + +{ + (['light', 'dark'] as const).map((theme) => ( +

+ + + + + +
+ diff --git a/apps/landing/src/components/Logo.astro b/apps/landing/src/components/Logo.astro new file mode 100644 index 00000000..da864229 --- /dev/null +++ b/apps/landing/src/components/Logo.astro @@ -0,0 +1,26 @@ +--- +// Study's logo: the highlighter's own tile with an open book, one line highlighted. The +// masters live in the repository's assets/logo; under 40px the simpler mark reads better, +// as in the app (`FULL_MARK_FROM` in crates/study-ui/src/logo.rs). +import full from '../../../../assets/logo/study.svg?raw'; +import small from '../../../../assets/logo/study-small.svg?raw'; + +interface Props { + size?: number; + class?: string; +} +const { size = 24, class: className } = Astro.props; +const svg = (size >= 40 ? full : small).replace(' + + diff --git a/apps/landing/src/components/Media.astro b/apps/landing/src/components/Media.astro new file mode 100644 index 00000000..64b3ae9c --- /dev/null +++ b/apps/landing/src/components/Media.astro @@ -0,0 +1,31 @@ +--- +// A docs page's picture or clip, written in its Markdown as an image: +// `![What it shows](shot:search "Caption")` for a still and `![…](clip:search-typing "…")` +// for a clip. The docs pages render every Markdown image through this, so the alt text and +// the caption are translated with the page, and the capture comes in the page's language and +// the reader's theme. Anything else written as an image fails the build: the docs show only +// captures of the app. +import Clip from './Clip.astro'; +import Shot from './Shot.astro'; +import type { Lang } from '../i18n/strings'; + +interface Props { + src: string; + /** What it shows, for a reader who cannot see it: the image's alt text, required. */ + alt?: string; + /** The caption, from the image's title. */ + title?: string; +} +const { src, alt, title } = Astro.props; +const lang = (Astro.currentLocale ?? 'en') as Lang; +const [kind, name] = src.split(':', 2); +if ((kind !== 'shot' && kind !== 'clip') || !name) { + throw new Error(`docs media must be shot: or clip:, not ${src}`); +} +if (!alt?.trim()) throw new Error(`${src} has no alt text: say what it shows in its ![…]`); +--- + +
+ {kind === 'shot' ? : } + {title &&
{title}
} +
diff --git a/apps/landing/src/components/Shot.astro b/apps/landing/src/components/Shot.astro new file mode 100644 index 00000000..a2969ff1 --- /dev/null +++ b/apps/landing/src/components/Shot.astro @@ -0,0 +1,45 @@ +--- +// A real capture of the app, in the reader's theme: light and dark variants of one page, +// in the page's language when it was captured in it, else in English. The captures are +// lossless WebP made by `just docs-media`; the site makes the smaller sizes from them. +import { getImage } from 'astro:assets'; +import type { ImageMetadata } from 'astro'; +import type { Lang } from '../i18n/strings'; +import { pick, type Theme } from './variant'; + +const files = import.meta.glob<{ default: ImageMetadata }>('../assets/shots/*.webp', { eager: true }); + +interface Props { + name: string; + lang: Lang; + alt: string; + /** The width it is shown at on a wide screen, for the browser to pick a source. */ + sizes?: string; + class?: string; + loading?: 'eager' | 'lazy'; +} +const { name, lang, alt, sizes = '(min-width: 1200px) 1100px, 100vw', class: className, loading = 'lazy' } = Astro.props; + +const capture = (theme: Theme) => pick(name, lang, theme, (file) => files[`../assets/shots/${file}.webp`]?.default); + +const widths = [720, 1440, 2160, 2880]; +const srcset = async (src: ImageMetadata) => { + const sets = await Promise.all( + widths.filter((w) => w <= src.width).map(async (w) => { + const image = await getImage({ src, width: w, format: 'webp', quality: 82 }); + return `${image.src} ${w}w`; + }), + ); + return sets.join(', '); +}; +const light = capture('light'); +const dark = capture('dark'); +// The `` itself is the light capture, for a browser that reads no ``. +const fallback = await getImage({ src: light, width: Math.min(1440, light.width), format: 'webp', quality: 82 }); +--- + + + + + {alt} + diff --git a/apps/landing/src/components/landing/Download.astro b/apps/landing/src/components/landing/Download.astro new file mode 100644 index 00000000..5a70b316 --- /dev/null +++ b/apps/landing/src/components/landing/Download.astro @@ -0,0 +1,253 @@ +--- +// The last call: the app's own icon beside "Study is free", the download for this visitor's +// platform with a note pointing at it, the other platforms in one line, and what to know +// first. Its script finds the visitor's platform and points every `[data-download]` on the +// page (the hero's too) at that installer, and drops that platform from the line. +import Icon from '../Icon.astro'; +import Logo from '../Logo.astro'; +import { keyed, words } from './text'; +import { downloadUrl, href, platforms, releases, repo, t, type Lang } from '../../i18n/strings'; + +interface Props { + lang: Lang; +} +const { lang } = Astro.props; +const s = t(lang); +const docs = (slug: string) => href(lang, `docs/${slug}/`); +--- + +
+
+
+ +
+

+

{s.get.body}

+ + +

+ {s.get.every} + + { + platforms.map((platform) => ( + {s.get.rows[platform.os].name} + )) + } + +

+

+ {s.get.linuxNote} {s.get.optional} {s.get.early} {s.get.tellUs}. +

+

+ {s.get.install} + {s.get.all} +

+

+
+ + +
+
+ + + + + + diff --git a/apps/landing/src/components/landing/Hero.astro b/apps/landing/src/components/landing/Hero.astro new file mode 100644 index 00000000..1644f8ad --- /dev/null +++ b/apps/landing/src/components/landing/Hero.astro @@ -0,0 +1,564 @@ +--- +// The first viewport: the headline and download beside a passage of a sample lecture with three +// phrases highlighted. Choosing one shows what Study made of it (an answer that cites it, a +// practice question, a flashcard), which writes itself out; it cycles until touched. The margin +// note's arrow and the timestamp's ring are anchored marks (`data-ink-scope`, see ink.ts). +import Icon from '../Icon.astro'; +import { pen } from '../rough'; +import { chars, words } from './text'; +import { t, type Lang } from '../../i18n/strings'; + +interface Props { + lang: Lang; +} +const { lang } = Astro.props; +const s = t(lang); +const p = s.passage; +const b = s.became; + +// The headline's last word gets one sweep of the highlighter: a band and a second, shorter +// drag of the nib, both roughened at the edges as a chisel tip leaves them. +const ink = pen(62); +const swash = [ink.swash(200, 40), ink.swash(200, 40)]; +const heroWords = s.hero.title.split(' '); +const heroLast = heroWords.pop(); +const heroNext = heroWords.pop(); +--- + +
+
+

+ {heroWords.join(' ')}{' '}{heroNext}{' '}{heroLast} +

+

{s.hero.lead}

+ +

{s.hero.terms} {s.hero.other}

+
+ +
+

+ +

+ +
+

+ {p.label} + + {p.source} + {p.time} +

+

+ {p.before}{p.p1}{p.mid1}{p.p2}{p.mid2}{p.p3}{p.after} +

+

{p.hint}

+ +
+
+

{b.answer.tab}

+

@study {b.answer.ask.replace('@study ', '')}

+

1`} /> +

1 {p.source} {b.answer.cite}

+ {b.answer.more} +
+
+

{b.question.tab}

+

{b.question.text}

+

{b.question.grade}

+

+ {b.question.more} +

+
+

{b.card.tab}

+

{b.card.front}

+

+

+ {b.card.more} +
+
+
+ + +
+ + + + + +
+ + + + diff --git a/apps/landing/src/components/landing/Learn.astro b/apps/landing/src/components/landing/Learn.astro new file mode 100644 index 00000000..abfbccf8 --- /dev/null +++ b/apps/landing/src/components/landing/Learn.astro @@ -0,0 +1,1318 @@ +--- +// One lecture, four ways to learn it: notebook tabs over one stage, each a piece of the app to +// try (a cited answer, a graded question, a flashcard, a hand-drawn diagram) beside a capture +// of the real thing. +import Icon from '../Icon.astro'; +import Shot from '../Shot.astro'; +import { pen } from '../rough'; +import { chars, draw, esc, keyed, pressed, words } from './text'; +import { href, t, type Lang } from '../../i18n/strings'; +import { quizAnswer } from '../../i18n/landing/learn'; + +interface Props { + lang: Lang; +} +const { lang } = Astro.props; +const s = t(lang); +const p = s.passage; +const b = s.became; +const letters = ['A', 'B', 'C', 'D']; +const docs = (slug: string) => href(lang, `docs/${slug}/`); +const chip = (i: number) => + ``; + +const ink = pen(64); +const strokes = { + tabLine: draw(ink.underline(4, 7, 112)), + check: pressed(() => ink.check(2, 2, 18)), +}; +// Each choice carries a hand-drawn check; only the right one's ever shows. +const checks = s.quiz.choices.map(() => pressed(() => ink.check(2, 3, 17))); + +// The four things a lecture becomes, as notebook tabs over one stage. +const tabs = [ + { id: 'answers', label: s.answers.tab, icon: 'wand-sparkles', tint: 'tint-green' }, + { id: 'quiz', label: s.quiz.tab, icon: 'list-checks', tint: 'tint-purple' }, + { id: 'card', label: s.card.tab, icon: 'shapes', tint: 'tint-orange' }, + { id: 'diagram', label: s.diagram.tab, icon: 'workflow', tint: 'tint-pink' }, +]; +// A transcript line split around the phrase a learner washed in it. +const around = (line: string, phrase: string) => { + const at = line.indexOf(phrase); + return at < 0 ? [line, '', ''] : [line.slice(0, at), phrase, line.slice(at + phrase.length)]; +}; +const cardQuote = around(s.transcript[1], p.p3); +const phaseQuote = around(s.transcript[0], p.p1); + +// Captures are cropped in the app's own points (each page is 1440 by 900), so its text stays +// readable: one crop for wide screens and a tighter one (`m`) for narrow ones. A target is a +// box on the page, in the same points, that an anchored mark circles or brackets. +type Box = { x: number; y: number; w: number; h: number }; +const crop = (d: Box, m: Box = d) => + `--dx:${d.x};--dy:${d.y};--dw:${d.w};--dh:${d.h};--mx:${m.x};--my:${m.y};--mw:${m.w};--mh:${m.h}`; +// How wide the whole page renders inside a crop, for `sizes`: the crop scales its box up to +// fill a panel column, at most 1.25 times the app's size. On wide screens a panel is the +// frame (1200px) less its padding (2 × 48px) and the columns' gap (24px), split 5 to 7; on +// narrower ones the crop takes no more than the viewport. +const panel = 1200 - 2 * 48 - 24; +const sizes = (column: 5 | 7, d: Box, m: Box = d) => + `(min-width: 1101px) ${Math.round((1440 / d.w) * Math.min((panel * column) / 12, d.w * 1.25))}px, ` + + `${Math.round((1440 / m.w) * 100)}vw`; +const spot = ({ x, y, w, h }: Box) => `--tx:${x};--ty:${y};--tw:${w};--th:${h}`; +const crops = { + passage: { x: 1052, y: 262, w: 372, h: 341 }, + graded: { x: 356, y: 566, w: 702, h: 254 }, + gradedNarrow: { x: 368, y: 724, w: 340, h: 96 }, + ratings: { x: 360, y: 466, w: 698, h: 52 }, + ratingsNarrow: { x: 536, y: 466, w: 346, h: 52 }, +}; +const spots = { + passage: { x: 1064, y: 406, w: 346, h: 190 }, + correct: { x: 382, y: 731, w: 86, h: 32 }, + good: { x: 712, y: 474, w: 165, h: 36 }, +}; + +// The diagram, laid out on a 560 by 320 sheet. +const phaseX = [10, 152, 294, 436]; +const diagram = { + root: { fill: ink.fill(205, 14, 150, 54), line: ink.rect(205, 14, 150, 54) }, + phases: phaseX.map((x) => ({ x, fill: ink.fill(x, 158, 118, 50), line: ink.rect(x, 158, 118, 50) })), + arrows: phaseX.map((x, i) => ink.arrow(262 + i * 12, 74, x + 59, 150, (1.5 - i) * -10)), + extra: ink.rect(84, 252, 156, 44), + extraArrow: ink.arrow(148, 248, 140, 214, 4, 9), + asideArrow: ink.arrow(330, 268, 250, 272, -6, 9), +}; +--- + +
+
+

+

{s.stage.body}

+

+
+
+ { + tabs.map((tab, i) => ( + + )) + } +
+ +
+
+
+
+

{s.answers.title}

+

{s.answers.body}

+ {s.answers.more} +
+

+ +

+
+
+
+

@study

+

+

+ +

+
+ { + s.answers.cites.map((time, i) => ( +
+

+ {i + 1} + {p.source} + {time} +

+

+ {s.transcript[i]} +

+
+ )) + } +
+
+
+
+
+ + +
+ +
+
{s.answers.caption}
+
+
+ + +
+ +
+
+
+

{s.quiz.title}

+

{s.quiz.body}

+ {s.quiz.more} +
+

+ +

+
+
+
+

{s.quiz.question}

+
+ { + s.quiz.choices.map((choice, i) => ( + + )) + } +
+

{s.quiz.hint}

+
+ + +
+
+
+
+
+ + +
+ +
+
{s.quiz.caption}
+
+
+ +
+ +
+
+
+

{s.card.title}

+

{s.card.body}

+ {s.card.more} +
+

+ +

+
+
+
+ +
+

{s.card.tap}

+
+ { + b.card.ratings.map((rating, i) => ( + + )) + } +
+
+

+ {s.card.next.map((note) =>

+
+
+
+
+
+ + +
+ +
+
{s.card.caption}
+
+
+

{p.source}

+

+

+ {cardQuote[0]}{cardQuote[1]}{cardQuote[2]} +

+
+
+
+ + +
+ +
+
+
+

{s.diagram.title}

+

{s.diagram.body}

+ {s.diagram.more} +
+

+ +

+
+
+
+

{p.source}

+

+

+ {phaseQuote[0]}{phaseQuote[1]}{phaseQuote[2]} +

+
+
+ + + + + { + diagram.phases.map((phase, i) => ( + <> + + + + )) + } + + {diagram.arrows.map((d) => )} + + + + + { + diagram.phases.map((phase, i) => ( + + )) + } + + + +
+
+ + +
+
+
+
+ + + + diff --git a/apps/landing/src/components/landing/Material.astro b/apps/landing/src/components/landing/Material.astro new file mode 100644 index 00000000..a65f631d --- /dev/null +++ b/apps/landing/src/components/landing/Material.astro @@ -0,0 +1,498 @@ +--- +// What Study reads: the Cell biology sample's library, one row per file with its kind's glyph +// and its size, and the lecture that drops into it, is listened to, and becomes a transcript +// whose lines type in. +import Icon from '../Icon.astro'; +import { pen } from '../rough'; +import { draw, keyed, words } from './text'; +import { href, t, type Lang } from '../../i18n/strings'; + +interface Props { + lang: Lang; +} +const { lang } = Astro.props; +const s = t(lang); +const m = s.material; +const docs = (slug: string) => href(lang, `docs/${slug}/`); + +// Each listed file's kind glyph, in the order of `material.files`: Audio, PDF and Web, as the +// app draws them. +const kindIcons = ['audio-lines', 'file-text', 'file-text']; + +const ink = pen(63); +const pencil = draw(ink.pencil(2, 4, 44)); +--- + +
+
+ +

+

{m.body}

+ +

+ +
+

+ {m.library} + {m.course} +

+
    +
  • +
    +
    + + {m.arriving.name} + + {m.arriving.size} + + {m.arriving.working} + {m.arriving.done} + + +
    +
    +
  • + { + m.files.map((name, i) => { + const icon = kindIcons[i]; + return ( +
  • +
    + + + + {name} + {m.sizes[i]} +
    +
  • + ); + }) + } +
+
+ +
+ + +
+ +
+
+

{m.arriving.name} · {m.transcript}

+

+ 2:02 + +

+

+ 3:03 + +

+
+
+ + + + + + + + + + + +
+ + + + diff --git a/apps/landing/src/components/landing/Yours.astro b/apps/landing/src/components/landing/Yours.astro new file mode 100644 index 00000000..12dd1751 --- /dev/null +++ b/apps/landing/src/components/landing/Yours.astro @@ -0,0 +1,477 @@ +--- +// Where the data stays: a sketch of the learner's computer holding their library, and the one +// dashed trip out to their ChatGPT plan, then three facts about privacy, cost and the code. +// +// The sketch draws in order: the laptop, the library filling, the cloud, the dashed line out, +// then its labels type. Every label and the line are placed from the same geometry as the part +// they name (the constants below), inside one SVG, so they stay on their part at every width. +import Icon from '../Icon.astro'; +import { pen } from '../rough'; +import { chars, keyed } from './text'; +import { href, repo, t, type Lang } from '../../i18n/strings'; + +interface Props { + lang: Lang; +} +const { lang } = Astro.props; +const s = t(lang); +const docs = (slug: string) => href(lang, `docs/${slug}/`); + +type Pt = [number, number]; +const ink = pen(65); + +// The sheet, and the parts everything else is placed from. +const W = 540; +const H = 436; +const lid = { x: 38, y: 160, w: 300, h: 190 }; +const screen = { x: lid.x + 14, y: lid.y + 14, w: lid.w - 28, h: lid.h - 26 }; +const deck = lid.y + lid.h; +const mid = lid.x + lid.w / 2; +const shelf = screen.y + 118; +const db = { cx: screen.x + 66, top: screen.y + 32, bottom: shelf - 6, rx: 34, ry: 10 }; +const books = [ + { x: screen.x + 136, h: 74, w: 22 }, + { x: screen.x + 160, h: 88, w: 18 }, + { x: screen.x + 180, h: 66, w: 26 }, +]; +const cloudPts: Pt[] = [ + [304, 123], + [287, 103], + [295, 78], + [322, 70], + [335, 43], + [370, 27], + [405, 36], + [430, 18], + [469, 20], + [493, 43], + [522, 49], + [535, 76], + [524, 103], + [500, 121], + [405, 128], + [304, 123], +]; +const cloudLeft = cloudPts[1]; +// The line leaves the lid's top edge above the books and ends a few points off the cloud. +const linkFrom: Pt = [screen.x + 210, lid.y - 8]; +const linkTo: Pt = [cloudLeft[0] - 7, cloudLeft[1] - 3]; + +// A curve, and its lighter second pass from a fresh pull of the same pen. +const both = (make: () => string) => [make(), make()] as const; + +const laptop = { + lid: ink.rect(lid.x, lid.y, lid.w, lid.h), + screen: ink.rect(screen.x, screen.y, screen.w, screen.h), + camera: ink.ring(mid, lid.y + 7, 2.2, 2.2), + base: ink.poly([ + [lid.x, deck], + [lid.x + lid.w, deck], + [lid.x + lid.w + 28, deck + 24], + [lid.x - 28, deck + 24], + ]), + lip: both(() => + ink.curve([ + [lid.x - 28, deck + 24], + [lid.x - 24, deck + 32], + [lid.x + lid.w + 24, deck + 32], + [lid.x + lid.w + 28, deck + 24], + ]), + ), + pad: ink.curve([ + [mid - 34, deck + 24], + [mid - 28, deck + 18], + [mid + 28, deck + 18], + [mid + 34, deck + 24], + ]), +}; + +const arc = (y: number) => () => + ink.curve([ + [db.cx - db.rx, y], + [db.cx, y + db.ry * 1.2], + [db.cx + db.rx, y], + ]); +const span = db.bottom - db.top; +const leaningPts: Pt[] = [ + [screen.x + 210, shelf], + [screen.x + 230, shelf], + [screen.x + 256, shelf - 54], + [screen.x + 238, shelf - 62], +]; +const library = { + // The cylinder's body as one closed shape: over the top ellipse, down its sides, round the base. + dbFill: `M${db.cx - db.rx} ${db.top} A${db.rx} ${db.ry} 0 0 1 ${db.cx + db.rx} ${db.top} L${db.cx + db.rx} ${db.bottom} Q${db.cx} ${db.bottom + db.ry * 2.4} ${db.cx - db.rx} ${db.bottom} Z`, + dbTop: both(() => ink.ring(db.cx, db.top, db.rx, db.ry)), + dbSides: `${ink.line(db.cx - db.rx, db.top, db.cx - db.rx, db.bottom)} ${ink.line(db.cx + db.rx, db.top, db.cx + db.rx, db.bottom)}`, + dbBands: [arc(db.top + span / 3)(), arc(db.top + (span * 2) / 3)()], + dbBottom: both(arc(db.bottom)), + shelf: ink.line(screen.x + 128, shelf, screen.x + screen.w - 10, shelf), + books: books.map((b) => ({ + fill: ink.fill(b.x + 1, shelf - b.h + 1, b.w - 2, b.h - 2), + outline: ink.rect(b.x, shelf - b.h, b.w, b.h), + marks: `${ink.line(b.x + 5, shelf - b.h + 12, b.x + b.w - 5, shelf - b.h + 12)} ${ink.line(b.x + 5, shelf - 14, b.x + b.w - 5, shelf - 14)}`, + })), + leaning: ink.poly(leaningPts), + leaningFill: `M${leaningPts.map((p) => p.join(' ')).join(' L')} Z`, +}; + +const cloud = { outline: both(() => ink.curve(cloudPts)), fill: ink.curve(cloudPts) }; + +// The arrow's shaft is dashed and revealed by a solid stroke drawing over it; its head is solid. +const arrow = ink.arrow(...linkFrom, ...linkTo, -14, 12); +const [shaft, ...wings] = arrow.split(' M').map((d, i) => (i ? `M${d}` : d)); +const head = wings.join(' '); + +// Labels sit on their part: the library under the shelf, the computer under the deck, the plan +// in the cloud's middle, and the note beside the line's lower half. +const label = { + library: { x: screen.x + screen.w / 2, y: screen.y + screen.h - 12 }, + computer: { x: mid, y: deck + 66 }, + plan: { x: 414, y: 66, step: 28 }, + out: { x: linkFrom[0] - 18, y: (linkFrom[1] + linkTo[1]) / 2 - 6 }, +}; + +// When each part starts drawing, in milliseconds after the sketch comes into view. +// Which step of the sequence each stroke belongs to, and its place in that step; the script +// below turns these into delays from scripts/pace.ts. +const step = (part: 'laptop' | 'library' | 'cloud' | 'link' | 'head', i: number) => ({ 'data-part': part, 'data-i': i }); +--- + +
+
+ +

+

{s.yours.lead}

+ {s.yours.more} +

+ +
+
+ + {`${s.yours.sketch.library}, ${s.yours.sketch.computer}; ${s.yours.sketch.out.join(' ')}: ${s.yours.sketch.plan.join(' ')}`} + + + + + + + + + + + + + + + + + + + {library.books.map((b, i) => )} + + + + + + {library.dbBands.map((d, i) => )} + + {library.books.map((b, i) => )} + {library.books.map((b, i) => )} + + + + + + + + + + + + + + + + + + + + { + s.yours.sketch.plan.map((line, i) => ( + + )) + } + + { + s.yours.sketch.out.map((line, i) => ( + + )) + } + + +
+ +
+ +

{s.yours.local.title}

+

{s.yours.local.body} {s.yours.local.never}{s.yours.local.end}

+ +
+
+ +

{s.yours.plan.title}

+

{s.yours.plan.body}

+
+
+ +
+

{s.yours.open.title}

+

{s.yours.open.body}

+
+ {s.yours.open.link} +
+
+
+ + + + diff --git a/apps/landing/src/components/landing/text.ts b/apps/landing/src/components/landing/text.ts new file mode 100644 index 00000000..4afff460 --- /dev/null +++ b/apps/landing/src/components/landing/text.ts @@ -0,0 +1,33 @@ +// What the landing sections write into their markup on the server: text split into the units +// `scripts/ink.ts` reveals, hand-drawn strokes ready for an inline SVG, and key phrases marked +// by hand. + +export const esc = (text: string) => text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + +/** Text that types by letter (`data-type="ch"`); `tspan` inside an SVG ``. */ +export const chars = (text: string, tag = 'span') => [...text].map((c) => (c === ' ' ? ' ' : `<${tag} class="t">${esc(c)}`)).join(''); + +/** Text that types or writes by word (`data-type="w"` or `"hand"`). */ +export const words = (text: string) => + text + .split(/(\s+)/) + .map((w) => (!w || /^\s+$/.test(w) ? w : `${esc(w)}`)) + .join(''); + +/** Strokes that draw along their length. */ +export const draw = (...ds: string[]) => ds.map((d) => ``).join(''); + +/** A stroke with a second, lighter pass, as a pen leaves it. */ +export const pressed = (make: () => string) => `${draw(make())}`; + +/** + * A headline with its one key phrase underlined or ringed by hand: an anchored mark + * (scripts/anchor.ts) sized to the phrase, the phrase its own ink scope. Each heading passes its own number, which names the + * phrase and so seeds its stroke; changing one never redraws another. + */ +export const keyed = (title: string, key: string, mark: 'under' | 'ring', id: number) => { + const at = title.indexOf(key); + if (at < 0) return esc(title); + const svg = ``; + return `${esc(title.slice(0, at))}${esc(key)}${svg}${esc(title.slice(at + key.length))}`; +}; diff --git a/apps/landing/src/components/rough.ts b/apps/landing/src/components/rough.ts new file mode 100644 index 00000000..b1308a70 --- /dev/null +++ b/apps/landing/src/components/rough.ts @@ -0,0 +1,178 @@ +// Hand-drawn strokes for inline SVG, in the app's diagram language: every line bows a little +// and is drawn twice, slightly apart. A pen is seeded, so each build draws the same strokes. + +export type Pt = [number, number]; + +/** The control points of a cubic from `a` to `b` whose middle stands `bend` off the chord, to the right of travel. */ +export const bow = (a: Pt, b: Pt, bend: number): [Pt, Pt] => { + const len = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1; + const k = (bend * 4) / 3; + const nx = -(b[1] - a[1]) / len; + const ny = (b[0] - a[0]) / len; + return [ + [a[0] + (b[0] - a[0]) / 3 + nx * k, a[1] + (b[1] - a[1]) / 3 + ny * k], + [a[0] + ((b[0] - a[0]) * 2) / 3 + nx * k, a[1] + ((b[1] - a[1]) * 2) / 3 + ny * k], + ]; +}; + +/** The point `t` of the way along the cubic `a c1 c2 b`. */ +export const along = (a: Pt, c1: Pt, c2: Pt, b: Pt, t: number): Pt => { + const u = 1 - t; + const f = (i: 0 | 1) => u * u * u * a[i] + 3 * u * u * t * c1[i] + 3 * u * t * t * c2[i] + t * t * t * b[i]; + return [f(0), f(1)]; +}; + +/** A string hashed to a pen seed, so a stroke named by its markup draws the same on every load. */ +export const seed = (text: string) => { + let h = 2166136261; + for (const c of text) h = Math.imul(h ^ c.charCodeAt(0), 16777619); + return h >>> 0; +}; + +export function pen(seed: number) { + let state = seed >>> 0 || 1; + const rand = () => (state = (state * 1664525 + 1013904223) >>> 0) / 2 ** 32; + const jit = (amount: number) => (rand() - 0.5) * 2 * amount; + const n = (v: number) => Math.round(v * 10) / 10; + + const stroke = (x1: number, y1: number, x2: number, y2: number, wobble = 1.4) => { + const bow = Math.min(Math.hypot(x2 - x1, y2 - y1) * 0.02, 2.5); + const mx = (x1 + x2) / 2 + jit(bow); + const my = (y1 + y2) / 2 + jit(bow); + return `M${n(x1 + jit(wobble))} ${n(y1 + jit(wobble))} Q${n(mx)} ${n(my)} ${n(x2 + jit(wobble))} ${n(y2 + jit(wobble))}`; + }; + const twice = (draw: () => string) => `${draw()} ${draw()}`; + + // A smooth closed or open curve through points, as cubic Béziers (Catmull-Rom). + const through = (pts: Pt[]) => { + let d = `M${n(pts[0][0])} ${n(pts[0][1])}`; + for (let i = 0; i < pts.length - 1; i++) { + const p0 = pts[Math.max(i - 1, 0)]; + const p1 = pts[i]; + const p2 = pts[i + 1]; + const p3 = pts[Math.min(i + 2, pts.length - 1)]; + const c1: Pt = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6]; + const c2: Pt = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6]; + d += ` C${n(c1[0])} ${n(c1[1])} ${n(c2[0])} ${n(c2[1])} ${n(p2[0])} ${n(p2[1])}`; + } + return d; + }; + + /** + * An arrow from `a` to `b` bowed `bend` to the right of travel: a shaft that wavers a + * little, and a head of two bowed flicks along the shaft's last tangent, its tip on `b`. + */ + const bowed = (a: Pt, b: Pt, bend = 0, head = 11) => { + const len = Math.hypot(b[0] - a[0], b[1] - a[1]); + const waver = Math.min(len * 0.02, 2.5); + const [c1, c2] = bow(a, b, bend).map(([x, y]) => [x + jit(waver), y + jit(waver)] as Pt); + const shaft = `M${n(a[0] + jit(0.6))} ${n(a[1] + jit(0.6))} C${n(c1[0])} ${n(c1[1])} ${n(c2[0])} ${n(c2[1])} ${n(b[0])} ${n(b[1])}`; + const back = Math.atan2(c2[1] - b[1], c2[0] - b[0]); + const size = Math.min(head, len * 0.4); + // One flick a little longer than the other, each bowing as the pen lifts. + const wing = (side: number) => { + const angle = back + side * (0.46 + jit(0.06)); + const reach = size * (side > 0 ? 1 : 0.82); + const ex = b[0] + Math.cos(angle) * reach; + const ey = b[1] + Math.sin(angle) * reach; + const lift = side * reach * 0.12; + return `M${n(b[0])} ${n(b[1])} Q${n((b[0] + ex) / 2 - Math.sin(angle) * lift)} ${n((b[1] + ey) / 2 + Math.cos(angle) * lift)} ${n(ex)} ${n(ey)}`; + }; + return { shaft, head: `${wing(1)} ${wing(-1)}` }; + }; + + return { + line: (x1: number, y1: number, x2: number, y2: number) => twice(() => stroke(x1, y1, x2, y2)), + rect: (x: number, y: number, w: number, h: number) => + twice(() => [stroke(x, y, x + w, y), stroke(x + w, y, x + w, y + h), stroke(x + w, y + h, x, y + h), stroke(x, y + h, x, y)].join(' ')), + /** The wash under a box: its fill, a little off the lines, as a marker leaves it. */ + fill: (x: number, y: number, w: number, h: number) => + `M${n(x + jit(2))} ${n(y + jit(2))} L${n(x + w + jit(2))} ${n(y + jit(2))} L${n(x + w + jit(2))} ${n(y + h + jit(2))} L${n(x + jit(2))} ${n(y + h + jit(2))} Z`, + bowed, + /** A hand-placed arrow, for strokes drawn into a fixed viewBox; `bend` as in a quadratic's control point. */ + arrow: (x1: number, y1: number, x2: number, y2: number, bend = 0, head = 11) => { + const { shaft, head: tip } = bowed([x1, y1], [x2, y2], bend / 2, head); + return `${shaft} ${tip}`; + }, + /** + * A loose ring, drawn once round and a little past where it started. Below 1, `square` + * flattens it toward a rounded box that hugs a word, and its loop never swells sideways + * past `rx`, so it stays off the next word. + */ + ring: (cx: number, cy: number, rx: number, ry: number, square = 1) => { + const pts: Pt[] = []; + const start = -0.6; + const hug = square < 1; + const steps = hug ? 16 : 8; + const bend = (c: number) => Math.sign(c) * Math.abs(c) ** square; + for (let i = 0; i <= steps + 1; i++) { + const a = start + (i / steps) * Math.PI * 2 * 1.06; + const grow = (i / (steps + 1)) * 0.06; + const kx = hug ? 1 - Math.abs(jit(0.02)) : 1 + jit(0.05) + grow; + const ky = hug ? 1 + jit(0.02) + grow * 0.3 : kx; + pts.push([cx + bend(Math.cos(a)) * rx * kx, cy + bend(Math.sin(a)) * ry * ky]); + } + return through(pts); + }, + /** An underline that drifts, the way a quick one does. */ + underline: (x: number, y: number, w: number) => + through([ + [x, y + jit(1)], + [x + w * 0.35, y - 2 + jit(1)], + [x + w * 0.7, y + 1 + jit(1)], + [x + w, y - 3 + jit(1)], + ]), + /** A small doodled star. */ + star: (cx: number, cy: number, r: number) => { + const pts: Pt[] = []; + for (let i = 0; i <= 10; i++) { + const a = -Math.PI / 2 + (i * Math.PI) / 5; + const k = (i % 2 ? 0.45 : 1) * r + jit(r * 0.08); + pts.push([cx + Math.cos(a) * k, cy + Math.sin(a) * k]); + } + return pts.map((p, i) => `${i ? 'L' : 'M'}${n(p[0])} ${n(p[1])}`).join(' '); + }, + /** A tall square bracket, its ends turned in toward what it marks. */ + bracket: (x: number, y: number, h: number, w = 8) => + through([ + [x + w, y + jit(1)], + [x + jit(0.6), y + 3], + [x + jit(1), y + h / 2], + [x + jit(0.6), y + h - 3], + [x + w, y + h + jit(1)], + ]), + /** A quick checkmark: a short dip, then a long stroke up. */ + check: (x: number, y: number, s: number) => + through([ + [x, y + s * 0.55], + [x + s * 0.32 + jit(0.8), y + s * 0.92], + [x + s * 0.62, y + s * 0.42 + jit(0.8)], + [x + s, y + jit(0.8)], + ]), + /** A small pencil, lying at an angle, drawn in outline. */ + pencil: (x: number, y: number, s: number) => { + const P = (px: number, py: number): Pt => [x + px * s, y + py * s]; + const seg = (a: Pt, b: Pt) => stroke(a[0], a[1], b[0], b[1], 0.5); + const [a, b, c, d] = [P(0.06, 0.62), P(0.66, 0.06), P(0.18, 0.76), P(0.78, 0.2)]; + const tip = P(0.98, 0); + return [seg(a, b), seg(c, d), seg(a, c), seg(b, tip), seg(d, tip), seg(P(0.15, 0.53), P(0.27, 0.67))].join(' '); + }, + curve: (pts: Pt[]) => through(pts.map(([x, y]) => [x + jit(1), y + jit(1)] as Pt)), + /** A closed outline through corners, each side its own bowed stroke. */ + poly: (pts: Pt[]) => twice(() => pts.map((p, i) => stroke(p[0], p[1], ...pts[(i + 1) % pts.length], 0.8)).join(' ')), + /** One sweep of a highlighter: a band with ragged long edges and rounded, uneven ends. */ + swash: (w: number, h: number) => { + const steps = 8; + const top: Pt[] = []; + const bottom: Pt[] = []; + for (let i = 0; i <= steps; i++) { + const x = 4 + ((w - 8) * i) / steps; + top.push([x, h * 0.16 - (i / steps) * h * 0.08 + jit(h * 0.05)]); + bottom.push([x, h * 0.9 - (i / steps) * h * 0.04 + jit(h * 0.05)]); + } + const ends = (x: number, a: Pt, b: Pt, out: number): Pt => [x + out, (a[1] + b[1]) / 2 + jit(h * 0.06)]; + const loop: Pt[] = [...top, ends(w - 4, top[steps], bottom[steps], 3), ...bottom.reverse(), ends(4, bottom[steps], top[0], -3), top[0]]; + return `${through(loop)} Z`; + }, + }; +} diff --git a/apps/landing/src/components/variant.ts b/apps/landing/src/components/variant.ts new file mode 100644 index 00000000..20877d05 --- /dev/null +++ b/apps/landing/src/components/variant.ts @@ -0,0 +1,25 @@ +// Which capture of a piece of docs media to show. `just docs-media` makes each one in every +// language and theme, named `--`. A published build shows only the exact +// one, so a capture that failed to be made fails the build instead of showing the wrong +// language; while writing (`just landing`), a missing one falls back to English, then to the +// other theme, so a few scenes made by name are enough to see a page. +import type { Lang } from '../i18n/strings'; + +export type Theme = 'light' | 'dark'; + +/** The file names to try for `name`, best first. */ +function variants(name: string, lang: Lang, theme: Theme): string[] { + const exact = `${name}-${lang}-${theme}`; + if (import.meta.env.PROD) return [exact]; + const other: Theme = theme === 'light' ? 'dark' : 'light'; + return [exact, `${name}-en-${theme}`, `${name}-${lang}-${other}`, `${name}-en-${other}`]; +} + +/** The first of `name`'s variants that `find` has, or an error naming what is missing. */ +export function pick(name: string, lang: Lang, theme: Theme, find: (file: string) => T | undefined): T { + for (const file of variants(name, lang, theme)) { + const found = find(file); + if (found !== undefined) return found; + } + throw new Error(`no ${lang}-${theme} capture named ${name}: make it with \`just docs-media ${name}\``); +} diff --git a/apps/landing/src/content.config.ts b/apps/landing/src/content.config.ts new file mode 100644 index 00000000..e3241172 --- /dev/null +++ b/apps/landing/src/content.config.ts @@ -0,0 +1,24 @@ +// The docs, one Markdown page per feature in each language: MDX when it shows captures of +// the app inline (see astro.config.mjs). A page's file name is its slug, and the English +// and Italian pages of one feature share it. +import { defineCollection, z } from 'astro:content'; +import { glob } from 'astro/loaders'; + +/** The sidebar's sections, in order; their names are in strings.ts. */ +export const sections = ['start', 'learn', 'more'] as const; + +const docs = defineCollection({ + loader: glob({ pattern: '{en,it}/*.{md,mdx}', base: './src/content/docs' }), + schema: z.object({ + title: z.string(), + description: z.string(), + // The sidebar groups pages by section, then sorts them by order. + section: z.enum(sections), + order: z.number(), + // The capture at the top of the page, from src/assets/shots by name (session, citation, …). + shot: z.string().optional(), + shotAlt: z.string().optional(), + }), +}); + +export const collections = { docs }; diff --git a/apps/landing/src/content/docs/en/answers.mdx b/apps/landing/src/content/docs/en/answers.mdx new file mode 100644 index 00000000..7db1d3dc --- /dev/null +++ b/apps/landing/src/content/docs/en/answers.mdx @@ -0,0 +1,55 @@ +--- +title: Answers and citations +description: How @study answers a note from your own files, and how to check where each claim came from. +section: learn +order: 3 +shot: citation +shotAlt: An answer with numbered citations, and the cited passage open in a dialog with its page. +--- + +When a note mentions **@study**, Study answers it from your own material and shows where each part of the answer came from. You can open any citation and read the passage it rests on. + +## Asking + +Write your question in a [session](../sessions/) and mention **@study** anywhere in the note. Notes without the mention get no reply, so you can write freely and ask only when you want to. + +![A session with a question to @study already answered, and the @study menu open in the note box.](shot:study-menu "Mention @study anywhere in a note to get an answer.") + +Study waits until the files attached to that note have been read, then answers. If something is missing, the answer waits and says why, for example **Waiting for ChatGPT sign-in**. Answers run on your ChatGPT plan. + +## Where an answer looks + +An answer draws first on the files in the session you're in, all of them. Then it adds the most related passages from the rest of the project. Files outside the project are not used. + +Because answers come from your files, the better your sources, the better the answers. If a file was read wrongly, fix its text with **Correct this text** in **File details**, and later answers use your version. + +## Reading citations + +Each claim that rests on a passage carries a small numbered chip, such as [1] or [2]. Under the answer, **Sources** lists every cited file with the place the passage comes from. + +Places are written the way you'd look them up: + +| Source | Place | +|---|---| +| PDF or picture | `p. 3` | +| Recording or video | `4:05–4:40`, or `1:02:03` for long ones | +| Text or code | `lines 12–18` | +| Slides | `slide 4` | +| Word document | `¶ 2` | +| Web page | `#section`, when the part is known | + +## Opening a citation + +Select a chip or a source to open it. A dialog shows the file and the place, with the cited passage highlighted among the text around it. **Open in Library** takes you to the whole file, and **Close** returns to the answer. + +![Selecting a citation chip under an answer opens the cited passage, highlighted in the transcript.](clip:citation-open "Select a citation to read the passage it rests on.") + +Use this to check an answer before you rely on it. If the passage doesn't say what the answer claims, trust the passage. + +## Changing an answer + +Answers keep their versions, as your notes do. **Regenerate** asks for a new answer, **AI edit** rewrites the current one with **Improve**, **Summarize** or your own instruction, and **Versions** moves between them. See [sessions](../sessions/#edit-and-compare-versions). + +## Citations elsewhere + +Answers aren't the only place with citations. [Flashcards](../flashcards/), the [diagram](../notes-and-diagrams/) and [practice](../practice/) questions also list their **Sources**, so you can always go back to the page or moment something came from. diff --git a/apps/landing/src/content/docs/en/background-work.mdx b/apps/landing/src/content/docs/en/background-work.mdx new file mode 100644 index 00000000..a45ef452 --- /dev/null +++ b/apps/landing/src/content/docs/en/background-work.mdx @@ -0,0 +1,71 @@ +--- +title: Background work +description: Follow what Study is doing in the background, and fix what needs your attention. +section: more +order: 1 +shot: activity +shotAlt: Activity, with the work Study has done and what is still to come. +--- + +Transcribing a lecture, reading a PDF or writing flashcards takes time, so Study does it in the background while you keep working. **Activity** in the sidebar shows all of it. Home also tells you when something needs you, with **Open activity**. + +## What you'll see + +Activity groups the work by where it stands: + +- **Working now**, running at the moment +- **Up next**, waiting its turn +- **Needs attention**, stopped by something you can fix +- **Stopped**, what you stopped yourself +- **Finished**, done + +Each item names its stage and the file, session or project it's for. The stages are: + +| Stage | What it does | +|---|---| +| Fetching a link | Saves a web page or a YouTube video's sound | +| Transcription | Turns a recording into text, on your computer | +| Reading pages | Reads PDF pages and pictures on your ChatGPT plan | +| Reading | Reads documents, slides, web pages and text | +| Search index | Adds the text to word search | +| Search by meaning | Adds the text to search by meaning, on your computer | +| Session title | Names a session from its notes | +| Answer | Writes an @study answer | +| Rewrite | Carries out an AI edit | +| Study material | Writes flashcards or a diagram | +| Practice question | Writes the next practice question | +| Grading an answer | Grades a practice answer | + +One step leads to the next. A recording, for example, is transcribed, corrected and tidied, then indexed, and then it can feed answers and study material. + +## Stopping and restarting + +**Stop** stops an item that's running or waiting. **Start again** runs a stopped item again. When something failed, **Try again** gives it another go. + +## When work waits for setup + +Some work needs something set up first. Instead of failing, it waits under **Up next** in Activity, and the page that needs it (a session, study material or practice) shows a card for what's missing: + +- **Connect your ChatGPT plan**, with sign-in right on the card +- **Download the transcription model** +- **Download the search model** + +![Activity with a practice question waiting under Up next, above the finished work.](shot:activity-waiting "Work held up by setup waits under Up next, and picks up on its own once it is ready.") + +**This picks up on its own once it is ready.** You don't need to restart anything after you sign in or install a model. + +## When something goes wrong + +Short problems, such as a dropped connection or a busy service, are retried on their own after a pause that grows each time. You don't need to do anything. + +Other problems land in **Needs attention** with a message saying what happened and what to do. For example: + +- you're signed out, or the connection is down +- a file can't be read; saving it again as PDF, PNG, MP3 or M4A often helps +- there isn't enough material yet to write from +- a model isn't installed +- the file, session or project was deleted + +## Quitting is safe + +Study keeps its work in its database, not only in memory. If you quit, or the app closes unexpectedly, the work is still there when you open Study again, and it carries on where it can. Recordings are saved every second while they run; see [sessions](../sessions/#record-a-lecture). diff --git a/apps/landing/src/content/docs/en/first-steps.mdx b/apps/landing/src/content/docs/en/first-steps.mdx new file mode 100644 index 00000000..320bab29 --- /dev/null +++ b/apps/landing/src/content/docs/en/first-steps.mdx @@ -0,0 +1,63 @@ +--- +title: First steps +description: What happens the first time you open Study, and how to set up your first project. +section: start +order: 2 +shot: home +shotAlt: The Study home page, with a greeting, the cards due today, an exam countdown and quick actions. +--- + +The first time you open Study, it gets itself ready, gives you a short tour, and then leaves you on the home page. You can skip any of it and come back later. + +## Getting ready + +Study first shows **Getting Study ready**. It measures your computer once, to know which local models it can run well. If you'd rather not wait, choose **Continue without it**. + +## The welcome tour + +The tour has a few short steps, and each one has **Skip**. + +- **Make it yours** sets the language (English or Italiano) and the look. +- **Sessions are your notebook** explains where your notes go. +- **Where AI runs** offers **Download what runs here**, for the models that run on your computer, and sign-in with your ChatGPT Plus or Pro plan. +- **You're all set** ends the tour with **Start studying**. + +To see it again, open **Help** and choose **Replay the welcome tour**. + +## Connecting your ChatGPT plan + +Choose **Sign in with ChatGPT**. Study opens your browser and shows **Finish signing in in your browser…** until you're done. If ChatGPT asks, choose **Allow use of my plan**. Some accounts can't use their plan in other apps; Study tells you if yours is one of them. + +Work done on your plan counts toward its usage limits, the same limits ChatGPT uses. Until you sign in, work that needs the plan waits for you in [Activity](../background-work/). + +## Make a project + +Everything in Study belongs to a project, usually one per course. Choose **New project**, give it a name, and choose **Create project**. + +![The New project dialog over a new session, with "Genetics" typed as the name.](shot:new-project "Name the project after the course, then choose Create project.") + +A project's page is where its work comes together: + +- **New session** starts a page of notes for a lecture or study session. See [sessions](../sessions/). +- **Take the quiz** opens [practice](../practice/). +- **Review flashcards** starts a [flashcard](../flashcards/) review. +- **View files** shows the project's files in the Library. +- **Study material** shows how the project's flashcards and diagram are doing. +- **Rename project** and **Delete project**. Deleting a project keeps its files in the Library. + +## Set an exam day + +On a project's page, **Exam day** lets you pick the date of the exam. Study uses it for a countdown: Home and the flashcard reviews count down to it, nearest exam first, with labels such as **Exam in 5 days**, **Exam tomorrow** or **Exam today**. The date doesn't change what you study or how cards are scheduled. + +## The home page + +Home is where Study opens. It shows: + +- a card for today, with the flashcards due across all your projects and your nearest exam, plus **Review now**; when nothing is due, it points you to practice instead +- quick actions: **New session**, **Add files** and **New project** +- **Pick up where you left off**, with your recent sessions +- work that needs you, with **Open activity** +- **Recent files** +- what this computer runs + +The sidebar holds the rest: **Library**, **Projects**, **Flashcards**, **Diagrams**, **Practice**, **Activity**, **Settings** and **Help**. diff --git a/apps/landing/src/content/docs/en/flashcards.mdx b/apps/landing/src/content/docs/en/flashcards.mdx new file mode 100644 index 00000000..0c3cfa9e --- /dev/null +++ b/apps/landing/src/content/docs/en/flashcards.mdx @@ -0,0 +1,57 @@ +--- +title: Flashcards +description: Review cards written from your project, scheduled so you see each one before you forget it. +section: learn +order: 5 +shot: flashcard +shotAlt: A flashcard review showing a question, its answer and the four rating buttons. +--- + +Study writes flashcards from a whole project's sources and schedules them with spaced repetition. You review the cards that are due, rate how well you remembered each one, and Study decides when to show it again. + +## Where cards come from + +Each project has one set of flashcards, written on your ChatGPT plan from the project's read sources. Each card cites the passages it rests on, and its **Sources** take you back to the page or moment it came from. + +The set lives in the project's **Study material**, and in **Flashcards** in the sidebar. Like the [diagram](../notes-and-diagrams/), it starts with **Make** and later offers **Update** when you want new cards from new material. An update revises the set instead of starting over. + +Which kinds of source feed the cards is set in **Settings › Processing**, under **Study material**. Code and spreadsheets are left out by default. + +## Reviewing + +Choose **Review** in **Flashcards**, **Review flashcards** on a project, or **Review now** on Home. Each card shows its question first. + +1. Try to remember the answer, then choose **Show answer**, or press Space or Enter. +2. Rate how it went: **Again**, **Hard**, **Good** or **Easy**. The keys 1 to 4 do the same. + +![A card in a review turns over to show its answer, then is rated Good.](clip:flashcard-flip "Show the answer, then rate how well you remembered it.") + +The cards you're closest to forgetting come first. When none are left, Study says **Review done. Cards come back when they are due.** + +Rate honestly. **Again** brings a card back soon; **Easy** pushes it further away. Scheduling uses FSRS, a spaced repetition method that learns from your ratings how quickly you forget each card. + +## How many new cards + +New cards are mixed in gradually, so a large set doesn't land on you all at once. **New flashcards a day**, in **Settings › Processing**, sets how many new ones a day's reviews may include. + +The **Flashcards** page shows what's coming up, including the cards due in the next 7 days. If a project has an exam date, its review tile counts down to it. + +![The Flashcards page: the cards due, reviews this week, the streak, and a tile for each project with its exam countdown.](shot:flashcards "The Flashcards page shows what is due, overall and for each project.") + +## Browsing and editing + +Open a set to look through all of its cards outside a review. Space turns a card over, and the left and right arrow keys move through the set. **Turn all over** and **Turn all back** flip every card at once. + +![The Cell biology set open outside a review, with one card face up and the whole set listed below.](shot:flashcard-deck "Browse a set one card at a time, or all at once.") + +You can change the set by hand: + +- **Edit card** changes its **Question** or **Answer** +- **Add a card** writes one of your own +- **Delete card** removes one + +From [practice](../practice/), **Add to my flashcards** turns a question you missed into a card. + +## Using cards elsewhere + +**Save for Anki** saves the set as a text file, one card per line with the front and back separated by a tab. Anki and most other flashcard apps can import it. diff --git a/apps/landing/src/content/docs/en/install.md b/apps/landing/src/content/docs/en/install.md new file mode 100644 index 00000000..03644e45 --- /dev/null +++ b/apps/landing/src/content/docs/en/install.md @@ -0,0 +1,53 @@ +--- +title: Install Study +description: Download Study for Linux, macOS or Windows, and keep it up to date. +section: start +order: 1 +--- + +Study is a desktop app. You install it once on your computer, and your projects, files and notes stay on that computer. Each release on the project's GitHub releases page has one download per platform. + +## Pick your download + +| Platform | File | +|---|---| +| Linux on Intel or AMD | `study-linux-x86_64.AppImage` | +| Linux on ARM | `study-linux-aarch64.AppImage` | +| macOS on Apple Silicon | `study-macos-aarch64.dmg` | +| Windows | `study-windows-x86_64.exe` | + +On **Linux**, the AppImage is the whole app. Make it executable, then open it: + +```sh +chmod +x study-linux-x86_64.AppImage +./study-linux-x86_64.AppImage +``` + +The Linux builds are made on Ubuntu 24.04, so they need a distribution of about that age or newer. + +On **macOS**, open the `.dmg` and drag Study into Applications. Study needs macOS 12 or later on an Apple Silicon Mac. There is no build for Intel Macs. + +On **Windows**, run the `.exe` installer and follow its steps. + +## Optional tools + +Two command-line tools add to what Study can read. You don't need either to get started. + +- **yt-dlp** lets Study take the sound of a YouTube link and transcribe it. Study asks it for the audio only, so this needs nothing else. +- **FFmpeg** is a last resort for unusual recording formats, such as AMR or WMA. Common formats like MP3, M4A, WAV and the sound of most videos are read without it. + +Install them with your system's package manager, so Study can find them on your path. + +## What runs where + +Study does its model work on your ChatGPT plan, so you sign in with a ChatGPT Plus or Pro account the first time you open it. Two things run on your computer instead: transcribing recordings, and search by meaning. Each needs a model that you download from inside the app, only when you choose to. [First steps](../first-steps/) walks through this, and [privacy](../privacy/) says what goes where. + +## Updates + +Study doesn't update itself behind your back. To look for a new version, open **Settings › Updates** and choose **Check for updates**. If one is available, **Install and restart** downloads it and opens the new version. Updates are signed, and Study checks the signature before installing. + +An update replaces only the app. Your database, your files and the models you downloaded stay where they are, and background work picks up again after the restart. + +## Uninstall + +Remove the app the way you'd remove any other on your system. Your data lives in a separate folder, which [privacy](../privacy/) lists for each platform. Delete that folder too if you want nothing left behind. diff --git a/apps/landing/src/content/docs/en/keyboard.md b/apps/landing/src/content/docs/en/keyboard.md new file mode 100644 index 00000000..786cd44d --- /dev/null +++ b/apps/landing/src/content/docs/en/keyboard.md @@ -0,0 +1,81 @@ +--- +title: Keyboard shortcuts +description: The keys Study responds to, on macOS, Linux and Windows. +section: more +order: 4 +--- + +These are the keys Study responds to. Where macOS uses Cmd, Linux and Windows use Ctrl; the tables say so where they differ. + +## Anywhere in the app + +| Action | macOS | Linux and Windows | +|---|---|---| +| Open search | Cmd K | Ctrl K | +| Open settings | Cmd , | Ctrl , | +| Full screen | F11, or Ctrl Cmd F | F11 | +| Make text larger | Cmd + or Cmd = | Ctrl + or Ctrl = | +| Make text smaller | Cmd − | Ctrl − | +| Reset text size | Cmd 0 | Ctrl 0 | + +The text size keys work even while you're typing in a field. Esc closes an open alert. + +## Search + +| Action | Key | +|---|---| +| Move through results | ↑ and ↓ | +| Open the selected result | Enter | +| Close search | Esc | + +## Sessions + +| Action | macOS | Linux and Windows | +|---|---|---| +| Send a note | Enter | Enter | +| Paste files or an image | Cmd V | Ctrl V | +| Leave the text box | Esc | Esc | +| Move to the next or previous control | Tab or Shift Tab | Tab or Shift Tab | +| Move through the @study menu | ↑ and ↓ | ↑ and ↓ | +| Insert the mention | Enter or Tab | Enter or Tab | +| Close the @study menu | Esc | Esc | +| Step through a message's versions | ← and → | ← and → | +| Cancel an edit | Esc | Esc | + +## Flashcards + +In a review: + +| Action | Key | +|---|---| +| Show the answer | Space or Enter | +| Again, Hard, Good, Easy | 1, 2, 3, 4 | + +When browsing a set: + +| Action | Key | +|---|---| +| Turn the card over, then go on | Space | +| Previous or next card | ← and → | + +## Practice + +| Action | Key | +|---|---| +| Pick a choice | Its letter, or its number | +| Send a written answer | Enter | +| New line in a written answer | Shift Enter | +| Next question | Enter or Space | + +## Diagrams + +| Action | macOS | Linux and Windows | +|---|---|---| +| Move around | Arrow keys, or drag | Arrow keys, or drag | +| Zoom in | + or = | + or = | +| Zoom out | − or _ | − or _ | +| Zoom with the mouse | Cmd and scroll | Ctrl and scroll | +| Actual size | 0 | 0 | +| Fit to view | F, or double-click | F, or double-click | + +For more on each of these, see [sessions](../sessions/), [flashcards](../flashcards/), [practice](../practice/) and [diagrams](../notes-and-diagrams/). diff --git a/apps/landing/src/content/docs/en/notes-and-diagrams.mdx b/apps/landing/src/content/docs/en/notes-and-diagrams.mdx new file mode 100644 index 00000000..9929f39d --- /dev/null +++ b/apps/landing/src/content/docs/en/notes-and-diagrams.mdx @@ -0,0 +1,57 @@ +--- +title: Notes and diagrams +description: Keep notes in sessions and the Library, and get a hand-drawn diagram of how a project's ideas connect. +section: learn +order: 4 +shot: diagram +shotAlt: A hand-drawn diagram of a project's main ideas, joined by arrows, with zoom controls. +--- + +Study keeps two kinds of notes, and it draws one diagram for each project from everything you've added to it. + +## Notes + +Most of your notes live in [sessions](../sessions/). Each note you send keeps its versions, so you can **Edit** it, ask for an **AI edit** with **Improve** or **Summarize**, and step back through **Versions** later. + +For notes that aren't tied to a lecture, use the Library. **New note** asks for a title and some text, and saves the note as a source of its own. Study reads it like any other file, so it shows up in [search](../search/). + +![The New note dialog in the Library, with a title and the text of the note.](shot:new-note "A note in the Library is a source of its own.") + +## The project diagram + +Each project has one diagram: cards for its main ideas, with details, joined by arrows that show how they connect. It's drawn in a hand-drawn style, and each card cites the passages it rests on by number, as an answer does. + +Diagrams are written on your ChatGPT plan, with the model set for **Demanding tasks** in **Settings › Language models**. + +![The Cell biology diagram: the cell cycle, mitosis and its four phases, each a card with details.](shot:diagram-cells "Each card is one idea, with its details and the arrows to the ideas it leads to.") + +## Making and updating it + +Open **Diagrams** in the sidebar, or the **Study material** section of a project. The diagram shows one of these states: + +- **Not made yet**, with **Make** to draw the first one +- **Up to date**, when nothing new needs drawing +- **Update**, to redraw it from the project as it is now +- **Updating…**, while Study redraws it +- **Couldn't update · showing the last one**, if a redraw failed; the previous diagram stays + +An update revises the current diagram and replaces it once it's finished. Older versions are not kept. + +Which sources feed the diagram is set per kind in **Settings › Processing**, under **Study material**. Pictures are left out by default. + +## Moving around + +| Action | How | +|---|---| +| Move | Drag, or use the arrow keys | +| Zoom | Ctrl and scroll (Cmd on macOS), or + and − | +| Actual size | 0, or **Actual size** | +| Fit the whole diagram | Double-click, F, or **Fit to view** | + +![Dragging the diagram moves it around the canvas.](clip:diagram-drag "Drag to move around the diagram.") + +The toolbar also has **Zoom in** and **Zoom out**. + +## Taking it with you + +**Save as image (SVG)** saves the diagram as a picture you can open in a browser or drawing app. **Copy** puts the diagram on the clipboard as a Mermaid flowchart, a plain-text format that many note apps and editors can draw. diff --git a/apps/landing/src/content/docs/en/practice.mdx b/apps/landing/src/content/docs/en/practice.mdx new file mode 100644 index 00000000..3bcef6b3 --- /dev/null +++ b/apps/landing/src/content/docs/en/practice.mdx @@ -0,0 +1,45 @@ +--- +title: Practice +description: Answer questions written from your project, one at a time, and get feedback on each answer. +section: learn +order: 6 +shot: practice +shotAlt: A multiple choice practice question with lettered options. +--- + +Practice is a quiz that doesn't run out. Study writes one question at a time from a project's sources, you answer it, and Study grades the answer and tells you why. Each project has one practice of its own. + +## Starting + +Open **Practice** in the sidebar and pick a project, or choose **Take the quiz** on a project's page. If nothing is due on Home, its today card also points you here. + +Questions are written on your ChatGPT plan from the project's read sources. Each new question avoids the ones you've already had. If a project has no notes yet and none of its files has been read, Study says **There's nothing to ask about yet** instead of making something up. Add some, then try again. + +## Two kinds of question + +- **Multiple choice** gives you a few options. Select one, or press its letter or number. +- **In your own words** asks for a written answer. Type it and choose **Submit**, or press Enter. Shift and Enter starts a new line. + +![Choosing an option of a multiple choice question, and the grade and feedback that follow.](clip:practice-answer "Choose an option, and Study tells you whether it was right and why.") + +## Feedback + +Each answer gets one of three verdicts: **Correct**, **Partly correct** or **Not quite**. The feedback says what you got right, what was wrong or missing, and why, next to **Your answer** and the **Right answer**. + +![An answered question with its verdict, the feedback, and the right answer.](shot:practice-feedback "Feedback sits next to your answer and the right one.") + +The grader sees only that question, its reference answer and the passages the question cites. It doesn't look at the rest of your files, so it grades the answer against what the question asked. The question's **Sources** let you open those passages yourself. + +When you've missed something, **Add to my flashcards** turns the question into a [flashcard](../flashcards/), so it comes back in your reviews. + +Then choose **Next question**, or press Enter or Space, to move on. + +## Results + +**Results** lists the questions you've answered, with what was right and why. **Delete practice** removes a project's practice, with every answer and grade in it. + +![Results for Cell biology: the questions answered so far, one open with your answer, its grade and the right answer.](shot:graded "Results keeps every answer with its grade.") + +## Practice and flashcards + +The two work well together. Flashcards help you recall facts on a schedule; practice asks you to apply and explain them. A useful habit is to review the cards that are due, then answer a few questions, and send the ones you miss to your flashcards. diff --git a/apps/landing/src/content/docs/en/privacy.md b/apps/landing/src/content/docs/en/privacy.md new file mode 100644 index 00000000..875c1734 --- /dev/null +++ b/apps/landing/src/content/docs/en/privacy.md @@ -0,0 +1,57 @@ +--- +title: Privacy and your data +description: What stays on your computer, what goes to your ChatGPT plan, and where Study keeps your files. +section: more +order: 2 +--- + +Study is local-first. Your projects, sessions, files and recordings live in one database on your computer. There is no Study account and no Study server. When a task needs a language model, Study sends it to your own ChatGPT plan, and only what that task needs. + +## What runs on your ChatGPT plan + +These tasks are sent to ChatGPT, under the account you signed in with: + +- reading PDF pages and pictures +- correcting transcripts +- naming sessions +- answering notes that mention @study +- writing flashcards and diagrams +- writing practice questions and grading your answers + +Each request carries the text or pages that task needs, such as the passages an answer cites from. The plan's own terms and usage limits apply, as they do in ChatGPT. You can sign out at any time in **Settings › Language models** with **Sign out**. + +## What runs on your computer + +Two things never leave your computer: + +- **Transcription.** Recordings are turned into text by a speech model running locally. It's about 670 MB to download and uses about 2 GB of memory for each copy you run. **Model copies** in **Settings › Transcription** sets how many run at once. +- **Search by meaning.** A multilingual model of about 135 MB turns your text into something search can compare. See [search](../search/). + +The audio of your recordings stays on your computer. Only the transcript is sent on, if you leave **Correct transcripts with AI** on. + +## Downloads + +Study downloads nothing on its own. The two local models come from Hugging Face, and only when you choose to install them, either during the welcome tour or in **Settings › This computer**. App updates download only when you choose **Install and restart**. + +## Other network use + +- **Add link** fetches the web page you give it, directly from your computer. +- For a YouTube link, Study runs yt-dlp on your computer to fetch the sound. + +## Where your data lives + +Study keeps your database and its log in one folder, and the downloaded models in another. + +| Platform | Database and log | Models | +|---|---|---| +| Linux | `~/.local/share/study/` | `~/.cache/study/models/` | +| macOS | `~/Library/Application Support/Study/` | `~/Library/Caches/Study/models/` | +| Windows | `%LOCALAPPDATA%\Study\data\` | `%LOCALAPPDATA%\Study\cache\models\` | + +On Linux, `XDG_DATA_HOME` and `XDG_CACHE_HOME` move these folders if you've set them. + +The data folder holds `study.sqlite3`, the database, and `diagnostics.log`, a log that can help when something goes wrong. On Linux and macOS, the folder can be opened only by your user account. + +## Keeping or removing it + +To back up your work, copy the data folder while Study is closed. To remove everything, quit Study and delete both folders. Updating the app never touches them. diff --git a/apps/landing/src/content/docs/en/search.mdx b/apps/landing/src/content/docs/en/search.mdx new file mode 100644 index 00000000..666c8e9b --- /dev/null +++ b/apps/landing/src/content/docs/en/search.mdx @@ -0,0 +1,57 @@ +--- +title: Search +description: Find projects, sessions, files and passages by their words or by what they mean. +section: learn +order: 8 +shot: search +shotAlt: Search open over the app, with results grouped into projects, sessions, files and passages. +--- + +Search looks through everything you've put into Study: projects, sessions, your files and what's inside them, and your messages. It matches the words you type and, once its model is installed, what they mean. + +## Open search + +Press Ctrl K, or Cmd K on macOS, from anywhere in the app. A box opens that says **Search projects, sessions, and files…**. Start typing; results appear as you go. + +| Key | What it does | +|---|---| +| ↑ and ↓ | Move through the results | +| Enter | Open the selected result | +| Esc | Close search | + +![Search opens with Ctrl K, and results appear and narrow as a word is typed.](clip:search-typing "Results appear as you type.") + +## What you get back + +Results come in groups: + +- **Projects**, by name +- **Sessions**, by title +- **Files**, by name +- **In your files**, passages inside your files that match +- **Messages**, your notes and the answers in your sessions + +Opening a file or a passage shows that file: beside its session if it was attached to one, otherwise in the Library. A message opens its session. + +## Words and meaning + +Study searches in two ways at once and combines the results. + +- **By words**, it finds passages that contain what you typed. This works from the start. +- **By meaning**, it finds passages about the same thing even when they use other words, or another language. A search for "cell energy" can find a passage about mitochondria and ATP. + +Search by meaning needs a small model that runs on your computer. It's a multilingual model of about 135 MB, and it never sends your text anywhere. Download it in **Settings › This computer**. Until you do, search shows **Not installed: search matches words only**, and everything else keeps working. + +After you install the model, Study prepares your existing files in the background. While it does, search says **Preparing search by meaning; showing exact matches for now.** New files are prepared as they're read. You can follow the work in [Activity](../background-work/), under **Search by meaning**. + +## What gets searched + +A file shows up inside **In your files** once Study has read it, which for a recording means once it's transcribed. Files Study can't read are still found by name. See [sources](../sources/) for which kinds are read. + +Each kind of source can be left out of the search index, or of search by meaning, in **Settings › Processing** under **Search**. + +If a file's text is wrong, fix it with **Correct this text** in **File details**. Search uses your correction. + +## Search and answers + +[Answers](../answers/) use the same index to find related passages across a project. A source that search can find well is one that answers can draw on well, so installing the search model also helps @study find what you mean. diff --git a/apps/landing/src/content/docs/en/sessions.mdx b/apps/landing/src/content/docs/en/sessions.mdx new file mode 100644 index 00000000..c5efcfeb --- /dev/null +++ b/apps/landing/src/content/docs/en/sessions.mdx @@ -0,0 +1,56 @@ +--- +title: Sessions +description: Take notes, record lectures and attach files in a session, and ask @study when you want an answer. +section: learn +order: 1 +shot: session +shotAlt: A session with notes, an attached file being transcribed, and an answer from @study with citations. +--- + +A session is your notebook for one lecture or one sitting. You write notes, record what you hear, and attach the files that go with it. Every session belongs to a project, and you reach it from that project's page. + +## Start a session + +Choose **New session** from Home or from a project. The new session offers three ways in: + +![A new session, with three cards: Record a lecture, Add files and Ask @study.](shot:new-session "A new session offers three ways to start.") + +- **Record a lecture**, transcribed on this computer as you listen +- **Add files**, to bring in slides, PDFs, recordings and more +- **Ask @study**, to start with a question + +If you started from Home, **Choose a project** picks where the session goes. + +Study names each session from its notes. If the name no longer fits, **Regenerate title from the notes** asks for a new one. + +## Write notes + +Type in the box at the bottom, which says **Write a note, or type @study to ask**, and press Enter to send. A note stays a note: Study doesn't reply to it. To get an answer, mention **@study** anywhere in the note. Typing `@` opens a small menu; Enter or Tab inserts the mention. How answers are built is on the [answers](../answers/) page. + +![Typing @ in the note box opens a menu with @study, and Enter inserts the mention.](clip:study-mention "Type @ and press Enter to ask @study.") + +## Attach files + +Use **Attach files**, drop files onto the session, or paste them with Ctrl V (Cmd V on macOS). Pasted images come in as files. Each file joins your Library, and Study reads it in the background. See [sources](../sources/) for what it can read. + +Every attachment has its own thread. **Reply in thread** keeps notes about that file next to it. A faint line under the file says what has happened to it, such as **Transcribed** or **Read**. + +If Study got something wrong, open **File details** and choose **Correct this text**. Search and answers use your correction from then on. Reading the file again replaces it. + +## Record a lecture + +**Record audio** starts recording in the session you're in. The recording is transcribed on your computer. If the transcription model isn't downloaded yet, the transcription waits in [Activity](../background-work/) until it is. When you're done, choose **Stop and send recording**. + +Study saves the recording every second while it runs. If the app closes in the middle, the session shows **Unfinished recording** next time, with **Resume**, **Send** and **Discard**. + +Only one session records at a time. A recording has a maximum length, and Study sends it when it gets there. + +## Edit and compare versions + +Each of your notes and each answer keeps its versions. + +- **Edit** changes the text yourself. Esc cancels. +- **AI edit** rewrites it for you, with **Improve**, **Summarize**, or your own words in **Tell the assistant what to change**. +- **Regenerate** asks for a new answer. + +When a message has more than one version, **Versions** steps through them; the left and right arrow keys do the same. Earlier versions are kept. diff --git a/apps/landing/src/content/docs/en/settings.mdx b/apps/landing/src/content/docs/en/settings.mdx new file mode 100644 index 00000000..d2645776 --- /dev/null +++ b/apps/landing/src/content/docs/en/settings.mdx @@ -0,0 +1,47 @@ +--- +title: Settings +description: A tour of Study's settings, from language and look to models and what runs on each kind of file. +section: more +order: 3 +shot: settings +shotAlt: Settings, with the General and AI groups in the sidebar. +--- + +Open **Settings** from the sidebar, or press Ctrl , (Cmd , on macOS). Settings are in two groups, **General** and **AI**. Changes are saved as you make them. + +## General + +**Language** switches Study between English and Italiano. A preview shows how menus, file sizes and times will look. + +**Appearance** offers **Light**, with bright surfaces for daylight, and **Dark**, with dim surfaces for focused study. **Text size** makes everything larger or smaller, from 75% to 200%. Ctrl + and Ctrl − (Cmd on macOS) do the same from anywhere in the app. + +![Settings › Appearance, with the Light and Dark themes and the text size.](shot:settings-appearance "Pick a theme and a text size.") + +**Updates** has **Check for updates**. When a new version is available, **Install and restart** installs it and restarts Study; save your work first. If you're recording, finish first. See [install](../install/#updates). + +**Processing** sets what Study does with each kind of file: + +- a table of every kind of source, with the columns **Reading**, **Tidying**, **Search** and **Study material**, where you turn each step on or off. See [sources](../sources/#changing-what-runs). +- **Skip small talk and logistics before writing**: a fast model leaves out small talk, logistics and noise before study material or practice questions are written, so all of your lectures fit, not a sample. +- **New flashcards a day**: a large new set comes in over several days, so reviews stay manageable. Cards you've already learned still come back when due. + +## AI + +**Overview** shows every AI feature at a glance: what it's for, where it runs, and whether it's **Ready**, **Needs sign-in** or **Not downloaded**. **Set up** takes you to what's missing. + +**Language models** is where you connect your ChatGPT plan. + +![Settings › Language models: sign-in with ChatGPT, and the model for quick, everyday and demanding tasks.](shot:settings-models "Sign in with your plan, and choose a model for each kind of task.") + +- **Sign in with ChatGPT**, and **Sign out** when you want to disconnect. +- **Manage usage** opens your ChatGPT usage settings in the browser. +- **Test connection** sends one small request to check that everything works. +- Under **Models by task**, three tiers: **Quick tasks** names sessions and leaves out small talk; **Everyday tasks** reads PDFs and pictures, corrects transcripts, answers @study, writes study material and practice questions, and grades answers; **Demanding tasks** draws diagrams. Each tier runs on the model recommended for it unless you pick another. + +**Transcription** turns recordings into text on your computer. Here you download the model, about 670 MB, and set **Model copies**, from 1 to 4. Each copy uses about 2 GB of memory and transcribes in parallel. The spoken language is detected on its own. + +**This computer** shows what Study found and measured about your computer, such as cores and memory, and **Where models run**: for search and for transcription, whether this computer runs them, or why not. You can **Measure again** after a hardware change. The search model, about 135 MB, is downloaded here. + +## Where settings are kept + +Settings are stored in the same database as your work, in your data folder. See [privacy](../privacy/#where-your-data-lives). diff --git a/apps/landing/src/content/docs/en/sources.mdx b/apps/landing/src/content/docs/en/sources.mdx new file mode 100644 index 00000000..cdccbc17 --- /dev/null +++ b/apps/landing/src/content/docs/en/sources.mdx @@ -0,0 +1,62 @@ +--- +title: Sources +description: The kinds of files and links Study can read, and how each one is read. +section: learn +order: 2 +shot: library +shotAlt: The Library, with recordings, PDFs, pictures and web pages from several projects. +--- + +A source is anything you give Study to learn from: a recording, a PDF, slides, a web page, a note. Study keeps every file in your Library and reads the ones it understands into text that answers, search and study material can point back to. + +## Adding sources + +You can add sources in a few places: + +- in a [session](../sessions/), with **Attach files**, by dropping files, or by pasting +- from Home or the Library, with **Add files** +- in the Library, with **New note**, which needs a title and some text +- in the Library, with **Add link**, for a web page or a YouTube video + +![The Add files dialog in the Library, with a place to drop files and a Choose files button.](shot:add-files "Drop files on the dialog, or choose them from your computer.") + +Each file must be under 512 MiB and not empty. Study looks at a file's contents to tell what it is, rather than trusting its name. + +## What Study reads, and how + +| Kind | How it's read | +|---|---| +| Recordings and audio | Transcribed on your computer | +| Videos | The sound is transcribed on your computer | +| PDFs | Pages read with AI | +| Pictures (PNG, JPEG, WebP, GIF, BMP, TIFF) | Read with AI | +| Documents (`.docx`) | Office reader | +| Slides (`.pptx`) | Office reader | +| Web pages | Web page reader | +| Code and web files, including HTML | Plain text reader | +| Text files and notes | Plain text reader | +| Spreadsheets saved as text, such as CSV | Plain text reader | + +Some files are kept but not read: archives, other office formats such as `.doc`, `.odt` and `.rtf`, e-books, SVG and HEIC pictures, and anything else Study doesn't recognise. They stay in your Library. If you need one read, saving it again as PDF, PNG, MP3 or M4A usually works. + +![A PDF open in the Library: its first page beside its project, and the text read from each page below.](shot:source-details "Each file shows what was read from it, page by page.") + +## Links + +**Add link** saves a web page and reads it. A YouTube link (including Shorts, live videos and `youtu.be` links) is handled like a recording: Study takes its sound with yt-dlp and transcribes it. yt-dlp has to be installed for that; see [install](../install/). + +![The Add link dialog, with a field for the address and a note about YouTube videos.](shot:add-link "A web page is saved and read; a YouTube video is transcribed.") + +## After reading + +Recordings go through two more steps. **Correct transcripts with AI** fixes words the transcription misheard, and **Tidy spacing** cleans up the spacing. Then every read source is added to the **Search index** and to **Search by meaning**, so [search](../search/) and [answers](../answers/) can find it. + +Read sources also feed the project's study material: [flashcards](../flashcards/) and the [diagram](../notes-and-diagrams/). Some kinds leave out a piece by default. Code and spreadsheets don't make flashcards, and pictures don't feed the diagram. + +## Changing what runs + +Open **Settings › Processing** to see every kind of source in a table with the columns **Reading**, **Tidying**, **Search** and **Study material**. Each step can be turned on or off for each kind, and every change is saved as you make it. + +![Settings › Processing: a table of every kind of source, with a switch for each step.](shot:settings-processing "Each step can be turned on or off for each kind of source.") + +If you correct a file's text by hand, with **Correct this text** in **File details**, search and answers use your version. diff --git a/apps/landing/src/content/docs/it/answers.mdx b/apps/landing/src/content/docs/it/answers.mdx new file mode 100644 index 00000000..d52cd33b --- /dev/null +++ b/apps/landing/src/content/docs/it/answers.mdx @@ -0,0 +1,55 @@ +--- +title: Risposte e citazioni +description: Come @study risponde a un appunto partendo dai tuoi file, e come controllare da dove viene ogni affermazione. +section: learn +order: 3 +shot: citation +shotAlt: Una risposta con citazioni numerate, e il passaggio citato aperto in una finestra con la sua pagina. +--- + +Quando un appunto menziona **@study**, Study risponde partendo dal tuo materiale e mostra da dove viene ogni parte della risposta. Puoi aprire qualsiasi citazione e leggere il passaggio su cui si basa. + +## Chiedere + +Scrivi la domanda in una [sessione](../sessions/) e metti **@study** in un punto qualsiasi dell'appunto. Gli appunti senza la menzione non ricevono risposta, così puoi scrivere liberamente e chiedere solo quando vuoi. + +![Una sessione con una domanda a @study già risposta, e il menu di @study aperto nel riquadro degli appunti.](shot:study-menu "Menziona @study in un punto qualsiasi dell'appunto per avere una risposta.") + +Study aspetta che i file allegati a quell'appunto siano stati letti, poi risponde. Se manca qualcosa, la risposta aspetta e dice perché, per esempio **In attesa dell'accesso a ChatGPT**. Le risposte usano il tuo piano ChatGPT. + +## Dove cerca una risposta + +Una risposta attinge prima ai file della sessione in cui ti trovi, a tutti. Poi aggiunge i passaggi più vicini dal resto del progetto. I file fuori dal progetto non vengono usati. + +Visto che le risposte nascono dai tuoi file, migliori sono le fonti, migliori sono le risposte. Se un file è stato letto male, correggi il testo con **Correggi questo testo** in **Dettagli del file**, e le risposte successive useranno la tua versione. + +## Leggere le citazioni + +Ogni affermazione che si basa su un passaggio ha un piccolo indicatore numerato, come [1] o [2]. Sotto la risposta, **Fonti** elenca ogni file citato con il punto da cui viene il passaggio. + +I punti sono scritti come li cercheresti: + +| Fonte | Punto | +|---|---| +| PDF o immagine | `pag. 3` | +| Registrazione o video | `4:05–4:40`, o `1:02:03` per quelle lunghe | +| Testo o codice | `righe 12–18` | +| Presentazione | `diapositiva 4` | +| Documento Word | `§ 2` | +| Pagina web | `#sezione`, quando la parte è nota | + +## Aprire una citazione + +Seleziona un indicatore o una fonte per aprirla. Una finestra mostra il file e il punto, con il passaggio citato evidenziato in mezzo al testo che lo circonda. **Apri nella Libreria** ti porta al file intero, e **Chiudi** ti riporta alla risposta. + +![Selezionando il numero di una citazione sotto una risposta si apre il passaggio citato, evidenziato nella trascrizione.](clip:citation-open "Seleziona una citazione per leggere il passaggio su cui si basa.") + +Usala per controllare una risposta prima di fidarti. Se il passaggio non dice quello che la risposta afferma, fidati del passaggio. + +## Cambiare una risposta + +Le risposte conservano le loro versioni, come i tuoi appunti. **Rigenera** chiede una nuova risposta, **Modifica con IA** riscrive quella attuale con **Migliora**, **Riassumi** o una tua istruzione, e **Versioni** passa dall'una all'altra. Vedi [sessioni](../sessions/#modifica-e-confronta-le-versioni). + +## Citazioni anche altrove + +Le risposte non sono l'unico posto con le citazioni. Anche le [flashcard](../flashcards/), lo [schema](../notes-and-diagrams/) e le domande delle [esercitazioni](../practice/) elencano le loro **Fonti**, così puoi sempre tornare alla pagina o al momento da cui viene qualcosa. diff --git a/apps/landing/src/content/docs/it/background-work.mdx b/apps/landing/src/content/docs/it/background-work.mdx new file mode 100644 index 00000000..b1a3b1c2 --- /dev/null +++ b/apps/landing/src/content/docs/it/background-work.mdx @@ -0,0 +1,71 @@ +--- +title: Lavoro in background +description: Segui cosa fa Study in background, e sistema quello che richiede la tua attenzione. +section: more +order: 1 +shot: activity +shotAlt: Attività, con il lavoro che Study ha fatto e quello ancora da fare. +--- + +Trascrivere una lezione, leggere un PDF o scrivere flashcard richiede tempo, quindi Study lo fa in background mentre tu continui a lavorare. **Attività** nella barra laterale mostra tutto. Anche la home ti avvisa quando qualcosa ha bisogno di te, con **Apri attività**. + +## Cosa vedi + +Attività raggruppa il lavoro in base a dove si trova: + +- **In corso**, quello che sta girando adesso +- **In coda**, quello che aspetta il suo turno +- **Da controllare**, quello fermato da qualcosa che puoi sistemare +- **Fermate**, quello che hai fermato tu +- **Completate**, quello finito + +Ogni voce indica la sua fase e il file, la sessione o il progetto a cui si riferisce. Le fasi sono: + +| Fase | Cosa fa | +|---|---| +| Recupero del link | Salva una pagina web o l'audio di un video di YouTube | +| Trascrizione | Trasforma una registrazione in testo, sul tuo computer | +| Lettura delle pagine | Legge le pagine dei PDF e le immagini sul tuo piano ChatGPT | +| Lettura | Legge documenti, presentazioni, pagine web e testo | +| Indice di ricerca | Aggiunge il testo alla ricerca per parole | +| Ricerca per significato | Aggiunge il testo alla ricerca per significato, sul tuo computer | +| Titolo della sessione | Dà un nome a una sessione dai suoi appunti | +| Risposta | Scrive una risposta di @study | +| Riscrittura | Esegue una modifica con IA | +| Materiale di studio | Scrive flashcard o uno schema | +| Domanda di esercitazione | Scrive la prossima domanda di esercitazione | +| Correzione della risposta | Corregge una risposta di un'esercitazione | + +Una fase porta alla successiva. Una registrazione, per esempio, viene trascritta, corretta e riordinata, poi indicizzata, e a quel punto può alimentare risposte e materiale di studio. + +## Fermare e riavviare + +**Ferma** ferma una voce in corso o in attesa. **Riavvia** fa ripartire una voce fermata. Quando qualcosa non è riuscito, **Riprova** ci prova di nuovo. + +## Quando il lavoro aspetta una configurazione + +Certo lavoro richiede prima qualcosa di configurato. Invece di fallire aspetta sotto **In coda** in Attività, e la pagina che ne ha bisogno (una sessione, il materiale di studio o l'esercitazione) mostra una scheda per quello che manca: + +- **Collega il tuo piano ChatGPT**, con l'accesso direttamente sulla scheda +- **Scarica il modello di trascrizione** +- **Scarica il modello di ricerca** + +![Attività con una domanda di esercitazione in attesa sotto In coda, sopra il lavoro finito.](shot:activity-waiting "Il lavoro fermo per una configurazione aspetta in coda, e riparte da solo quando è pronta.") + +**Riprende da sola appena è tutto pronto.** Dopo l'accesso o l'installazione di un modello non devi riavviare niente. + +## Quando qualcosa va storto + +I problemi brevi, come una connessione caduta o un servizio occupato, vengono riprovati da soli dopo una pausa che si allunga ogni volta. Non devi fare niente. + +Gli altri problemi finiscono in **Da controllare**, con un messaggio che dice cosa è successo e cosa fare. Per esempio: + +- non hai effettuato l'accesso, o la connessione non c'è +- un file non si riesce a leggere; salvarlo di nuovo come PDF, PNG, MP3 o M4A spesso aiuta +- non c'è ancora abbastanza materiale da cui scrivere +- un modello non è installato +- il file, la sessione o il progetto è stato eliminato + +## Chiudere è sicuro + +Study tiene il suo lavoro nel database, non solo in memoria. Se chiudi l'app, o se si chiude all'improvviso, il lavoro è ancora lì quando riapri Study, e riprende dove può. Le registrazioni vengono salvate ogni secondo mentre sono in corso; vedi [sessioni](../sessions/#registra-una-lezione). diff --git a/apps/landing/src/content/docs/it/first-steps.mdx b/apps/landing/src/content/docs/it/first-steps.mdx new file mode 100644 index 00000000..7cafacd0 --- /dev/null +++ b/apps/landing/src/content/docs/it/first-steps.mdx @@ -0,0 +1,63 @@ +--- +title: Primi passi +description: Cosa succede la prima volta che apri Study, e come preparare il tuo primo progetto. +section: start +order: 2 +shot: home +shotAlt: La home di Study, con un saluto, le flashcard da ripassare oggi, il conto alla rovescia per un esame e le azioni rapide. +--- + +La prima volta che apri Study, l'app si prepara, ti fa fare un breve tour e poi ti lascia sulla home. Puoi saltare qualsiasi parte e tornarci dopo. + +## La preparazione + +Study mostra prima **Preparazione di Study**. Misura il tuo computer una volta sola, per sapere quali modelli locali può far funzionare bene. Se non vuoi aspettare, scegli **Continua senza misurare**. + +## Il tour di benvenuto + +Il tour ha pochi passaggi brevi, e ognuno ha **Salta**. + +- **Fallo tuo** imposta la lingua (English o Italiano) e l'aspetto. +- **Le sessioni sono il tuo quaderno** spiega dove finiscono i tuoi appunti. +- **Dove lavora l'IA** offre **Scarica ciò che funziona qui**, per i modelli che girano sul tuo computer, e l'accesso con il tuo piano ChatGPT Plus o Pro. +- **È tutto pronto** chiude il tour con **Inizia a studiare**. + +Per rivederlo, apri **Aiuto** e scegli **Rivedi il tour di benvenuto**. + +## Collegare il piano ChatGPT + +Scegli **Accedi con ChatGPT**. Study apre il browser e mostra **Completa l'accesso nel browser…** finché non hai finito. Se ChatGPT te lo chiede, scegli **Consenti l'uso del mio piano**. Alcuni account non possono usare il proprio piano in altre app; se il tuo è uno di questi, Study te lo dice. + +Il lavoro fatto sul tuo piano conta nei suoi limiti di utilizzo, gli stessi che usa ChatGPT. Finché non accedi, il lavoro che richiede il piano ti aspetta in [Attività](../background-work/). + +## Crea un progetto + +In Study tutto appartiene a un progetto, di solito uno per corso. Scegli **Nuovo progetto**, dagli un nome e scegli **Crea progetto**. + +![La finestra Nuovo progetto sopra una sessione nuova, con "Genetics" scritto come nome.](shot:new-project "Dai al progetto il nome del corso, poi scegli Crea progetto.") + +La pagina di un progetto è dove si riunisce il suo lavoro: + +- **Nuova sessione** apre una pagina di appunti per una lezione o un momento di studio. Vedi [sessioni](../sessions/). +- **Fai il quiz** apre le [esercitazioni](../practice/). +- **Ripassa le flashcard** avvia un ripasso delle [flashcard](../flashcards/). +- **Mostra file** mostra i file del progetto nella Libreria. +- **Materiale di studio** mostra a che punto sono le flashcard e lo schema del progetto. +- **Rinomina progetto** ed **Elimina progetto**. Se elimini un progetto, i suoi file restano nella Libreria. + +## Imposta il giorno dell'esame + +Nella pagina di un progetto, **Giorno dell'esame** ti fa scegliere la data dell'esame. Study la usa per un conto alla rovescia: la home e i ripassi delle flashcard contano i giorni che mancano, a partire dall'esame più vicino, con scritte come **Esame tra 5 giorni**, **Esame domani** o **Esame oggi**. La data non cambia cosa studi né come vengono programmate le flashcard. + +## La home + +La home è dove si apre Study. Mostra: + +- una scheda per oggi, con le flashcard da ripassare in tutti i progetti e l'esame più vicino, più **Ripassa ora**; quando non c'è niente da ripassare, ti indirizza alle esercitazioni +- le azioni rapide: **Nuova sessione**, **Aggiungi file** e **Nuovo progetto** +- **Riprendi da dove eri rimasto**, con le sessioni recenti +- il lavoro che ha bisogno di te, con **Apri attività** +- **File recenti** +- cosa fa funzionare questo computer + +La barra laterale contiene il resto: **Libreria**, **Progetti**, **Flashcard**, **Schemi**, **Esercitazioni**, **Attività**, **Impostazioni** e **Aiuto**. diff --git a/apps/landing/src/content/docs/it/flashcards.mdx b/apps/landing/src/content/docs/it/flashcards.mdx new file mode 100644 index 00000000..f10113dc --- /dev/null +++ b/apps/landing/src/content/docs/it/flashcards.mdx @@ -0,0 +1,57 @@ +--- +title: Flashcard +description: Ripassa flashcard scritte dal tuo progetto, programmate per rivederle prima di dimenticarle. +section: learn +order: 5 +shot: flashcard +shotAlt: Un ripasso di flashcard con una domanda, la sua risposta e i quattro pulsanti di valutazione. +--- + +Study scrive le flashcard dalle fonti di un intero progetto e le programma con la ripetizione dilazionata. Ripassi le flashcard in scadenza, valuti quanto bene ricordavi ciascuna, e Study decide quando mostrartela di nuovo. + +## Da dove vengono + +Ogni progetto ha un mazzo di flashcard, scritto sul tuo piano ChatGPT dalle fonti lette del progetto. Ogni flashcard cita i passaggi su cui si basa, e le sue **Fonti** ti riportano alla pagina o al momento da cui viene. + +Il mazzo sta nel **Materiale di studio** del progetto, e in **Flashcard** nella barra laterale. Come lo [schema](../notes-and-diagrams/), si parte con **Crea** e più avanti c'è **Aggiorna**, quando vuoi nuove flashcard da materiale nuovo. Un aggiornamento rivede il mazzo invece di ricominciare da capo. + +Quali tipi di fonte alimentano le flashcard si decide in **Impostazioni › Elaborazione**, sotto **Materiale di studio**. Codice e fogli di calcolo sono esclusi in modo predefinito. + +## Ripassare + +Scegli **Ripassa** in **Flashcard**, **Ripassa le flashcard** in un progetto, o **Ripassa ora** nella home. Ogni flashcard mostra prima la domanda. + +1. Prova a ricordare la risposta, poi scegli **Mostra risposta**, o premi Spazio o Invio. +2. Valuta com'è andata: **Di nuovo**, **Difficile**, **Bene** o **Facile**. I tasti da 1 a 4 fanno lo stesso. + +![Una flashcard in un ripasso si gira e mostra la risposta, poi viene valutata.](clip:flashcard-flip "Mostra la risposta, poi valuta quanto bene la ricordavi.") + +Le flashcard che stai per dimenticare vengono prima. Quando non ne restano, Study dice **Ripasso finito. Le flashcard tornano quando è il momento.** + +Valuta con sincerità. **Di nuovo** fa tornare una flashcard presto; **Facile** la allontana. La programmazione usa FSRS, un metodo di ripetizione dilazionata che impara dalle tue valutazioni quanto in fretta dimentichi ogni flashcard. + +## Quante flashcard nuove + +Le flashcard nuove entrano poco alla volta, così un mazzo grande non ti arriva addosso tutto insieme. **Nuove flashcard al giorno**, in **Impostazioni › Elaborazione**, decide quante nuove possono entrare nei ripassi di un giorno. + +La pagina **Flashcard** mostra cosa ti aspetta, comprese le flashcard in scadenza nei prossimi 7 giorni. Se un progetto ha una data d'esame, il suo riquadro di ripasso conta i giorni che mancano. + +![La pagina Flashcard: le flashcard da ripassare, i ripassi della settimana, i giorni di fila, e un riquadro per ogni progetto con il conto alla rovescia per l'esame.](shot:flashcards "La pagina Flashcard mostra cosa c'è da ripassare, in tutto e per ogni progetto.") + +## Sfogliare e modificare + +Apri un mazzo per scorrere tutte le sue flashcard fuori da un ripasso. Spazio gira una flashcard, e le frecce sinistra e destra si muovono nel mazzo. **Girale tutte** e **Coprile tutte** girano tutte le flashcard insieme. + +![Il mazzo di Cell biology aperto fuori da un ripasso, con una flashcard in primo piano e tutto il mazzo elencato sotto.](shot:flashcard-deck "Sfoglia un mazzo una flashcard alla volta, o tutto insieme.") + +Puoi cambiare il mazzo a mano: + +- **Modifica flashcard** cambia la **Domanda** o la **Risposta** +- **Aggiungi una flashcard** ne scrive una tua +- **Elimina flashcard** ne toglie una + +Dalle [esercitazioni](../practice/), **Aggiungi alle mie flashcard** trasforma in flashcard una domanda che hai sbagliato. + +## Usare le flashcard altrove + +**Salva per Anki** salva il mazzo come file di testo, una flashcard per riga, con fronte e retro separati da una tabulazione. Anki e la maggior parte delle altre app di flashcard sanno importarlo. diff --git a/apps/landing/src/content/docs/it/install.md b/apps/landing/src/content/docs/it/install.md new file mode 100644 index 00000000..c03c0308 --- /dev/null +++ b/apps/landing/src/content/docs/it/install.md @@ -0,0 +1,53 @@ +--- +title: Installare Study +description: Scarica Study per Linux, macOS o Windows e tienilo aggiornato. +section: start +order: 1 +--- + +Study è un'app desktop. La installi una volta sul tuo computer, e progetti, file e appunti restano su quel computer. Ogni versione, nella pagina delle release del progetto su GitHub, ha un file da scaricare per ogni piattaforma. + +## Scegli il file giusto + +| Piattaforma | File | +|---|---| +| Linux su Intel o AMD | `study-linux-x86_64.AppImage` | +| Linux su ARM | `study-linux-aarch64.AppImage` | +| macOS su Apple Silicon | `study-macos-aarch64.dmg` | +| Windows | `study-windows-x86_64.exe` | + +Su **Linux** l'AppImage è l'app intera. Rendila eseguibile, poi aprila: + +```sh +chmod +x study-linux-x86_64.AppImage +./study-linux-x86_64.AppImage +``` + +Le versioni per Linux sono compilate su Ubuntu 24.04, quindi serve una distribuzione di quell'epoca o più recente. + +Su **macOS** apri il `.dmg` e trascina Study in Applicazioni. Study richiede macOS 12 o successivo su un Mac con Apple Silicon. Non esiste una versione per i Mac Intel. + +Su **Windows** avvia l'installer `.exe` e segui i passaggi. + +## Strumenti facoltativi + +Due strumenti da riga di comando ampliano ciò che Study sa leggere. Per iniziare non ti serve nessuno dei due. + +- **yt-dlp** permette a Study di prendere l'audio di un link di YouTube e trascriverlo. Study gli chiede solo l'audio, quindi non serve altro. +- **FFmpeg** è l'ultima risorsa per i formati di registrazione insoliti, come AMR o WMA. I formati comuni come MP3, M4A, WAV e l'audio della maggior parte dei video si leggono senza. + +Installali con il gestore di pacchetti del tuo sistema, così Study li trova nel percorso. + +## Cosa funziona dove + +Study svolge il lavoro dei modelli sul tuo piano ChatGPT, quindi la prima volta che lo apri accedi con un account ChatGPT Plus o Pro. Due cose invece funzionano sul tuo computer: la trascrizione delle registrazioni e la ricerca per significato. Ognuna richiede un modello che scarichi dall'app, solo quando lo decidi tu. [Primi passi](../first-steps/) ti accompagna in questo, e [privacy](../privacy/) spiega cosa va dove. + +## Aggiornamenti + +Study non si aggiorna da solo a tua insaputa. Per cercare una nuova versione apri **Impostazioni › Aggiornamenti** e scegli **Cerca aggiornamenti**. Se ce n'è una, **Installa e riavvia** la scarica e apre la nuova versione. Gli aggiornamenti sono firmati, e Study controlla la firma prima di installarli. + +Un aggiornamento sostituisce solo l'app. Il database, i tuoi file e i modelli che hai scaricato restano dove sono, e le attività in background riprendono dopo il riavvio. + +## Disinstallare + +Rimuovi l'app come faresti con qualsiasi altra sul tuo sistema. I tuoi dati stanno in una cartella separata, che [privacy](../privacy/) indica per ogni piattaforma. Elimina anche quella se non vuoi lasciare niente. diff --git a/apps/landing/src/content/docs/it/keyboard.md b/apps/landing/src/content/docs/it/keyboard.md new file mode 100644 index 00000000..221f357f --- /dev/null +++ b/apps/landing/src/content/docs/it/keyboard.md @@ -0,0 +1,81 @@ +--- +title: Scorciatoie da tastiera +description: I tasti a cui risponde Study, su macOS, Linux e Windows. +section: more +order: 4 +--- + +Questi sono i tasti a cui risponde Study. Dove macOS usa Cmd, Linux e Windows usano Ctrl; le tabelle lo indicano dove c'è differenza. + +## In tutta l'app + +| Azione | macOS | Linux e Windows | +|---|---|---| +| Apri la ricerca | Cmd K | Ctrl K | +| Apri le impostazioni | Cmd , | Ctrl , | +| Schermo intero | F11, oppure Ctrl Cmd F | F11 | +| Testo più grande | Cmd + o Cmd = | Ctrl + o Ctrl = | +| Testo più piccolo | Cmd − | Ctrl − | +| Dimensione del testo predefinita | Cmd 0 | Ctrl 0 | + +I tasti per la dimensione del testo funzionano anche mentre scrivi in un campo. Esc chiude un avviso aperto. + +## Ricerca + +| Azione | Tasto | +|---|---| +| Muoversi tra i risultati | ↑ e ↓ | +| Aprire il risultato selezionato | Invio | +| Chiudere la ricerca | Esc | + +## Sessioni + +| Azione | macOS | Linux e Windows | +|---|---|---| +| Inviare un appunto | Invio | Invio | +| Incollare file o un'immagine | Cmd V | Ctrl V | +| Uscire dalla casella di testo | Esc | Esc | +| Passare al controllo successivo o precedente | Tab o Maiuscolo Tab | Tab o Maiuscolo Tab | +| Muoversi nel menu di @study | ↑ e ↓ | ↑ e ↓ | +| Inserire la menzione | Invio o Tab | Invio o Tab | +| Chiudere il menu di @study | Esc | Esc | +| Scorrere le versioni di un messaggio | ← e → | ← e → | +| Annullare una modifica | Esc | Esc | + +## Flashcard + +Durante un ripasso: + +| Azione | Tasto | +|---|---| +| Mostrare la risposta | Spazio o Invio | +| Di nuovo, Difficile, Bene, Facile | 1, 2, 3, 4 | + +Mentre sfogli un mazzo: + +| Azione | Tasto | +|---|---| +| Girare la flashcard, poi andare avanti | Spazio | +| Flashcard precedente o successiva | ← e → | + +## Esercitazioni + +| Azione | Tasto | +|---|---| +| Scegliere una risposta | La sua lettera, o il suo numero | +| Inviare una risposta scritta | Invio | +| Andare a capo in una risposta scritta | Maiuscolo Invio | +| Prossima domanda | Invio o Spazio | + +## Schemi + +| Azione | macOS | Linux e Windows | +|---|---|---| +| Spostarsi | Frecce, o trascina | Frecce, o trascina | +| Ingrandire | + o = | + o = | +| Ridurre | − o _ | − o _ | +| Zoom con il mouse | Cmd e rotellina | Ctrl e rotellina | +| Dimensione reale | 0 | 0 | +| Adattare alla vista | F, o doppio clic | F, o doppio clic | + +Per saperne di più, vedi [sessioni](../sessions/), [flashcard](../flashcards/), [esercitazioni](../practice/) e [schemi](../notes-and-diagrams/). diff --git a/apps/landing/src/content/docs/it/notes-and-diagrams.mdx b/apps/landing/src/content/docs/it/notes-and-diagrams.mdx new file mode 100644 index 00000000..e114f6a7 --- /dev/null +++ b/apps/landing/src/content/docs/it/notes-and-diagrams.mdx @@ -0,0 +1,57 @@ +--- +title: Appunti e schemi +description: Tieni appunti nelle sessioni e nella Libreria, e ottieni uno schema disegnato a mano di come si collegano le idee di un progetto. +section: learn +order: 4 +shot: diagram +shotAlt: Uno schema disegnato a mano con le idee principali di un progetto, unite da frecce, e i comandi di zoom. +--- + +Study tiene due tipi di appunti, e disegna uno schema per ogni progetto a partire da tutto quello che ci hai aggiunto. + +## Appunti + +La maggior parte dei tuoi appunti sta nelle [sessioni](../sessions/). Ogni appunto che mandi conserva le sue versioni, così puoi usare **Modifica**, chiedere una **Modifica con IA** con **Migliora** o **Riassumi**, e più tardi tornare indietro con **Versioni**. + +Per gli appunti che non riguardano una lezione, usa la Libreria. **Nuova nota** chiede un titolo e del testo, e salva la nota come fonte a sé. Study la legge come qualsiasi altro file, quindi compare nella [ricerca](../search/). + +![La finestra Nuova nota nella Libreria, con il titolo e il testo della nota.](shot:new-note "Una nota nella Libreria è una fonte a sé.") + +## Lo schema del progetto + +Ogni progetto ha uno schema: schede per le idee principali, con i dettagli, unite da frecce che mostrano come si collegano. È disegnato in stile a mano, e ogni scheda cita con un numero i passaggi su cui si basa, come fa una risposta. + +Gli schemi vengono scritti sul tuo piano ChatGPT, con il modello scelto per **Attività impegnative** in **Impostazioni › Modelli linguistici**. + +![Lo schema di Cell biology: il ciclo cellulare, la mitosi e le sue quattro fasi, ognuna una scheda con i dettagli.](shot:diagram-cells "Ogni scheda è un'idea, con i suoi dettagli e le frecce verso le idee a cui porta.") + +## Crearlo e aggiornarlo + +Apri **Schemi** nella barra laterale, oppure la sezione **Materiale di studio** di un progetto. Lo schema mostra uno di questi stati: + +- **Non ancora creato**, con **Crea** per disegnare il primo +- **Aggiornato**, quando non c'è niente di nuovo da disegnare +- **Aggiorna**, per ridisegnarlo dal progetto così com'è ora +- **Aggiornamento…**, mentre Study lo ridisegna +- **Aggiornamento non riuscito · resta l'ultima**, se un aggiornamento non è riuscito; lo schema precedente resta + +Un aggiornamento rivede lo schema attuale e lo sostituisce quando ha finito. Le versioni più vecchie non vengono conservate. + +Quali fonti alimentano lo schema si decide per tipo in **Impostazioni › Elaborazione**, sotto **Materiale di studio**. Le immagini sono escluse in modo predefinito. + +## Muoversi + +| Azione | Come | +|---|---| +| Spostarsi | Trascina, o usa le frecce | +| Zoom | Ctrl e rotellina (Cmd su macOS), oppure + e − | +| Dimensione reale | 0, oppure **Dimensione reale** | +| Vedere tutto lo schema | Doppio clic, F, oppure **Adatta alla vista** | + +![Trascinando lo schema lo si sposta sulla tela.](clip:diagram-drag "Trascina per muoverti nello schema.") + +Nella barra degli strumenti ci sono anche **Ingrandisci** e **Riduci**. + +## Portarlo con te + +**Salva come immagine (SVG)** salva lo schema come immagine che puoi aprire in un browser o in un programma di disegno. **Copia** mette lo schema negli appunti come diagramma di flusso Mermaid, un formato di testo che molte app di note ed editor sanno disegnare. diff --git a/apps/landing/src/content/docs/it/practice.mdx b/apps/landing/src/content/docs/it/practice.mdx new file mode 100644 index 00000000..697b382a --- /dev/null +++ b/apps/landing/src/content/docs/it/practice.mdx @@ -0,0 +1,45 @@ +--- +title: Esercitazioni +description: Rispondi a domande scritte dal tuo progetto, una alla volta, e ricevi un commento su ogni risposta. +section: learn +order: 6 +shot: practice +shotAlt: Una domanda a scelta multipla con le opzioni indicate da lettere. +--- + +Un'esercitazione è un quiz che non finisce mai. Study scrive una domanda alla volta dalle fonti di un progetto, tu rispondi, e Study corregge la risposta e ti spiega perché. Ogni progetto ha la sua esercitazione. + +## Iniziare + +Apri **Esercitazioni** nella barra laterale e scegli un progetto, oppure scegli **Fai il quiz** nella pagina di un progetto. Se nella home non c'è niente da ripassare, anche la scheda di oggi ti porta qui. + +Le domande vengono scritte sul tuo piano ChatGPT dalle fonti lette del progetto. Ogni nuova domanda evita quelle che hai già avuto. Se un progetto non ha ancora appunti e nessuno dei suoi file è stato letto, Study dice **Non c'è ancora niente su cui fare domande** invece di inventare qualcosa. Aggiungine, poi riprova. + +## Due tipi di domanda + +- **Scelta multipla** ti dà alcune opzioni. Selezionane una, o premi la sua lettera o il suo numero. +- **Con parole tue** chiede una risposta scritta. Scrivila e scegli **Invia**, o premi Invio. Maiuscolo e Invio va a capo. + +![La scelta di un'opzione in una domanda a scelta multipla, e il voto e il commento che seguono.](clip:practice-answer "Scegli un'opzione, e Study ti dice se era giusta e perché.") + +## Il commento + +Ogni risposta riceve uno di tre giudizi: **Corretto**, **In parte corretto** o **Non proprio**. Il commento dice cosa hai azzeccato, cosa era sbagliato o mancava, e perché, accanto a **La tua risposta** e alla **Risposta giusta**. + +![Una domanda con risposta, il suo esito, il commento e la risposta giusta.](shot:practice-feedback "Il commento sta accanto alla tua risposta e a quella giusta.") + +Chi corregge vede solo quella domanda, la sua risposta di riferimento e i passaggi citati dalla domanda. Non guarda il resto dei tuoi file, quindi valuta la risposta rispetto a quello che la domanda chiedeva. Le **Fonti** della domanda ti permettono di aprire quei passaggi da solo. + +Quando ti è sfuggito qualcosa, **Aggiungi alle mie flashcard** trasforma la domanda in una [flashcard](../flashcards/), così torna nei tuoi ripassi. + +Poi scegli **Prossima domanda**, o premi Invio o Spazio, per andare avanti. + +## Risultati + +**Risultati** elenca le domande a cui hai risposto, con cosa era giusto e perché. **Elimina esercitazione** toglie l'esercitazione di un progetto, con ogni risposta e valutazione al suo interno. + +![I risultati di Cell biology: le domande a cui hai risposto, una aperta con la tua risposta, il voto e la risposta giusta.](shot:graded "I risultati conservano ogni risposta con il suo voto.") + +## Esercitazioni e flashcard + +Le due cose funzionano bene insieme. Le flashcard ti aiutano a ricordare i fatti secondo un programma; le esercitazioni ti chiedono di applicarli e spiegarli. Un'abitudine utile è ripassare le flashcard in scadenza, poi rispondere a qualche domanda, e mandare alle flashcard quelle che sbagli. diff --git a/apps/landing/src/content/docs/it/privacy.md b/apps/landing/src/content/docs/it/privacy.md new file mode 100644 index 00000000..a3f48e90 --- /dev/null +++ b/apps/landing/src/content/docs/it/privacy.md @@ -0,0 +1,57 @@ +--- +title: Privacy e dati +description: Cosa resta sul tuo computer, cosa va al tuo piano ChatGPT, e dove Study tiene i tuoi file. +section: more +order: 2 +--- + +Study lavora in locale. Progetti, sessioni, file e registrazioni stanno in un unico database sul tuo computer. Non esiste un account Study né un server Study. Quando un'attività ha bisogno di un modello linguistico, Study la manda al tuo piano ChatGPT, e solo quello che serve a quell'attività. + +## Cosa usa il tuo piano ChatGPT + +Queste attività vengono mandate a ChatGPT, con l'account con cui hai effettuato l'accesso: + +- leggere le pagine dei PDF e le immagini +- correggere le trascrizioni +- dare un nome alle sessioni +- rispondere agli appunti che menzionano @study +- scrivere flashcard e schemi +- scrivere le domande delle esercitazioni e correggere le tue risposte + +Ogni richiesta porta il testo o le pagine che servono a quell'attività, per esempio i passaggi da cui cita una risposta. Valgono i termini e i limiti di utilizzo del piano, come in ChatGPT. Puoi uscire in qualsiasi momento da **Impostazioni › Modelli linguistici** con **Esci**. + +## Cosa gira sul tuo computer + +Due cose non lasciano mai il tuo computer: + +- **La trascrizione.** Le registrazioni diventano testo grazie a un modello vocale che gira in locale. Da scaricare pesa circa 670 MB e usa circa 2 GB di memoria per ogni copia in funzione. **Copie del modello** in **Impostazioni › Trascrizione** decide quante ne girano insieme. +- **La ricerca per significato.** Un modello multilingue di circa 135 MB trasforma il tuo testo in qualcosa che la ricerca può confrontare. Vedi [ricerca](../search/). + +L'audio delle tue registrazioni resta sul tuo computer. Viene inviata solo la trascrizione, se lasci attiva **Correggi le trascrizioni con l'IA**. + +## Download + +Study non scarica niente di sua iniziativa. I due modelli locali arrivano da Hugging Face, e solo quando scegli di installarli, durante il tour di benvenuto o in **Impostazioni › Questo computer**. Gli aggiornamenti dell'app si scaricano solo quando scegli **Installa e riavvia**. + +## Altri usi della rete + +- **Aggiungi link** scarica la pagina web che gli dai, direttamente dal tuo computer. +- Per un link di YouTube, Study esegue yt-dlp sul tuo computer per prenderne l'audio. + +## Dove stanno i tuoi dati + +Study tiene il database e il suo log in una cartella, e i modelli scaricati in un'altra. + +| Piattaforma | Database e log | Modelli | +|---|---|---| +| Linux | `~/.local/share/study/` | `~/.cache/study/models/` | +| macOS | `~/Library/Application Support/Study/` | `~/Library/Caches/Study/models/` | +| Windows | `%LOCALAPPDATA%\Study\data\` | `%LOCALAPPDATA%\Study\cache\models\` | + +Su Linux, `XDG_DATA_HOME` e `XDG_CACHE_HOME` spostano queste cartelle se le hai impostate. + +La cartella dei dati contiene `study.sqlite3`, il database, e `diagnostics.log`, un log che può aiutare quando qualcosa va storto. Su Linux e macOS la cartella è accessibile solo dal tuo account utente. + +## Conservare o eliminare + +Per fare un backup del tuo lavoro, copia la cartella dei dati mentre Study è chiuso. Per eliminare tutto, chiudi Study ed elimina entrambe le cartelle. Aggiornare l'app non le tocca mai. diff --git a/apps/landing/src/content/docs/it/search.mdx b/apps/landing/src/content/docs/it/search.mdx new file mode 100644 index 00000000..53e004c6 --- /dev/null +++ b/apps/landing/src/content/docs/it/search.mdx @@ -0,0 +1,57 @@ +--- +title: Ricerca +description: Trova progetti, sessioni, file e passaggi in base alle parole o al significato. +section: learn +order: 8 +shot: search +shotAlt: La ricerca aperta sopra l'app, con i risultati divisi in progetti, sessioni, file e passaggi. +--- + +La ricerca guarda in tutto quello che hai messo in Study: progetti, sessioni, i tuoi file e cosa contengono, e i tuoi messaggi. Trova le parole che scrivi e, una volta installato il suo modello, anche quello che significano. + +## Aprire la ricerca + +Premi Ctrl K, o Cmd K su macOS, da qualsiasi punto dell'app. Si apre una casella con **Cerca progetti, sessioni e file…**. Inizia a scrivere; i risultati compaiono mentre scrivi. + +| Tasto | Cosa fa | +|---|---| +| ↑ e ↓ | Si muove tra i risultati | +| Invio | Apre il risultato selezionato | +| Esc | Chiude la ricerca | + +![La ricerca si apre con Ctrl K, e i risultati compaiono e si restringono mentre si scrive una parola.](clip:search-typing "I risultati compaiono mentre scrivi.") + +## Cosa ottieni + +I risultati sono divisi in gruppi: + +- **Progetti**, per nome +- **Sessioni**, per titolo +- **File**, per nome +- **Nei tuoi file**, i passaggi dentro i tuoi file che corrispondono +- **Messaggi**, i tuoi appunti e le risposte nelle sessioni + +Aprire un file o un passaggio mostra quel file: accanto alla sua sessione se era allegato a una, altrimenti nella Libreria. Un messaggio apre la sua sessione. + +## Parole e significato + +Study cerca in due modi insieme e unisce i risultati. + +- **Per parole**, trova i passaggi che contengono quello che hai scritto. Funziona da subito. +- **Per significato**, trova i passaggi che parlano della stessa cosa anche con altre parole, o in un'altra lingua. Una ricerca per "energia della cellula" può trovare un passaggio su mitocondri e ATP. + +La ricerca per significato richiede un piccolo modello che gira sul tuo computer. È un modello multilingue di circa 135 MB, e non manda il tuo testo da nessuna parte. Scaricalo in **Impostazioni › Questo computer**. Finché non lo fai, la ricerca mostra **Non installato: la ricerca trova solo le parole**, e tutto il resto continua a funzionare. + +Dopo che hai installato il modello, Study prepara in background i file che hai già. Nel frattempo la ricerca dice **Ricerca per significato in preparazione: per ora solo risultati esatti.** I file nuovi vengono preparati man mano che vengono letti. Puoi seguire il lavoro in [Attività](../background-work/), alla voce **Ricerca per significato**. + +## Cosa viene cercato + +Un file compare in **Nei tuoi file** una volta che Study lo ha letto, che per una registrazione vuol dire una volta trascritta. I file che Study non sa leggere si trovano comunque per nome. Vedi [fonti](../sources/) per sapere quali tipi vengono letti. + +Ogni tipo di fonte si può escludere dall'indice di ricerca, o dalla ricerca per significato, in **Impostazioni › Elaborazione** alla voce **Ricerca**. + +Se il testo di un file è sbagliato, correggilo con **Correggi questo testo** in **Dettagli del file**. La ricerca usa la tua correzione. + +## Ricerca e risposte + +Le [risposte](../answers/) usano lo stesso indice per trovare i passaggi collegati in tutto il progetto. Una fonte che la ricerca trova bene è una fonte da cui le risposte attingono bene, quindi installare il modello di ricerca aiuta anche @study a capire cosa intendi. diff --git a/apps/landing/src/content/docs/it/sessions.mdx b/apps/landing/src/content/docs/it/sessions.mdx new file mode 100644 index 00000000..ed1d33ae --- /dev/null +++ b/apps/landing/src/content/docs/it/sessions.mdx @@ -0,0 +1,56 @@ +--- +title: Sessioni +description: Prendi appunti, registra le lezioni e allega file in una sessione, e chiedi a @study quando vuoi una risposta. +section: learn +order: 1 +shot: session +shotAlt: Una sessione con appunti, un file allegato in trascrizione e una risposta di @study con le citazioni. +--- + +Una sessione è il tuo quaderno per una lezione o per un momento di studio. Scrivi appunti, registri quello che ascolti e alleghi i file che ci vanno insieme. Ogni sessione appartiene a un progetto, e ci arrivi dalla pagina di quel progetto. + +## Inizia una sessione + +Scegli **Nuova sessione** dalla home o da un progetto. La nuova sessione offre tre modi per iniziare: + +![Una sessione nuova, con tre schede per registrare una lezione, aggiungere file o chiedere a @study.](shot:new-session "Una sessione nuova ti offre tre modi per iniziare.") + +- **Registra una lezione**, trascritta su questo computer mentre ascolti +- **Aggiungi file**, per portare dentro slide, PDF, registrazioni e altro +- **Chiedi a @study**, per partire da una domanda + +Se sei partito dalla home, **Scegli un progetto** decide dove va la sessione. + +Study dà un nome a ogni sessione a partire dai suoi appunti. Se il nome non va più bene, **Rigenera il titolo dagli appunti** ne chiede uno nuovo. + +## Scrivi appunti + +Scrivi nella casella in basso, quella con **Scrivi un appunto, o @study per chiedere**, e premi Invio per mandare. Un appunto resta un appunto: Study non gli risponde. Per avere una risposta, scrivi **@study** in un punto qualsiasi dell'appunto. Quando digiti `@` si apre un piccolo menu; Invio o Tab inserisce la menzione. Come nascono le risposte lo spiega la pagina sulle [risposte](../answers/). + +![Scrivendo @ nel riquadro degli appunti si apre un menu con @study, e Invio inserisce la menzione.](clip:study-mention "Scrivi @ e premi Invio per chiedere a @study.") + +## Allega file + +Usa **Allega file**, trascina i file sulla sessione oppure incollali con Ctrl V (Cmd V su macOS). Le immagini incollate arrivano come file. Ogni file entra nella tua Libreria, e Study lo legge in background. Vedi [fonti](../sources/) per sapere cosa sa leggere. + +Ogni allegato ha il suo thread. **Rispondi nel thread** tiene gli appunti su quel file accanto al file. Una riga chiara sotto il file dice cosa gli è successo, per esempio **Trascritto** o **Letto**. + +Se Study ha sbagliato qualcosa, apri **Dettagli del file** e scegli **Correggi questo testo**. Da quel momento ricerca e risposte usano la tua correzione. Se il file viene letto di nuovo, la correzione viene sostituita. + +## Registra una lezione + +**Registra audio** avvia la registrazione nella sessione in cui ti trovi. La registrazione viene trascritta sul tuo computer. Se il modello di trascrizione non è ancora scaricato, la trascrizione aspetta in [Attività](../background-work/) finché non lo è. Quando hai finito, scegli **Interrompi e invia la registrazione**. + +Study salva la registrazione ogni secondo mentre è in corso. Se l'app si chiude a metà, la volta dopo la sessione mostra **Registrazione non terminata**, con **Riprendi**, **Invia** ed **Elimina**. + +Si registra in una sola sessione alla volta. Una registrazione ha una durata massima, e quando la raggiunge Study la invia. + +## Modifica e confronta le versioni + +Ogni tuo appunto e ogni risposta conservano le loro versioni. + +- **Modifica** ti fa cambiare il testo da solo. Esc annulla. +- **Modifica con IA** lo riscrive per te, con **Migliora**, **Riassumi** o le tue parole in **Dì all'assistente cosa cambiare**. +- **Rigenera** chiede una nuova risposta. + +Quando un messaggio ha più di una versione, **Versioni** le scorre una per una; le frecce sinistra e destra fanno lo stesso. Le versioni precedenti restano. diff --git a/apps/landing/src/content/docs/it/settings.mdx b/apps/landing/src/content/docs/it/settings.mdx new file mode 100644 index 00000000..8b6517a0 --- /dev/null +++ b/apps/landing/src/content/docs/it/settings.mdx @@ -0,0 +1,47 @@ +--- +title: Impostazioni +description: Un giro tra le impostazioni di Study, dalla lingua e dall'aspetto fino ai modelli e a cosa gira su ogni tipo di file. +section: more +order: 3 +shot: settings +shotAlt: Le impostazioni, con i gruppi Generali e IA nella barra laterale. +--- + +Apri **Impostazioni** dalla barra laterale, o premi Ctrl , (Cmd , su macOS). Le impostazioni sono divise in due gruppi, **Generali** e **IA**. Le modifiche vengono salvate mentre le fai. + +## Generali + +**Lingua** passa Study da English a Italiano e viceversa. Un'anteprima mostra come appariranno menu, dimensioni dei file e orari. + +**Aspetto** offre **Chiaro**, con superfici chiare per il giorno, e **Scuro**, con superfici scure per concentrarsi. **Dimensione del testo** ingrandisce o rimpicciolisce tutto, dal 75% al 200%. Ctrl + e Ctrl − (Cmd su macOS) fanno lo stesso da qualsiasi punto dell'app. + +![Impostazioni › Aspetto, con i temi chiaro e scuro e la dimensione del testo.](shot:settings-appearance "Scegli un tema e una dimensione del testo.") + +**Aggiornamenti** ha **Cerca aggiornamenti**. Quando c'è una nuova versione, **Installa e riavvia** la installa e riavvia Study; prima salva il tuo lavoro. Se stai registrando, finisci prima. Vedi [installazione](../install/#aggiornamenti). + +**Elaborazione** decide cosa fa Study con ogni tipo di file: + +- una tabella con ogni tipo di fonte e le colonne **Lettura**, **Riordino del testo**, **Ricerca** e **Materiale di studio**, dove attivi o disattivi ogni passaggio. Vedi [fonti](../sources/#cambiare-cosa-viene-eseguito). +- **Salta chiacchiere e questioni organizzative prima di scrivere**: un modello veloce elimina chiacchiere, questioni organizzative e rumore prima di scrivere il materiale di studio o le domande di esercitazione, così entrano tutte le lezioni e non solo una parte. +- **Nuove flashcard al giorno**: un mazzo nuovo e grande viene distribuito su più giorni, così i ripassi restano gestibili. Le flashcard già imparate tornano comunque quando è il momento. + +## IA + +**Panoramica** mostra tutte le funzioni di IA a colpo d'occhio: a cosa servono, dove funzionano e se sono **Pronto**, **Serve l'accesso** o **Da scaricare**. **Configura** ti porta a quello che manca. + +**Modelli linguistici** è dove colleghi il tuo piano ChatGPT. + +![Impostazioni › Modelli linguistici: l'accesso con ChatGPT, e il modello per le attività rapide, quotidiane e impegnative.](shot:settings-models "Accedi con il tuo piano, e scegli un modello per ogni tipo di attività.") + +- **Accedi con ChatGPT**, ed **Esci** quando vuoi scollegarti. +- **Gestisci l'utilizzo** apre nel browser le impostazioni di utilizzo di ChatGPT. +- **Prova la connessione** manda una piccola richiesta per controllare che tutto funzioni. +- In **Modelli per attività**, tre livelli: **Attività rapide** dà un nome alle sessioni e toglie le chiacchiere; **Attività quotidiane** legge PDF e immagini, corregge le trascrizioni, risponde a @study, scrive il materiale di studio e le domande di esercitazione, e valuta le risposte; **Attività impegnative** disegna gli schemi. Ogni livello usa il proprio modello consigliato, a meno che tu non ne scelga un altro. + +**Trascrizione** trasforma le registrazioni in testo sul tuo computer. Qui scarichi il modello, circa 670 MB, e imposti **Copie del modello**, da 1 a 4. Ogni copia usa circa 2 GB di memoria e trascrive in parallelo. La lingua parlata viene riconosciuta da sola. + +**Questo computer** mostra cosa Study ha trovato e misurato sul tuo computer, come core e memoria, e **Dove funzionano i modelli**: per la ricerca e per la trascrizione, se questo computer li fa girare, o perché no. Dopo un cambio di hardware puoi usare **Misura di nuovo**. Qui si scarica anche il modello di ricerca, circa 135 MB. + +## Dove vengono salvate + +Le impostazioni stanno nello stesso database del tuo lavoro, nella cartella dei dati. Vedi [privacy](../privacy/#dove-stanno-i-tuoi-dati). diff --git a/apps/landing/src/content/docs/it/sources.mdx b/apps/landing/src/content/docs/it/sources.mdx new file mode 100644 index 00000000..ee82e8cb --- /dev/null +++ b/apps/landing/src/content/docs/it/sources.mdx @@ -0,0 +1,62 @@ +--- +title: Fonti +description: I tipi di file e di link che Study sa leggere, e come legge ciascuno. +section: learn +order: 2 +shot: library +shotAlt: La Libreria, con registrazioni, PDF, immagini e pagine web di diversi progetti. +--- + +Una fonte è qualsiasi cosa dai a Study per imparare: una registrazione, un PDF, delle slide, una pagina web, una nota. Study conserva ogni file nella tua Libreria e trasforma in testo quelli che capisce, così risposte, ricerca e materiale di studio possono rimandare al punto da cui viene ogni cosa. + +## Aggiungere fonti + +Puoi aggiungere fonti in più punti: + +- in una [sessione](../sessions/), con **Allega file**, trascinando i file o incollandoli +- dalla home o dalla Libreria, con **Aggiungi file** +- nella Libreria, con **Nuova nota**, che richiede un titolo e del testo +- nella Libreria, con **Aggiungi link**, per una pagina web o un video di YouTube + +![La finestra Aggiungi file nella Libreria, con uno spazio dove trascinare i file e un pulsante per sceglierli.](shot:add-files "Trascina i file nella finestra, o sceglili dal tuo computer.") + +Ogni file deve essere sotto i 512 MiB e non vuoto. Study guarda il contenuto di un file per capire cos'è, invece di fidarsi del nome. + +## Cosa legge Study, e come + +| Tipo | Come viene letto | +|---|---| +| Registrazioni e audio | Trascritte sul tuo computer | +| Video | L'audio viene trascritto sul tuo computer | +| PDF | Pagine lette con l'IA | +| Immagini (PNG, JPEG, WebP, GIF, BMP, TIFF) | Lette con l'IA | +| Documenti (`.docx`) | Lettore Office | +| Presentazioni (`.pptx`) | Lettore Office | +| Pagine web | Lettore di pagine web | +| Codice e file web, HTML compreso | Lettore di testo semplice | +| File di testo e note | Lettore di testo semplice | +| Fogli di calcolo salvati come testo, per esempio CSV | Lettore di testo semplice | + +Alcuni file vengono conservati ma non letti: archivi, altri formati per ufficio come `.doc`, `.odt` e `.rtf`, e-book, immagini SVG e HEIC, e tutto ciò che Study non riconosce. Restano nella tua Libreria. Se ti serve leggerne uno, di solito funziona salvarlo di nuovo come PDF, PNG, MP3 o M4A. + +![Un PDF aperto nella Libreria: la prima pagina accanto al progetto, e sotto il testo letto da ogni pagina.](shot:source-details "Ogni file mostra cosa è stato letto, pagina per pagina.") + +## Link + +**Aggiungi link** salva una pagina web e la legge. Un link di YouTube (compresi Shorts, dirette e link `youtu.be`) viene trattato come una registrazione: Study ne prende l'audio con yt-dlp e lo trascrive. Per questo yt-dlp deve essere installato; vedi [installazione](../install/). + +![La finestra Aggiungi link, con un campo per l'indirizzo e una nota sui video di YouTube.](shot:add-link "Una pagina web viene salvata e letta; un video di YouTube viene trascritto.") + +## Dopo la lettura + +Le registrazioni passano per altri due passaggi. **Correggi le trascrizioni con l'IA** sistema le parole che la trascrizione ha capito male, e **Riordina gli spazi** mette in ordine la spaziatura. Poi ogni fonte letta entra nell'**Indice di ricerca** e nella **Ricerca per significato**, così la [ricerca](../search/) e le [risposte](../answers/) possono trovarla. + +Le fonti lette alimentano anche il materiale di studio del progetto: le [flashcard](../flashcards/) e lo [schema](../notes-and-diagrams/). Alcuni tipi lasciano fuori una parte in modo predefinito. Codice e fogli di calcolo non producono flashcard, e le immagini non entrano nello schema. + +## Cambiare cosa viene eseguito + +Apri **Impostazioni › Elaborazione** per vedere ogni tipo di fonte in una tabella con le colonne **Lettura**, **Riordino del testo**, **Ricerca** e **Materiale di studio**. Ogni passaggio si può attivare o disattivare per ogni tipo, e ogni modifica viene salvata subito. + +![Impostazioni › Elaborazione: una tabella di ogni tipo di fonte, con un interruttore per ogni passaggio.](shot:settings-processing "Puoi attivare o disattivare ogni passaggio per ogni tipo di fonte.") + +Se correggi a mano il testo di un file, con **Correggi questo testo** in **Dettagli del file**, ricerca e risposte usano la tua versione. diff --git a/apps/landing/src/i18n/docs.ts b/apps/landing/src/i18n/docs.ts new file mode 100644 index 00000000..977f0d6e --- /dev/null +++ b/apps/landing/src/i18n/docs.ts @@ -0,0 +1,24 @@ +// The docs in one language, in sidebar order, grouped by section. +import { getCollection, type CollectionEntry } from 'astro:content'; +import { sections } from '../content.config'; +import type { Lang } from './strings'; + +export { sections }; +export type Doc = CollectionEntry<'docs'>; + +/** A doc's slug: its file name, shared by the English and the Italian page. */ +export const slug = (doc: Doc) => doc.id.split('/').pop()!; + +/** The docs in `lang`, in sidebar order. Every page exists in English and Italian, or the + * build fails naming the one missing, so the language switch never leads nowhere. */ +export async function docsIn(lang: Lang): Promise { + const every = await getCollection('docs'); + const all = every.filter((doc) => doc.id.startsWith(`${lang}/`)); + const here = new Set(all.map(slug)); + const missing = every.find((doc) => !here.has(slug(doc))); + if (missing) throw new Error(`docs/${missing.id} has no ${lang} page: add src/content/docs/${lang}/${slug(missing)}.md(x)`); + return all.sort( + (a, b) => + sections.indexOf(a.data.section) - sections.indexOf(b.data.section) || a.data.order - b.data.order, + ); +} diff --git a/apps/landing/src/i18n/landing/download.ts b/apps/landing/src/i18n/landing/download.ts new file mode 100644 index 00000000..5a5ee156 --- /dev/null +++ b/apps/landing/src/i18n/landing/download.ts @@ -0,0 +1,62 @@ +// The landing's last call to download: the call, the other platforms in a line, and the notes. +import type { Os, Shape } from '../strings'; + +export const en = { + get: { + download: 'Download', + downloadFor: { + mac: 'Download for macOS', + windows: 'Download for Windows', + linux: 'Download for Linux', + 'linux-arm': 'Download for Linux', + } satisfies Record, + title: 'Study is free.', + key: 'free', + body: 'Open source, and every update is signed.', + note: 'updates wait until you ask for them', + also: 'Also for', + every: 'For', + rows: { + mac: { name: 'macOS on Apple Silicon', label: 'Download for macOS on Apple Silicon' }, + windows: { name: 'Windows', label: 'Download for Windows, x86-64' }, + linux: { name: 'Linux x86-64', label: 'Download the Linux x86-64 AppImage' }, + 'linux-arm': { name: 'Linux ARM64', label: 'Download the Linux ARM64 AppImage' }, + } satisfies Record, + linuxNote: 'Linux needs Ubuntu 24.04, Debian 13 or newer.', + optional: 'Optional: yt-dlp for YouTube videos, and FFmpeg for rare recording formats.', + early: 'Study is in early development. Expect rough edges, and', + tellUs: 'tell us about them', + install: 'Installing Study', + all: 'All releases', + }, +} as const; + +export const it: Shape = { + get: { + download: 'Scarica', + downloadFor: { + mac: 'Scarica per macOS', + windows: 'Scarica per Windows', + linux: 'Scarica per Linux', + 'linux-arm': 'Scarica per Linux', + }, + title: 'Study è gratis.', + key: 'gratis', + body: 'Open source, e ogni aggiornamento è firmato.', + note: 'gli aggiornamenti arrivano solo quando li chiedi', + also: 'Anche per', + every: 'Per', + rows: { + mac: { name: 'macOS su Apple Silicon', label: 'Scarica per macOS su Apple Silicon' }, + windows: { name: 'Windows', label: 'Scarica per Windows, x86-64' }, + linux: { name: 'Linux x86-64', label: 'Scarica l’AppImage Linux x86-64' }, + 'linux-arm': { name: 'Linux ARM64', label: 'Scarica l’AppImage Linux ARM64' }, + }, + linuxNote: 'Su Linux serve Ubuntu 24.04, Debian 13 o successivi.', + optional: 'Facoltativi: yt-dlp per i video di YouTube e FFmpeg per i formati di registrazione rari.', + early: 'Study è nelle prime fasi di sviluppo. Aspettati qualche imperfezione, e', + tellUs: 'segnalacela', + install: 'Installare Study', + all: 'Tutte le versioni', + }, +}; diff --git a/apps/landing/src/i18n/landing/hero.ts b/apps/landing/src/i18n/landing/hero.ts new file mode 100644 index 00000000..06816f51 --- /dev/null +++ b/apps/landing/src/i18n/landing/hero.ts @@ -0,0 +1,24 @@ +// The landing hero's copy: headline, lead, actions and margin note. +import type { Shape } from '../strings'; + +export const en = { + hero: { + title: 'A calm place for everything you learn.', + lead: 'Bring your lectures, slides and PDFs. Study reads them, answers your questions with citations, and turns them into flashcards and practice.', + download: 'Download', + other: 'Other platforms', + terms: 'Free and open source. Runs on your ChatGPT Plus or Pro plan.', + note: 'try a highlight', + }, +} as const; + +export const it: Shape = { + hero: { + title: 'Un posto tranquillo per tutto ciò che impari.', + lead: 'Porta lezioni, slide e PDF. Study li legge, risponde alle tue domande citando le fonti e li trasforma in flashcard ed esercizi.', + download: 'Scarica', + other: 'Altre piattaforme', + terms: 'Libero e open source. Usa il tuo piano ChatGPT Plus o Pro.', + note: 'prova un’evidenziazione', + }, +}; diff --git a/apps/landing/src/i18n/landing/learn.ts b/apps/landing/src/i18n/landing/learn.ts new file mode 100644 index 00000000..184237d3 --- /dev/null +++ b/apps/landing/src/i18n/landing/learn.ts @@ -0,0 +1,151 @@ +// The landing's tabbed stage: its heading, then answers, quiz, flashcard and diagram. +import type { Shape } from '../strings'; + +export const en = { + stage: { + title: 'One lecture, four ways to learn it.', + key: 'four ways', + body: 'Each tab is a piece of the app to try, made from the passage above.', + label: 'What Study makes of a lecture', + }, + answers: { + tab: 'Answers', + title: 'Answers you can check.', + body: 'Ask anything about your material. Every answer cites the page, or the exact moment in the recording. Open a citation and the passage lights up where it came from.', + note: 'your 2:02 highlight, now a cited answer', + ask: 'How many phases does mitosis have?', + a1: 'Mitosis has four phases: prophase, metaphase, anaphase and telophase, though some textbooks add prometaphase between the first two', + a2: '. In prophase the chromatin condenses into chromosomes and the spindle forms', + a3: '.', + cites: ['2:02–3:03', '3:03–4:04'], + tap: 'open a citation', + citeLabel: 'Show the source of citation', + shotAlt: + 'Study on a sample course: the cited passage of the mitosis lecture, 2:02 to 3:03, washed in the file’s details.', + caption: 'In the app, on a sample course: the same passage, washed in the file’s details.', + more: 'Answers and citations', + }, + quiz: { + tab: 'Quizzes', + title: 'Quizzes that grade you.', + body: 'Multiple choice and open questions from your own material, marked with feedback that says why.', + note: 'that textbook aside? now it’s a question', + question: 'Where do the textbooks that add it put prometaphase?', + choices: ['Before prophase', 'Between prophase and metaphase', 'Between metaphase and anaphase', 'After telophase'], + hint: 'Press a choice’s letter, or its number, to answer.', + correct: 'Correct', + wrong: 'Not quite', + right: 'Exactly: it falls between prophase and metaphase, when the nuclear envelope breaks down.', + miss: 'The lecture puts it between prophase and metaphase, when the nuclear envelope breaks down.', + shotAlt: + 'Study on a sample course: an open question on mitosis marked Correct, with feedback that says why.', + caption: 'In the app: an open answer, graded Correct.', + more: 'How practice works', + }, + card: { + tab: 'Flashcards', + title: 'Flashcards that come back on time.', + body: 'Rate each answer and the schedule adapts, so you review just before you forget.', + note: 'and the chromatin? it’s a card now', + course: 'Cell biology', + question: 'Question', + answer: 'Answer', + show: 'Show answer', + hide: 'Show question', + tap: 'flip it', + next: ['back later today', 'back soon', 'back in a few days', 'back in a while'], + from: 'from the lecture, at 3:03', + shotAlt: 'Study on a sample course: the four rating buttons under a flashcard, with Good picked.', + caption: 'In the app, on a sample course: the same four ratings, Good picked.', + more: 'How flashcards are scheduled', + }, + diagram: { + tab: 'Diagrams', + title: 'Diagrams, drawn by hand.', + body: 'A course’s ideas laid out as a hand-drawn sketch, made from your own notes and files. Pan, zoom, and update it as the course grows.', + note: 'the four phases, sketched', + root: 'Mitosis', + phases: ['Prophase', 'Metaphase', 'Anaphase', 'Telophase'], + extra: 'prometaphase?', + aside: 'some textbooks', + label: 'A hand-drawn diagram: mitosis and its four phases, with prometaphase pencilled in between prophase and metaphase.', + from: 'from the lecture, at 2:02', + more: 'Notes and diagrams', + }, +} as const; + +/** Which of the quiz's choices is right, the same in every language. */ +export const quizAnswer = 1; + +export const it: Shape = { + stage: { + title: 'Una lezione, quattro modi per impararla.', + key: 'quattro modi', + body: 'Ogni scheda è un pezzo dell’app da provare, fatto dal passaggio qui sopra.', + label: 'Cosa fa Study di una lezione', + }, + answers: { + tab: 'Risposte', + title: 'Risposte che puoi verificare.', + body: 'Chiedi qualsiasi cosa sul tuo materiale. Ogni risposta cita la pagina, o il momento esatto della registrazione. Apri una citazione e il passaggio si illumina nel punto da cui viene.', + note: 'la tua evidenziazione delle 2:02, ora una risposta citata', + ask: 'Quante fasi ha la mitosi?', + a1: 'La mitosi ha quattro fasi: profase, metafase, anafase e telofase, anche se alcuni libri aggiungono la prometafase tra le prime due', + a2: '. Nella profase la cromatina si condensa in cromosomi e si forma il fuso', + a3: '.', + cites: ['2:02–3:03', '3:03–4:04'], + tap: 'apri una citazione', + citeLabel: 'Mostra la fonte della citazione', + shotAlt: + 'Study su un corso di esempio: il passaggio citato della lezione di mitosi, dalle 2:02 alle 3:03, evidenziato nei dettagli del file.', + caption: 'Nell’app, su un corso di esempio: lo stesso passaggio, evidenziato nei dettagli del file.', + more: 'Risposte e citazioni', + }, + quiz: { + tab: 'Quiz', + title: 'Quiz che ti correggono.', + body: 'Domande a scelta multipla e aperte dal tuo materiale, corrette con un commento che spiega il perché.', + note: 'l’inciso sui libri? ora è una domanda', + question: 'Dove collocano la prometafase i libri che la aggiungono?', + choices: ['Prima della profase', 'Tra profase e metafase', 'Tra metafase e anafase', 'Dopo la telofase'], + hint: 'Premi la lettera di una risposta, o il suo numero, per rispondere.', + correct: 'Corretto', + wrong: 'Non proprio', + right: 'Esatto: si colloca tra profase e metafase, quando l’involucro nucleare si dissolve.', + miss: 'La lezione la colloca tra profase e metafase, quando l’involucro nucleare si dissolve.', + shotAlt: + 'Study su un corso di esempio: una domanda aperta sulla mitosi segnata Corretto, con un commento che spiega il perché.', + caption: 'Nell’app: una risposta aperta, segnata Corretto.', + more: 'Come funzionano le esercitazioni', + }, + card: { + tab: 'Flashcard', + title: 'Flashcard che tornano al momento giusto.', + body: 'Valuta ogni risposta e il calendario si adatta: ripassi poco prima di dimenticare.', + note: 'e la cromatina? ora è una flashcard', + course: 'Biologia cellulare', + question: 'Domanda', + answer: 'Risposta', + show: 'Mostra risposta', + hide: 'Mostra domanda', + tap: 'girala', + next: ['torna più tardi oggi', 'torna presto', 'torna tra qualche giorno', 'torna tra un bel po’'], + from: 'dalla lezione, alle 3:03', + shotAlt: 'Study su un corso di esempio: i quattro pulsanti di valutazione sotto una flashcard, con Bene scelto.', + caption: 'Nell’app, su un corso di esempio: le stesse quattro valutazioni, con Bene scelto.', + more: 'Come vengono pianificate le flashcard', + }, + diagram: { + tab: 'Schemi', + title: 'Schemi, disegnati a mano.', + body: 'Le idee di un corso in uno schizzo a mano, fatto dai tuoi appunti e file. Spostati, ingrandisci e aggiornalo quando il corso cresce.', + note: 'le quattro fasi, a schizzo', + root: 'Mitosi', + phases: ['Profase', 'Metafase', 'Anafase', 'Telofase'], + extra: 'prometafase?', + aside: 'alcuni libri', + label: 'Uno schema disegnato a mano: la mitosi e le sue quattro fasi, con la prometafase segnata a matita tra profase e metafase.', + from: 'dalla lezione, alle 2:02', + more: 'Appunti e schemi', + }, +}; diff --git a/apps/landing/src/i18n/landing/lecture.ts b/apps/landing/src/i18n/landing/lecture.ts new file mode 100644 index 00000000..a4d0cdee --- /dev/null +++ b/apps/landing/src/i18n/landing/lecture.ts @@ -0,0 +1,97 @@ +// The sample lecture the landing quotes in several sections: the passage the hero highlights, +// what its three phrases became, and the two transcript lines answers cite. Shared: change it +// with every section that quotes it (Hero, Material, Learn). +import type { Shape } from '../strings'; + +export const en = { + passage: { + label: 'Sample lecture', + source: 'lecture-mitosis.mp3', + time: '2:02', + hint: 'Choose a highlight to see what it became.', + // The transcript, split around the three phrases a learner highlighted. + before: '', + p1: 'Mitosis itself has four phases: prophase, metaphase, anaphase and telophase.', + mid1: ' ', + p2: 'Some textbooks add prometaphase between prophase and metaphase', + mid2: '; I’ll mention it, but we’ll work with four. In prophase ', + p3: 'the chromatin condenses into visible chromosomes', + after: + ', the nucleolus disappears, and the centrosomes move to opposite poles and start building the spindle.', + }, + became: { + answer: { + tab: 'An answer', + ask: '@study How many phases does mitosis have?', + text: 'Mitosis has four phases: prophase, metaphase, anaphase and telophase, though some textbooks add prometaphase between the first two.', + cite: '2:02–3:03', + more: 'How answers cite their sources', + }, + question: { + tab: 'A practice question', + text: 'The lecture mentions prometaphase but works with four phases of mitosis. Which four, and where would prometaphase fit?', + grade: 'Correct', + feedback: + 'Exactly: the four phases in order, and prometaphase fits between prophase and metaphase, when the nuclear envelope breaks down.', + more: 'How practice works', + }, + card: { + tab: 'A flashcard', + front: 'What happens to the chromatin in prophase?', + back: 'It condenses into visible chromosomes.', + ratings: ['Again', 'Hard', 'Good', 'Easy'], + more: 'How flashcards are scheduled', + }, + }, + // The two transcript lines, at 2:02 and 3:03; the passage's phrases come from them. + transcript: [ + 'Mitosis itself has four phases: prophase, metaphase, anaphase and telophase. Some textbooks add prometaphase between prophase and metaphase; I’ll mention it, but we’ll work with four.', + 'In prophase the chromatin condenses into visible chromosomes, the nucleolus disappears, and the centrosomes move to opposite poles and start building the spindle.', + ], +} as const; + +export const it: Shape = { + passage: { + label: 'Lezione di esempio', + source: 'lecture-mitosis.mp3', + time: '2:02', + hint: 'Scegli un’evidenziazione per vedere cosa è diventata.', + before: '', + p1: 'La mitosi vera e propria ha quattro fasi: profase, metafase, anafase e telofase.', + mid1: ' ', + p2: 'Alcuni libri aggiungono la prometafase tra profase e metafase', + mid2: '; la cito, ma lavoreremo con quattro. Nella profase ', + p3: 'la cromatina si condensa in cromosomi visibili', + after: + ', il nucleolo scompare e i centrosomi si spostano ai poli opposti e iniziano a formare il fuso.', + }, + became: { + answer: { + tab: 'Una risposta', + ask: '@study Quante fasi ha la mitosi?', + text: 'La mitosi ha quattro fasi: profase, metafase, anafase e telofase, anche se alcuni libri aggiungono la prometafase tra le prime due.', + cite: '2:02–3:03', + more: 'Come le risposte citano le fonti', + }, + question: { + tab: 'Una domanda di esercizio', + text: 'La lezione cita la prometafase ma lavora con quattro fasi della mitosi. Quali sono, e dove si collocherebbe la prometafase?', + grade: 'Corretto', + feedback: + 'Esatto: le quattro fasi in ordine, e la prometafase si colloca tra profase e metafase, quando l’involucro nucleare si dissolve.', + more: 'Come funzionano gli esercizi', + }, + card: { + tab: 'Una flashcard', + front: 'Cosa succede alla cromatina nella profase?', + back: 'Si condensa in cromosomi visibili.', + ratings: ['Di nuovo', 'Difficile', 'Bene', 'Facile'], + more: 'Come vengono pianificate le flashcard', + }, + }, + // The two transcript lines, at 2:02 and 3:03; the passage's phrases come from them. + transcript: [ + 'La mitosi vera e propria ha quattro fasi: profase, metafase, anafase e telofase. Alcuni libri aggiungono la prometafase tra profase e metafase; la cito, ma lavoreremo con quattro.', + 'Nella profase la cromatina si condensa in cromosomi visibili, il nucleolo scompare e i centrosomi si spostano ai poli opposti e iniziano a formare il fuso.', + ], +}; diff --git a/apps/landing/src/i18n/landing/material.ts b/apps/landing/src/i18n/landing/material.ts new file mode 100644 index 00000000..481acd17 --- /dev/null +++ b/apps/landing/src/i18n/landing/material.ts @@ -0,0 +1,51 @@ +// The landing's material section: what Study reads, the Cell biology course's library (the +// sample's own files, each with its size as `media_size` writes it), the lecture that arrives, +// and its transcript. +import type { Shape } from '../strings'; + +export const en = { + material: { + title: 'Everything you study, in one place.', + key: 'one place', + body: 'Recordings, slides, PDFs, pictures, documents, web pages and YouTube videos, organized by course. Study reads each one, so a lecture becomes a transcript you can search and cite.', + more: 'What Study can read', + sessions: 'Sessions', + library: 'Library', + course: 'Cell biology', + // The lecture that drops into the library, and what Study does with it. + arriving: { + name: 'lecture-mitosis.mp3', + size: '44.1 MiB', + working: 'Listening', + done: 'Transcribed', + }, + // The files already there, in the order the tile lists them, and their sizes; Material.astro + // gives each its kind's glyph, in the same order. + files: ['lecture-cell-cycle.mp3', 'slides-mitosis.pdf', 'Mitosis – Wikipedia'], + sizes: ['38.6 MiB', '3.4 MiB', '212.7 KiB'], + note: 'every line keeps its minute', + transcript: 'Transcript', + }, +} as const; + +export const it: Shape = { + material: { + title: 'Tutto ciò che studi, in un posto solo.', + key: 'un posto solo', + body: 'Registrazioni, slide, PDF, immagini, documenti, pagine web e video di YouTube, organizzati per corso. Study li legge uno per uno, così una lezione diventa una trascrizione che puoi cercare e citare.', + more: 'Cosa sa leggere Study', + sessions: 'Sessioni', + library: 'Libreria', + course: 'Biologia cellulare', + arriving: { + name: 'lecture-mitosis.mp3', + size: '44,1 MiB', + working: 'In ascolto', + done: 'Trascritto', + }, + files: ['lecture-cell-cycle.mp3', 'slides-mitosis.pdf', 'Mitosis – Wikipedia'], + sizes: ['38,6 MiB', '3,4 MiB', '212,7 KiB'], + note: 'ogni riga tiene il suo minuto', + transcript: 'Trascrizione', + }, +}; diff --git a/apps/landing/src/i18n/landing/yours.ts b/apps/landing/src/i18n/landing/yours.ts new file mode 100644 index 00000000..f9dad2aa --- /dev/null +++ b/apps/landing/src/i18n/landing/yours.ts @@ -0,0 +1,64 @@ +// The landing's privacy section: where the data stays, the sketch's labels and the three facts. +// Every claim here is what the app does: the database and the two local models (speech-to-text +// and search) are on the learner's computer, and only model work goes to their ChatGPT plan. +import type { Shape } from '../strings'; + +export const en = { + yours: { + title: 'Yours, on your computer.', + key: 'your computer', + lead: 'One database on your computer holds everything you bring. Your material leaves only for your own ChatGPT plan, carrying just what a model needs.', + sketch: { + computer: 'your computer', + library: 'your library', + plan: ['your', 'ChatGPT plan'], + out: ['only what', 'a model needs'], + }, + local: { + title: 'Your library stays here', + body: 'Everything lives in one database on your computer. Transcription and search run here too, and their two models download only when you install them. The audio of your recordings', + never: 'never leaves', + end: '.', + }, + plan: { + title: 'No extra AI bill', + body: 'It runs on the ChatGPT Plus or Pro plan you already have. No API keys, no subscription to us. Each request carries only what its task needs, like the pages to read or the passages found for your question.', + }, + open: { + title: 'Apache 2.0 or MIT', + body: 'Open source, under the licence you choose. Read the code, build it yourself, send a fix.', + link: 'The code on GitHub', + }, + more: 'Privacy and your data', + }, +} as const; + +export const it: Shape = { + yours: { + title: 'Tuo, sul tuo computer.', + key: 'tuo computer', + lead: 'Un unico database sul tuo computer tiene tutto ciò che porti. Il tuo materiale esce soltanto verso il tuo piano ChatGPT, con solo ciò che serve a un modello.', + sketch: { + computer: 'il tuo computer', + library: 'la tua libreria', + plan: ['il tuo piano', 'ChatGPT'], + out: ['solo ciò che', 'serve a un modello'], + }, + local: { + title: 'La tua libreria resta qui', + body: 'Tutto vive in un unico database sul tuo computer. Anche trascrizione e ricerca girano qui, e i loro due modelli si scaricano solo quando li installi. L’audio delle tue registrazioni', + never: 'non esce mai', + end: '.', + }, + plan: { + title: 'Nessun costo AI in più', + body: 'Usa il piano ChatGPT Plus o Pro che hai già. Niente chiavi API, nessun abbonamento con noi. Ogni richiesta porta solo ciò che serve al suo compito, come le pagine da leggere o i passaggi trovati per la tua domanda.', + }, + open: { + title: 'Apache 2.0 or MIT', + body: 'Open source, con la licenza che scegli tu. Leggi il codice, compilalo da te, proponi una correzione.', + link: 'Il codice su GitHub', + }, + more: 'Privacy e dati', + }, +}; diff --git a/apps/landing/src/i18n/strings.ts b/apps/landing/src/i18n/strings.ts new file mode 100644 index 00000000..232fc15b --- /dev/null +++ b/apps/landing/src/i18n/strings.ts @@ -0,0 +1,110 @@ +// Every word the site's pages show, outside the docs' own Markdown, in English and Italian. +// The two must say the same thing; the Italian is written, not translated word for word. +// The landing's sections keep their copy in landing/
.ts, spread in here so `t(lang)` +// stays the one way in; nav, meta, footer and the docs' chrome stay in this file. + +import * as lecture from './landing/lecture'; +import * as hero from './landing/hero'; +import * as material from './landing/material'; +import * as learn from './landing/learn'; +import * as yours from './landing/yours'; +import * as download from './landing/download'; + +export type Lang = 'en' | 'it'; + +export const repo = 'https://github.com/nicksan222/study'; +export const releases = `${repo}/releases`; +const latest = `${releases}/latest/download`; + +/** What the release publishes, one row per installer (apps/desktop/packaging/release.sh names them). */ +export const platforms = [ + { os: 'mac', file: 'study-macos-aarch64.dmg' }, + { os: 'windows', file: 'study-windows-x86_64.exe' }, + { os: 'linux', file: 'study-linux-x86_64.AppImage' }, + { os: 'linux-arm', file: 'study-linux-aarch64.AppImage' }, +] as const; +export type Os = (typeof platforms)[number]['os']; +export const downloadUrl = (file: string) => `${latest}/${file}`; + +/** A path inside the site, with the base and the language prefix. */ +export function href(lang: Lang, path = ''): string { + const base = import.meta.env.BASE_URL.replace(/\/$/, ''); + const prefix = lang === 'en' ? '' : `/${lang}`; + const clean = path.replace(/^\//, ''); + return `${base}${prefix}/${clean}`; +} + +const en = { + otherLang: 'Italiano', + skip: 'Skip to content', + nav: { docs: 'Docs', github: 'GitHub', download: 'Download' }, + meta: { + title: 'Study: a calm place for everything you learn', + description: + 'Bring your lectures, slides and PDFs. Study reads them, answers your questions with citations, and turns them into flashcards and practice.', + }, + ...lecture.en, + ...hero.en, + ...material.en, + ...learn.en, + ...yours.en, + ...download.en, + footer: { + license: 'Apache 2.0 or MIT', + contribute: 'Contribute', + bug: 'Report a bug', + sample: 'Screens show the app on sample courses.', + }, + docs: { + title: 'Docs', + lead: 'How each part of Study works, one page per feature.', + sections: { start: 'Get started', learn: 'Learn with Study', more: 'More' }, + menu: 'Docs menu', + close: 'Close', + next: 'Next', + turn: 'Previous and next page', + previous: 'Previous', + edit: 'Edit this page', + home: 'Study', + }, +} as const; + +export type Shape = { [K in keyof T]: T[K] extends string ? string : T[K] extends readonly string[] ? readonly string[] : Shape }; + +const it: Shape = { + otherLang: 'English', + skip: 'Vai al contenuto', + nav: { docs: 'Guida', github: 'GitHub', download: 'Scarica' }, + meta: { + title: 'Study: un posto tranquillo per tutto ciò che impari', + description: + 'Porta lezioni, slide e PDF. Study li legge, risponde alle tue domande citando le fonti e li trasforma in flashcard ed esercizi.', + }, + ...lecture.it, + ...hero.it, + ...material.it, + ...learn.it, + ...yours.it, + ...download.it, + footer: { + license: 'Apache 2.0 o MIT', + contribute: 'Contribuisci', + bug: 'Segnala un problema', + sample: 'Le schermate mostrano l’app su corsi di esempio.', + }, + docs: { + title: 'Guida', + lead: 'Come funziona ogni parte di Study, una pagina per funzione.', + sections: { start: 'Per iniziare', learn: 'Studiare con Study', more: 'Altro' }, + menu: 'Menu della guida', + close: 'Chiudi', + next: 'Successiva', + turn: 'Pagina precedente e successiva', + previous: 'Precedente', + edit: 'Modifica questa pagina', + home: 'Study', + }, +}; + +export const strings = { en, it }; +export const t = (lang: Lang) => strings[lang]; diff --git a/apps/landing/src/icons/LICENSE b/apps/landing/src/icons/LICENSE new file mode 100644 index 00000000..718bb3f0 --- /dev/null +++ b/apps/landing/src/icons/LICENSE @@ -0,0 +1,43 @@ +ISC License + +Copyright (c) 2026 Lucide Icons and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--- + +The following Lucide icons are derived from the Feather project: + +airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out + +The MIT License (MIT) (for the icons listed above) + +Copyright (c) 2013-present Cole Bemis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/landing/src/icons/audio-lines.svg b/apps/landing/src/icons/audio-lines.svg new file mode 100644 index 00000000..20cf82ab --- /dev/null +++ b/apps/landing/src/icons/audio-lines.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/apps/landing/src/icons/wallet.svg b/apps/landing/src/icons/wallet.svg new file mode 100644 index 00000000..f88eb9f2 --- /dev/null +++ b/apps/landing/src/icons/wallet.svg @@ -0,0 +1,14 @@ + + + + diff --git a/apps/landing/src/layouts/Base.astro b/apps/landing/src/layouts/Base.astro new file mode 100644 index 00000000..b530cf4a --- /dev/null +++ b/apps/landing/src/layouts/Base.astro @@ -0,0 +1,216 @@ +--- +// Every page's frame: the head, the slim bar (wordmark, Docs, GitHub, language, Download) +// and the footer. `path` is the page's address without its language, for the switch. +import '../styles/global.css'; +import Logo from '../components/Logo.astro'; +import Icon from '../components/Icon.astro'; +import { href, repo, releases, t, type Lang } from '../i18n/strings'; + +interface Props { + lang: Lang; + title: string; + description: string; + path: string; + /** The landing shows its own Download in the hero, so the bar's stays quiet there. */ + quietDownload?: boolean; +} +const { lang, title, description, path, quietDownload = false } = Astro.props; +const s = t(lang); +const other: Lang = lang === 'en' ? 'it' : 'en'; +const canonical = new URL(href(lang, path), Astro.site); +const ogImage = new URL(href(lang, 'og.png'), Astro.site); +--- + + + + + + + {title} + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + diff --git a/apps/landing/src/layouts/Docs.astro b/apps/landing/src/layouts/Docs.astro new file mode 100644 index 00000000..ae47d141 --- /dev/null +++ b/apps/landing/src/layouts/Docs.astro @@ -0,0 +1,450 @@ +--- +// A docs page: the app's own shape at reading size, a tonal sidebar of every page beside +// one reading column. On a narrow screen the sidebar folds into a menu above the page. +import Base from './Base.astro'; +import Icon from '../components/Icon.astro'; +import Shot from '../components/Shot.astro'; +import { docsIn, sections, slug, type Doc } from '../i18n/docs'; +import { href, repo, t, type Lang } from '../i18n/strings'; + +interface Props { + lang: Lang; + /** The page shown, or none for the docs' index. */ + doc?: Doc; +} +const { lang, doc } = Astro.props; +const s = t(lang); +const all = await docsIn(lang); +const current = doc ? slug(doc) : undefined; +const at = doc ? all.findIndex((d) => slug(d) === current) : -1; +const previous = at > 0 ? all[at - 1] : undefined; +const next = at >= 0 && at < all.length - 1 ? all[at + 1] : undefined; +const path = doc ? `docs/${current}/` : 'docs/'; +const title = doc ? `${doc.data.title} · ${s.docs.title} · Study` : `${s.docs.title} · Study`; +const description = doc ? doc.data.description : s.docs.lead; +const link = (d: Doc) => href(lang, `docs/${slug(d)}/`); +--- + + +
+
+ + + {s.docs.menu} + + +
+ +
+ { + doc ? ( + + ) : ( +
+

{s.docs.title}

+

{s.docs.lead}

+ {sections.map((section) => ( +
+

{s.docs.sections[section]}

+ +
+ ))} +
+ ) + } +
+
+ + + + + diff --git a/apps/landing/src/pages/apple-touch-icon.png.ts b/apps/landing/src/pages/apple-touch-icon.png.ts new file mode 100644 index 00000000..2c078835 --- /dev/null +++ b/apps/landing/src/pages/apple-touch-icon.png.ts @@ -0,0 +1,4 @@ +// The home-screen icon on iPhones and iPads: the full mark at the size they ask for. +import { mark } from '../brand'; + +export const GET = () => mark(180); diff --git a/apps/landing/src/pages/docs/[slug].astro b/apps/landing/src/pages/docs/[slug].astro new file mode 100644 index 00000000..26d03057 --- /dev/null +++ b/apps/landing/src/pages/docs/[slug].astro @@ -0,0 +1,18 @@ +--- +import { render } from 'astro:content'; +import Media from '../../components/Media.astro'; +import Docs from '../../layouts/Docs.astro'; +import { docsIn, slug } from '../../i18n/docs'; + +export async function getStaticPaths() { + const docs = await docsIn('en'); + return docs.map((doc) => ({ params: { slug: slug(doc) }, props: { doc } })); +} + +const { doc } = Astro.props; +const { Content } = await render(doc); +--- + + + + diff --git a/apps/landing/src/pages/docs/index.astro b/apps/landing/src/pages/docs/index.astro new file mode 100644 index 00000000..e7920834 --- /dev/null +++ b/apps/landing/src/pages/docs/index.astro @@ -0,0 +1,5 @@ +--- +import Docs from '../../layouts/Docs.astro'; +--- + + diff --git a/apps/landing/src/pages/favicon.svg.ts b/apps/landing/src/pages/favicon.svg.ts new file mode 100644 index 00000000..0b1698ac --- /dev/null +++ b/apps/landing/src/pages/favicon.svg.ts @@ -0,0 +1,4 @@ +// The browser tab's icon: the small mark, as in the app's title bar. +import { small } from '../brand'; + +export const GET = () => new Response(small, { headers: { 'Content-Type': 'image/svg+xml' } }); diff --git a/apps/landing/src/pages/index.astro b/apps/landing/src/pages/index.astro new file mode 100644 index 00000000..2b024002 --- /dev/null +++ b/apps/landing/src/pages/index.astro @@ -0,0 +1,5 @@ +--- +import Landing from '../components/Landing.astro'; +--- + + diff --git a/apps/landing/src/pages/it/docs/[slug].astro b/apps/landing/src/pages/it/docs/[slug].astro new file mode 100644 index 00000000..7bbe7fb3 --- /dev/null +++ b/apps/landing/src/pages/it/docs/[slug].astro @@ -0,0 +1,18 @@ +--- +import { render } from 'astro:content'; +import Media from '../../../components/Media.astro'; +import Docs from '../../../layouts/Docs.astro'; +import { docsIn, slug } from '../../../i18n/docs'; + +export async function getStaticPaths() { + const docs = await docsIn('it'); + return docs.map((doc) => ({ params: { slug: slug(doc) }, props: { doc } })); +} + +const { doc } = Astro.props; +const { Content } = await render(doc); +--- + + + + diff --git a/apps/landing/src/pages/it/docs/index.astro b/apps/landing/src/pages/it/docs/index.astro new file mode 100644 index 00000000..da74eef4 --- /dev/null +++ b/apps/landing/src/pages/it/docs/index.astro @@ -0,0 +1,5 @@ +--- +import Docs from '../../../layouts/Docs.astro'; +--- + + diff --git a/apps/landing/src/pages/it/index.astro b/apps/landing/src/pages/it/index.astro new file mode 100644 index 00000000..f65a084f --- /dev/null +++ b/apps/landing/src/pages/it/index.astro @@ -0,0 +1,5 @@ +--- +import Landing from '../../components/Landing.astro'; +--- + + diff --git a/apps/landing/src/pages/it/og.png.ts b/apps/landing/src/pages/it/og.png.ts new file mode 100644 index 00000000..c98ae3ab --- /dev/null +++ b/apps/landing/src/pages/it/og.png.ts @@ -0,0 +1,4 @@ +// The card a link to the Italian pages shows. +import { preview } from '../../brand'; + +export const GET = () => preview('it'); diff --git a/apps/landing/src/pages/og.png.ts b/apps/landing/src/pages/og.png.ts new file mode 100644 index 00000000..664cba55 --- /dev/null +++ b/apps/landing/src/pages/og.png.ts @@ -0,0 +1,4 @@ +// The card a link to the English pages shows. +import { preview } from '../brand'; + +export const GET = () => preview('en'); diff --git a/apps/landing/src/scripts/anchor.ts b/apps/landing/src/scripts/anchor.ts new file mode 100644 index 00000000..77aa20d1 --- /dev/null +++ b/apps/landing/src/scripts/anchor.ts @@ -0,0 +1,482 @@ +// Anchored ink: arrows and marks the page measures and draws, so each lands on what it names at +// every width. A section writes them empty, inside a `[data-ink-scope]`: +// +// +// +// +// An arrow runs from its note's nearest edge to just outside its target's, the head's tip at the +// target; `data-bow` (-1 to 1, default 0.2) bends it to the right of travel, or left when +// negative. A mark (`ring`, `underline`, `double`, `bracket`, `check`, `star`) is sized from +// its target's words, line by line (`data-lines="first"` underlines only the first line). Both +// redraw when their scope, note or target resizes, when the fonts load, after a transition that +// shows or moves them, and on `ink:relayout` (`relayout`). With `js-ink` each draws in once its +// target is in view, after its note has written and its `[data-manual]` root (if any) is inked, +// two at a time; otherwise it is set down at once. A piece that cannot land cleanly is hidden, and +// `data-ink-hidden` says why: `min` (narrower than data-arrow-min), `missing` (no such note +// or target), `unseen` (one is not shown), `apart` (they overlap), `short` (too close for an +// arrow) or `crosses` (every route would cross text or a picture, or leave the page). +import { along, bow, pen, seed, type Pt } from '../components/rough'; +import { DRAW_MS, HEAD_MS, live, STAGGER_MS, STEP_MS, VIEW_THRESHOLD } from './pace'; + +type Box = { l: number; t: number; r: number; b: number }; +type Mark = 'ring' | 'underline' | 'double' | 'bracket' | 'check' | 'star'; +type Piece = { + svg: SVGSVGElement; + scope: Element; + from: Element | null; + to: Element | null; + drawn: 'waiting' | 'drawing' | 'done'; + seen: boolean; + /** Moved while drawing in; redrawn once it has finished. */ + stale: boolean; +}; + +/** Tells the anchored ink that something moved without resizing: a tab, a flip, a reveal. */ +export const relayout = () => document.dispatchEvent(new Event('ink:relayout')); + +const OUT_NOTE = 6; +const OUT_TARGET = 5; +const MIN_LENGTH = 26; + +const boxOf = (r: DOMRectReadOnly): Box => ({ + l: r.left, + t: r.top, + r: r.right, + b: r.bottom, +}); +const grow = (b: Box, by: number): Box => ({ + l: b.l - by, + t: b.t - by, + r: b.r + by, + b: b.b + by, +}); +const inside = ([x, y]: Pt, b: Box) => x >= b.l && x <= b.r && y >= b.t && y <= b.b; +const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), Math.max(lo, hi)); +const shown = (el: Element) => el.checkVisibility({ visibilityProperty: true }); + +const find = (scope: Element, sel: string | undefined) => { + if (!sel) return null; + try { + return scope.querySelector(sel) ?? document.querySelector(sel); + } catch { + return null; + } +}; + +// What a stroke must not cross: the lines of text and the pictures shown in a scope, cut to +// what their clipping boxes let show. Gathered once per scope per redraw, in viewport +// coordinates; the route leaves out its own note and target. +const obstaclesOf = (scope: Element): { box: Box; el: Element }[] => { + const all: Box = { l: -Infinity, t: -Infinity, r: Infinity, b: Infinity }; + const seen = new Map(); + const visible = (el: Element) => seen.get(el) ?? (seen.set(el, shown(el)), seen.get(el)!); + const clips = new Map(); + const clipOf = (el: Element | null): Box => { + if (!el || el === scope) return all; + const known = clips.get(el); + if (known) return known; + const outer = clipOf(el.parentElement); + const style = getComputedStyle(el); + const own = style.overflowX !== 'visible' || style.overflowY !== 'visible' ? boxOf(el.getBoundingClientRect()) : all; + const clip = { + l: Math.max(outer.l, own.l), + t: Math.max(outer.t, own.t), + r: Math.min(outer.r, own.r), + b: Math.min(outer.b, own.b), + }; + clips.set(el, clip); + return clip; + }; + const found: { box: Box; el: Element }[] = []; + const add = (r: DOMRectReadOnly, el: Element) => { + const clip = clipOf(el.parentElement); + const box = { + l: Math.max(r.left, clip.l), + t: Math.max(r.top, clip.t), + r: Math.min(r.right, clip.r), + b: Math.min(r.bottom, clip.b), + }; + if (box.r > box.l && box.b > box.t) found.push({ box, el }); + }; + const walk = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT); + const range = document.createRange(); + for (let node = walk.nextNode(); node; node = walk.nextNode()) { + const el = node.parentElement; + if (!el || !node.textContent?.trim() || el.closest('svg[data-arrow], svg[data-mark]') || !visible(el)) continue; + range.selectNodeContents(node); + for (const r of range.getClientRects()) add(r, el); + } + for (const el of scope.querySelectorAll('img, picture, video, canvas')) if (visible(el)) add(el.getBoundingClientRect(), el); + return found; +}; + +// The two ends of an arrow along one axis, or nothing when the boxes do not part along it. +const ends = (a: Box, b: Box, axis: 'x' | 'y'): [Pt, Pt] | null => { + const [lo, hi, across, acrossEnd] = axis === 'x' ? (['l', 'r', 't', 'b'] as const) : (['t', 'b', 'l', 'r'] as const); + const mid = (box: Box) => (box[across] + box[acrossEnd]) / 2; + const size = (box: Box) => box[acrossEnd] - box[across]; + // The note's end leans toward the target, the target's end toward the note. + const from = clamp(mid(b), a[across] + size(a) * 0.3, a[acrossEnd] - size(a) * 0.3); + const inset = Math.min(size(b) * 0.3, 12); + const to = clamp(mid(a), b[across] + inset, b[acrossEnd] - inset); + let s: number; + let e: number; + if (b[lo] >= a[hi]) [s, e] = [a[hi] + OUT_NOTE, b[lo] - OUT_TARGET]; + else if (b[hi] <= a[lo]) [s, e] = [a[lo] - OUT_NOTE, b[hi] + OUT_TARGET]; + else return null; + return axis === 'x' + ? [ + [s, from], + [e, to], + ] + : [ + [from, s], + [to, e], + ]; +}; + +// The cleanest route from note to target: the axis they part along most and the bow asked for +// first, then a flatter bow, the other way, and the other axis. Nothing when none is clean. +const route = (from: Element, to: Element, bowAsked: number, obstacles: { box: Box; el: Element }[]) => { + const a = boxOf(from.getBoundingClientRect()); + const b = boxOf(to.getBoundingClientRect()); + const gapX = Math.max(b.l - a.r, a.l - b.r); + const gapY = Math.max(b.t - a.b, a.t - b.b); + if (gapX < 0 && gapY < 0) return 'apart' as const; + const blocks = obstacles.filter(({ el }) => !from.contains(el) && !to.contains(el)).map(({ box }) => grow(box, 3)); + const page = document.documentElement.clientWidth; + const axes: ('x' | 'y')[] = gapX >= gapY ? ['x', 'y'] : ['y', 'x']; + let tried = false; + for (const axis of axes) { + const pair = ends(a, b, axis); + if (!pair) continue; + const [s, e] = pair; + const len = Math.hypot(e[0] - s[0], e[1] - s[1]); + if (len < MIN_LENGTH) continue; + tried = true; + for (const bowTry of new Set([bowAsked, bowAsked * 0.4, -bowAsked])) { + const bend = bowTry * len * 0.45; + const [c1, c2] = bow(s, e, bend); + let clean = true; + for (let i = 1; i <= 22 && clean; i++) { + const p = along(s, c1, c2, e, 0.04 + (i / 22) * 0.88); + clean = p[0] > 2 && p[0] < page - 2 && !blocks.some((box) => inside(p, box)); + } + if (clean) return { s, e, bend, len }; + } + } + return tried ? ('crosses' as const) : ('short' as const); +}; + +// A target's lines of text, each its own box, or the target's box when it has none. +const linesOf = (to: Element): Box[] => { + // Only the words: no whitespace at either end of a text run, and nothing inside an SVG + // (the mark's own, sitting at the target's corner, would read as a line of its own). + const range = document.createRange(); + const walk = document.createTreeWalker(to, NodeFilter.SHOW_TEXT, { + acceptNode: (node) => (node.parentElement?.closest('svg') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT), + }); + const lines: Box[] = []; + for (let node = walk.nextNode(); node; node = walk.nextNode()) { + const text = node.textContent ?? ''; + const start = text.search(/\S/); + if (start < 0) continue; + range.setStart(node, start); + range.setEnd(node, text.trimEnd().length); + for (const r of range.getClientRects()) { + if (!r.width || !r.height) continue; + const line = lines.find((l) => r.top < l.b - 2 && r.bottom > l.t + 2); + if (line) + Object.assign(line, { + l: Math.min(line.l, r.left), + r: Math.max(line.r, r.right), + t: Math.min(line.t, r.top), + b: Math.max(line.b, r.bottom), + }); + else lines.push(boxOf(r)); + } + } + return lines.length ? lines : [boxOf(to.getBoundingClientRect())]; +}; + +// The punctuation written right after an element, if any: the `.` in `free.`. +const trailing = (to: Element): Box | null => { + const next = to.nextSibling; + if (next?.nodeType !== Node.TEXT_NODE) return null; + const mark = /^[.,;:!?…»”’)]+/.exec(next.textContent ?? ''); + if (!mark) return null; + const range = document.createRange(); + range.setStart(next, 0); + range.setEnd(next, mark[0].length); + return boxOf(range.getBoundingClientRect()); +}; + +// Where a ring round `to` may run, top to bottom: the letters' own ink with a little air, cut +// to half the gap to the letters of a line above or below. +const ringRoom = (to: Element, words: Box, font: number, stop: Box | null) => { + const style = getComputedStyle(to); + const ctx = (ringRoom.ctx ??= document.createElement('canvas').getContext('2d')); + const air = clamp(font * 0.12, 3, 8); + if (!ctx) return { t: words.t - air, b: words.b + air, top: words.t, bottom: words.b }; + ctx.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; + const text = (linesText(to) + (stop ? '.' : '')).trim() || 'x'; + const own = ctx.measureText(text); + const tall = ctx.measureText('Hdkl'); + const deep = ctx.measureText('gjpqy'); + // A range's line box starts at the font's ascent above the baseline and ends at its descent below. + const top = words.t + own.fontBoundingBoxAscent - own.actualBoundingBoxAscent; + const bottom = words.b - own.fontBoundingBoxDescent + own.actualBoundingBoxDescent; + const lh = parseFloat(style.lineHeight) || font * 1.2; + const block = (to.closest('h1, h2, h3, h4, p, li, figcaption') ?? to.parentElement ?? to).getBoundingClientRect(); + let t = top - air; + let b = bottom + air; + if (words.t - block.top > lh * 0.5) t = Math.max(t, top - (lh - deep.actualBoundingBoxDescent - own.actualBoundingBoxAscent) / 2); + if (block.bottom - words.b > lh * 0.5) b = Math.min(b, bottom + (lh - tall.actualBoundingBoxAscent - own.actualBoundingBoxDescent) / 2); + // Never through its own letters, however tight the lines. + return { t: Math.min(t, top - 2), b: Math.max(b, bottom + 2), top, bottom }; +}; +ringRoom.ctx = null as CanvasRenderingContext2D | null; + +// The words of `to`, without its marks. +const linesText = (to: Element) => { + let text = ''; + const walk = document.createTreeWalker(to, NodeFilter.SHOW_TEXT); + for (let node = walk.nextNode(); node; node = walk.nextNode()) if (!node.parentElement?.closest('svg')) text += node.textContent; + return text; +}; + +// The words' box, all lines together. +const wordsOf = (to: Element): Box => + linesOf(to).reduce((u, l) => ({ + l: Math.min(u.l, l.l), + t: Math.min(u.t, l.t), + r: Math.max(u.r, l.r), + b: Math.max(u.b, l.b), + })); + +// A mark's strokes in viewport coordinates: the first pass, then a lighter one a hair apart. +const markStrokes = (svg: SVGSVGElement, kind: Mark, to: Element, ink: ReturnType, at: (p: Pt) => Pt): string[][] => { + const box = boxOf(to.getBoundingClientRect()); + const w = box.r - box.l; + const h = box.b - box.t; + const font = parseFloat(getComputedStyle(to).fontSize) || 16; + const twice = (make: () => string) => [make(), make()]; + const [x, y] = at([box.l, box.t]); + switch (kind) { + case 'ring': { + // Hugs the words: sideways by under half a space, so it never reaches the next word, and + // round any punctuation that sits right against them, so it never cuts through that. + // Up and down it rings the letters' ink, not the line box, and stays inside half the gap + // to the lines above and below. + const words = wordsOf(to); + const stop = trailing(to); + if (stop) words.r = Math.max(words.r, stop.r); + const { t, b, top, bottom } = ringRoom(to, words, font, stop); + const [cx, cy] = at([(words.l + words.r) / 2, (t + b) / 2]); + const rx = (words.r - words.l) / 2 + Math.max(font * 0.1, 2.5); + // The loop runs a few hundredths past ry, and the stroke has width. + const ry = Math.max((b - t) / 2 / 1.04 - 1, Math.max((t + b) / 2 - top, bottom - (t + b) / 2) + 2.5); + return [twice(() => ink.ring(cx, cy, rx, ry, 0.55))]; + } + case 'underline': + case 'double': + // Just under the baseline: a line box's foot less most of the descender. + // From the words' left edge, overshooting each end by a hair. `data-lines="first"` + // underlines only the first line of a wrapped phrase. + return linesOf(to) + .slice(0, svg.dataset.lines === 'first' ? 1 : undefined) + .map((line) => { + const [lx, ly] = at([line.l, line.b - font * 0.1]); + const width = line.r - line.l + 2; + const under = () => ink.underline(lx - 1, ly, width); + return kind === 'underline' ? twice(under) : [under(), ink.underline(lx - 2, ly + Math.max(3, font * 0.14), width * 0.96)]; + }); + case 'bracket': + return [twice(() => ink.bracket(x - 9, y - 4, h + 8, 10))]; + case 'check': { + const size = clamp(h * 0.9, 14, 26); + return [twice(() => ink.check(x + w - size * 0.15, y + (h - size) / 2, size))]; + } + case 'star': { + const r = clamp(h * 0.45, 7, 14); + return [twice(() => ink.star(x + w + r * 0.2, y + h / 2, r))]; + } + } +}; + +const pieces: Piece[] = []; + +const hide = (piece: Piece, why: string) => { + piece.svg.dataset.inkHidden = why; + piece.svg.replaceChildren(); +}; + +const paths = (piece: Piece, strokes: { d: string; cls: string }[]) => { + piece.svg.innerHTML = strokes.map(({ d, cls }) => ``).join(''); + if (piece.drawn === 'waiting') { + for (const path of piece.svg.querySelectorAll('path')) { + const len = path.getTotalLength() + 1; + path.style.strokeDasharray = `${len} ${len}`; + path.style.strokeDashoffset = `${len}`; + } + } +}; + +const draw = (piece: Piece, obstacles: (scope: Element) => { box: Box; el: Element }[]) => { + const { svg } = piece; + if (piece.drawn === 'drawing') return void (piece.stale = true); + const min = Number(svg.dataset.arrowMin ?? 0); + if (min && !matchMedia(`(min-width: ${min}px)`).matches) return hide(piece, 'min'); + const arrow = svg.hasAttribute('data-arrow'); + const from = (piece.from = arrow ? find(piece.scope, svg.dataset.from) : null); + const to = (piece.to = find(piece.scope, svg.dataset.to)); + if (!to || (arrow && !from)) return hide(piece, 'missing'); + if (!shown(to) || (from && !shown(from)) || !svg.parentElement || !shown(svg.parentElement)) return hide(piece, 'unseen'); + delete svg.dataset.inkHidden; + const origin = svg.getBoundingClientRect(); + const at = ([x, y]: Pt): Pt => [x - origin.left, y - origin.top]; + const ink = pen(seed(`${svg.dataset.mark ?? 'arrow'} ${svg.dataset.from ?? ''} ${svg.dataset.to}`)); + if (!arrow) { + const strokes = markStrokes(svg, svg.dataset.mark as Mark, to, ink, at); + return paths( + piece, + strokes.flatMap(([first, second]) => [ + { d: first, cls: 'stroke' }, + { d: second, cls: 'stroke pass' }, + ]), + ); + } + const found = route(from!, to, clamp(Number(svg.dataset.bow ?? 0.2) || 0, -1, 1), obstacles(piece.scope)); + if (typeof found === 'string') return hide(piece, found); + const head = clamp(found.len * 0.12, 8, 13); + const first = ink.bowed(at(found.s), at(found.e), found.bend, head); + const second = ink.bowed(at(found.s), at(found.e), found.bend, head * 0.92); + paths(piece, [ + { d: first.shaft, cls: 'shaft' }, + { d: second.shaft, cls: 'shaft pass' }, + { d: first.head, cls: 'head' }, + { d: second.head, cls: 'head pass' }, + ]); +}; + +const redraw = () => { + const cache = new Map(); + const obstacles = (scope: Element) => cache.get(scope) ?? (cache.set(scope, obstaclesOf(scope)), cache.get(scope)!); + pieces.forEach((piece) => draw(piece, obstacles)); + pieces.forEach(start); +}; + +let frame = 0; +const schedule = () => { + if (!frame) frame = requestAnimationFrame(() => ((frame = 0), redraw())); +}; + +// Drawing in: strokes that come into view together go two at a time, a little apart, each +// once its note has written, so a page of them settles rather than flickers. +const running = new Set(); +const queue: Piece[] = []; +let lastStart = 0; +const pump = () => { + while (queue.length && running.size < 2) { + const wait = lastStart + STAGGER_MS - performance.now(); + if (wait > 0) return void window.setTimeout(pump, wait); + const piece = queue.shift()!; + if (piece.drawn !== 'waiting' || piece.svg.dataset.inkHidden) continue; + lastStart = performance.now(); + running.add(piece); + piece.drawn = 'drawing'; + let delay = 0; + for (const path of piece.svg.querySelectorAll('path')) { + const len = path.getTotalLength(); + const head = path.classList.contains('head'); + const pass = path.classList.contains('pass'); + const duration = head ? HEAD_MS : clamp(len * 1.4, DRAW_MS * 0.6, DRAW_MS); + path.style.transition = `stroke-dashoffset ${duration}ms var(--ease-out) ${delay + (pass ? STAGGER_MS / 2 : 0)}ms`; + path.style.strokeDashoffset = '0'; + if (!pass) delay += duration * (head ? 1 : 0.92); + } + window.setTimeout(() => { + running.delete(piece); + piece.drawn = 'done'; + piece.svg.querySelectorAll('path').forEach((path) => path.removeAttribute('style')); + if (piece.stale) schedule(); + piece.stale = false; + pump(); + }, delay + STAGGER_MS); + } +}; + +// Resolves once `el` has `cls`, or after `ms` when given. +const until = (el: Element, cls: string, ms?: number) => + el.classList.contains(cls) + ? Promise.resolve() + : new Promise((resolve) => { + const done = () => (watch.disconnect(), window.clearTimeout(timer), resolve()); + const watch = new MutationObserver(() => el.classList.contains(cls) && done()); + const timer = ms === undefined ? undefined : window.setTimeout(done, ms); + watch.observe(el, { attributes: true, attributeFilter: ['class'] }); + }); + +// A piece draws in once the section has inked its `[data-manual]` root, if it has one, and its +// note has written (or had a moment to). +const ready = (piece: Piece) => { + const manual = piece.svg.closest('[data-manual]'); + const note = piece.from?.closest('[data-type]') ?? piece.from?.querySelector('[data-type]'); + return Promise.all([manual && until(manual, 'is-inked'), note && until(note, 'typed', STEP_MS * 6)]); +}; + +const queued = new WeakSet(); +function start(piece: Piece) { + if (piece.drawn !== 'waiting' || !piece.seen || piece.svg.dataset.inkHidden || queued.has(piece)) return; + queued.add(piece); + void ready(piece).then(() => { + queued.delete(piece); + if (piece.drawn !== 'waiting' || piece.svg.dataset.inkHidden) return; + queue.push(piece); + pump(); + }); +} + +// Every anchored arrow and mark on the page, kept drawn from here on. +{ + for (const svg of document.querySelectorAll('svg[data-arrow], svg[data-mark]')) { + const scope = svg.closest('[data-ink-scope]') ?? svg.parentElement ?? document.body; + const from = svg.hasAttribute('data-arrow') ? find(scope, svg.dataset.from) : null; + pieces.push({ + svg, + scope, + from, + to: find(scope, svg.dataset.to), + drawn: live ? 'waiting' : 'done', + seen: false, + stale: false, + }); + } + const views = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + for (const piece of pieces) { + if (piece.to !== entry.target || piece.seen) continue; + piece.seen = true; + start(piece); + } + } + }, + { threshold: VIEW_THRESHOLD }, + ); + const sizes = new ResizeObserver(schedule); + for (const piece of pieces) { + [piece.scope, piece.from, piece.to].forEach((el) => el && sizes.observe(el)); + if (piece.to) views.observe(piece.to); + } + schedule(); + document.addEventListener('ink:relayout', schedule); + void document.fonts.ready.then(schedule); + // A tab or note that fades in or settles its tilt moves what is anchored to it. + document.addEventListener('transitionend', (event) => { + const el = event.target as Element; + if (pieces.some((piece) => el === piece.from || el === piece.to || el.contains(piece.svg))) schedule(); + }); +} diff --git a/apps/landing/src/scripts/ink.ts b/apps/landing/src/scripts/ink.ts new file mode 100644 index 00000000..2e519f13 --- /dev/null +++ b/apps/landing/src/scripts/ink.ts @@ -0,0 +1,124 @@ +// Ink and type for the landing page. Margin strokes draw and their notes write once each comes +// into view, a few at a time; what the app writes types out when it is shown. Without `js-ink` +// (no script, or reduced motion; Landing.astro sets it before first paint) everything is +// already set down, and these only mark it done. +// +// Markup contract: `[data-ink]` is a piece that inks once (`.draw` strokes, `.fade` fills); +// `[data-type="ch"|"w"|"hand"]` is text split into `.t` units (see components/landing/text.ts); +// `[data-seq]` gets `done` when its texts have finished; `[data-manual]` pieces and anything +// inside `[data-ink-later]` wait for their section's script to call `ink` or `play`. +// Arrows and marks anchored to what they name are drawn by `scripts/anchor.ts`; call `relayout` +// after anything moves them without a resize. + +import { CHAR_MS, DRAW_MS, HAND_WORD_MS, live, PIECE_MS, STAGGER_MS, STEP_MS, VIEW_THRESHOLD, WORD_MS } from './pace'; + +// The page's pace (scripts/pace.ts), for sections to time their own steps by, and the +// anchored arrows and marks (scripts/anchor.ts), which draw themselves once loaded. +export * from './pace'; +export { relayout } from './anchor'; + +export const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms)); +const between = ([lo, hi]: readonly [number, number]) => lo + Math.random() * (hi - lo); + +const runs = new WeakMap(); +const bump = (el: Element) => { + const run = (runs.get(el) ?? 0) + 1; + runs.set(el, run); + return run; +}; + +/** Puts a piece back to unwritten, cancelling any typing in progress. */ +export const clear = (el: Element) => { + bump(el); + el.classList.remove('typed', 'done'); + el.querySelectorAll('.t').forEach((unit) => unit.classList.remove('on', 'at')); +}; + +/** + * One piece of text: by letter with a caret, by word with a caret, or by word in the hand, + * sped up as needed to finish within `budget`. + */ +export const type = async (el: HTMLElement | SVGElement, budget = PIECE_MS) => { + const run = bump(el); + const mode = el.dataset.type; + const units = [...el.querySelectorAll('.t')]; + const most = budget / Math.max(units.length, 1); + el.classList.remove('typed'); + units.forEach((unit) => unit.classList.remove('on', 'at')); + if (!live) return void el.classList.add('typed'); + let last: HTMLElement | undefined; + for (const unit of units) { + if (runs.get(el) !== run) return; + const length = unit.textContent?.length ?? 1; + if (mode === 'hand') unit.style.setProperty('--dur', `${Math.min(length * CHAR_MS[1] + STAGGER_MS, STEP_MS * 1.5)}ms`); + unit.classList.add('on'); + if (mode !== 'hand' && unit.tagName !== 'tspan') { + last?.classList.remove('at'); + unit.classList.add('at'); + last = unit; + } + await wait(Math.min(mode === 'ch' ? between(CHAR_MS) : mode === 'w' ? between(WORD_MS) : HAND_WORD_MS, most)); + } + if (runs.get(el) !== run) return; + last?.classList.remove('at'); + el.classList.add('typed'); +}; + +/** A run of texts, one after another, sharing one piece's time; the root is `done` when the last one is. */ +export const play = async (root: HTMLElement, texts = [...root.querySelectorAll('[data-type]')], budget = PIECE_MS) => { + const run = bump(root); + root.classList.remove('done'); + for (const text of texts) { + await type(text, budget / texts.length); + if (runs.get(root) !== run) return; + } + root.classList.add('done'); +}; + +/** Strokes draw in order, then the texts that are not waiting on a visitor. */ +export const ink = (root: HTMLElement, auto = true) => { + const strokes = [...root.querySelectorAll('.draw, .fade')]; + const last = Math.min((strokes.length - 1) * STAGGER_MS, STEP_MS * 2); + strokes.forEach((stroke, i) => { + stroke.style.setProperty('--d', `${Math.min(i * STAGGER_MS, last)}ms`); + stroke.style.transitionDuration = `${DRAW_MS}ms`; + }); + root.classList.remove('is-inked'); + if (live) void root.getBoundingClientRect(); + root.classList.add('is-inked'); + // Texts in a manual piece inside this one wait for that piece; the root's own count. + const waits = (text: HTMLElement) => { + const manual = text.closest('[data-manual]'); + return !!manual && manual !== root && root.contains(manual); + }; + const texts = [...root.querySelectorAll('[data-type]')].filter((text) => !text.hidden && !waits(text)); + if (!auto || !texts.length) return Promise.resolve(); + // Text starts while the strokes finish, and shares what is left of the piece's time. + const settle = live && strokes.length ? Math.min(last + STAGGER_MS, STEP_MS) : 0; + return wait(settle).then(() => play(root, texts, PIECE_MS - settle)); +}; + +// A few at a time: pieces that come into view together start a little apart. +let next = 0; +const views = live + ? new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + views?.unobserve(entry.target); + const now = performance.now(); + const start = Math.max(now, next); + next = start + STAGGER_MS * 2; + window.setTimeout(() => ink(entry.target as HTMLElement), start - now); + } + }, + { threshold: VIEW_THRESHOLD }, + ) + : undefined; + +for (const root of document.querySelectorAll('[data-ink]:not([data-manual])')) { + if (!views) ink(root); + else if (!root.closest('[data-ink-later]')) views.observe(root); +} +if (!live) document.querySelectorAll('[data-type]').forEach((text) => text.classList.add('typed')); +if (!live) document.querySelectorAll('[data-seq]').forEach((root) => root.classList.add('done')); diff --git a/apps/landing/src/scripts/pace.ts b/apps/landing/src/scripts/pace.ts new file mode 100644 index 00000000..3d2ad807 --- /dev/null +++ b/apps/landing/src/scripts/pace.ts @@ -0,0 +1,36 @@ +// The landing page's pace, quick rather than slow: how long ink and type take, shared by +// scripts/ink.ts, scripts/anchor.ts, the sections' own scripts (through ink.ts) and the +// stylesheets (through the `--pace-*` properties set on the root below). Every inked or typed +// sequence takes its durations from here; a piece, from its first ink to its last, stays under +// about 1.2s. Hover and press feedback and ambient loops keep their own, in their CSS. + +/** One stroke drawing along its length. */ +export const DRAW_MS = 300; +/** An arrowhead's two flicks, after its shaft. */ +export const HEAD_MS = 90; +/** A fade or a pop: a word, a citation, a step appearing. */ +export const FADE_MS = 160; +/** Between strokes, or pieces, that start together. */ +export const STAGGER_MS = 50; +/** The longest wait between one step and the next. */ +export const STEP_MS = 120; +/** The longest any one piece's text takes; with its strokes and arrow, under about 1.2s. */ +export const PIECE_MS = 800; +/** Per letter typed, and per word typed, as the fastest and slowest. */ +export const CHAR_MS = [10, 14] as const; +export const WORD_MS = [20, 28] as const; +/** Per word written by hand. */ +export const HAND_WORD_MS = 20; +/** How much of a piece must be in view before it inks. */ +export const VIEW_THRESHOLD = 0.15; + +/** Whether ink and type animate: with the script and motion allowed (Landing.astro sets `js-ink`). */ +export const live = document.documentElement.classList.contains('js-ink'); + +// The stylesheets' transitions read the same pace. +for (const [name, ms] of [ + ['draw', DRAW_MS], + ['fade', FADE_MS], + ['step', STEP_MS], +] as const) + document.documentElement.style.setProperty(`--pace-${name}`, `${ms}ms`); diff --git a/apps/landing/src/styles/global.css b/apps/landing/src/styles/global.css new file mode 100644 index 00000000..b0b12b9d --- /dev/null +++ b/apps/landing/src/styles/global.css @@ -0,0 +1,450 @@ +/* The Highlighted Notebook at website scale: the app's tokens (DESIGN.md) in both themes, + its fonts (study-ui's own files), and the browser's own surfaces themed from them. */ + +@font-face { + font-family: 'Inter'; + src: url('../../../../crates/study-ui/assets/fonts/Inter-Regular.ttf') format('truetype'); + font-weight: 400; + font-display: swap; +} +@font-face { + font-family: 'Inter'; + src: url('../../../../crates/study-ui/assets/fonts/Inter-Medium.ttf') format('truetype'); + font-weight: 500; + font-display: swap; +} +@font-face { + font-family: 'Inter'; + src: url('../../../../crates/study-ui/assets/fonts/Inter-SemiBold.ttf') format('truetype'); + font-weight: 600; + font-display: swap; +} +@font-face { + font-family: 'Excalifont'; + src: url('../../../../crates/study-ui/assets/fonts/Excalifont-Regular.ttf') format('truetype'); + font-weight: 400; + font-display: swap; +} + +:root { + color-scheme: light dark; + + --canvas: #ffffff; + --sidebar: #f3f3f3; + --raised: #ffffff; + --fill: #f3f3f3; + --hover: #ededed; + --selected: #e7e7e7; + --line: #e4e4e4; + --ink: #111111; + --ink-2: #4f4f4f; + --ink-3: #686868; + --hl-ink: #806200; + --hl-wash: #fff0a1; + --hl-fill: #f2d24b; + --on-hl: #1a1600; + --shadow: 0 8px 24px rgb(0 0 0 / 0.08); + + /* Notebook tabs: one soft tint per story section, on its tile and icon chip only. */ + --blue-bg: #ebf3f8; + --blue-chip: #d3e5ef; + --blue-ink: #24516e; + --green-bg: #edf4ee; + --green-chip: #d5e9d8; + --green-ink: #2c5a37; + --orange-bg: #fbf0e4; + --orange-chip: #f7dcc2; + --orange-ink: #7a4519; + --purple-bg: #f4f0f8; + --purple-chip: #e3d8ee; + --purple-ink: #57397a; + --pink-bg: #faeff4; + --pink-chip: #f2d6e3; + --pink-ink: #7d3256; + + --font: 'Inter', system-ui, sans-serif; + --font-hand: 'Excalifont', 'Inter', system-ui, sans-serif; + + --r-sm: 6px; + --r-md: 8px; + --r-lg: 12px; + --r-xl: 20px; + + --s-1: 4px; + --s-2: 8px; + --s-3: 12px; + --s-4: 16px; + --s-5: 24px; + --s-6: 32px; + --s-7: 48px; + --s-8: 64px; + --s-9: 96px; + --s-10: 144px; + + --frame: 1200px; + --gutter: clamp(16px, 4vw, 48px); + --measure: 680px; + + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); +} + +@media (prefers-color-scheme: dark) { + :root { + --canvas: #1a1a1a; + --sidebar: #0e0e0e; + --raised: #222222; + --fill: #222222; + --hover: #262626; + --selected: #2e2e2e; + --line: #2c2c2c; + --ink: #ececec; + --ink-2: #ababab; + --ink-3: #858585; + --hl-ink: #f2d24b; + --hl-wash: #3b3416; + --shadow: 0 8px 24px rgb(0 0 0 / 0.45); + + --blue-bg: #1b252c; + --blue-chip: #23394a; + --blue-ink: #a9cde3; + --green-bg: #1c261f; + --green-chip: #25392b; + --green-ink: #a8d3b1; + --orange-bg: #2a2219; + --orange-chip: #45311f; + --orange-ink: #eec39c; + --purple-bg: #241f2b; + --purple-chip: #392d48; + --purple-ink: #cfb8ea; + --pink-bg: #2a1e24; + --pink-chip: #45293a; + --pink-ink: #ebb5cd; + } +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + -webkit-text-size-adjust: 100%; + scrollbar-color: var(--selected) var(--canvas); + scroll-padding-top: 80px; +} + +body { + margin: 0; + background: var(--canvas); + color: var(--ink); + font-family: var(--font); + font-size: 17px; + line-height: 1.6; + font-feature-settings: 'cv11', 'ss01'; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +h1, +h2, +h3 { + margin: 0; + font-weight: 600; + text-wrap: balance; +} + +p { + margin: 0; + text-wrap: pretty; +} + +img { + display: block; + max-width: 100%; + height: auto; +} + +a { + color: inherit; + text-decoration-thickness: 1px; + text-underline-offset: 0.22em; + text-decoration-color: var(--ink-3); +} +a:hover { + text-decoration-color: currentColor; +} + +::selection { + background: var(--hl-wash); + color: var(--ink); +} + +input, +textarea { + caret-color: var(--hl-ink); +} + +:focus { + outline: none; +} +:focus-visible { + outline: 2px solid var(--hl-ink); + outline-offset: 2px; + border-radius: var(--r-sm); +} + +::-webkit-scrollbar { + width: 12px; + height: 12px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--selected); + border: 3px solid var(--canvas); + border-radius: 9999px; +} + +code, +kbd { + font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; + font-size: 0.88em; +} +kbd { + display: inline-block; + min-width: 1.6em; + padding: 1px 6px; + border-radius: var(--r-sm); + background: var(--selected); + color: var(--ink); + text-align: center; + line-height: 1.5; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +/* A margin note, in the diagrams' hand. */ +.hand { + font-family: var(--font-hand); + font-weight: 400; + color: var(--ink-2); + letter-spacing: 0; +} +.hand-note { + display: inline-block; + rotate: var(--r, -2deg); + transition: rotate var(--pace-draw, 300ms) var(--ease-out); +} +.ink { + display: block; + overflow: visible; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} +/* The second, lighter pass of a stroke, a hair off the first, as a pen leaves it. */ +.ink .thin { + stroke-width: 1.1; + opacity: 0.55; + translate: 0.8px 0.6px; +} + +/* + * Ink and type, set down once as they come into view. Strokes draw along their length, + * notes write word by word, and what the app writes types with a caret. The text is in the + * page from the start; `js-ink` (absent without the script, or with reduced motion) only + * hides what has not been set down yet. + */ +.js-ink [data-ink]:not(.is-inked) .draw { + stroke-dasharray: 1 1; + stroke-dashoffset: 1; +} +.js-ink [data-ink]:not(.is-inked) .fade { + opacity: 0; +} +.js-ink .is-inked .draw { + stroke-dasharray: 1 1; + transition: stroke-dashoffset var(--pace-draw, 300ms) var(--ease-out) var(--d, 0ms); +} +.js-ink .is-inked .fade { + transition: opacity var(--pace-draw, 300ms) var(--ease-out) var(--d, 0ms); +} +.js-ink [data-type]:not(.typed) .t:not(.on) { + opacity: 0; +} +.js-ink [data-type]:not(.typed) tspan.t:not(.on) { + opacity: 1; + fill-opacity: 0; +} +.js-ink [data-type='w'] .t { + transition: opacity var(--pace-fade, 160ms) ease-out; +} +.js-ink [data-type='hand'] .t { + display: inline-block; + transition: clip-path var(--dur, var(--pace-step, 120ms)) linear; +} +.js-ink [data-type='hand']:not(.typed) .t:not(.on) { + opacity: 1; + clip-path: inset(-30% 100% -30% -10%); +} +.js-ink [data-type='hand'] .t.on { + clip-path: inset(-30% -10% -30% -10%); +} +.js-ink .hand-note[data-type]:not(.typed) { + rotate: calc(var(--r, -2deg) + 2.5deg); +} +.js-ink [data-type]:not(.typed) .wash { + background-size: 0% 88%; +} +.js-ink [data-type] .wash { + transition: background-size var(--pace-draw, 300ms) var(--ease-out); +} +.js-ink .cite.t { + transition: opacity var(--pace-fade, 160ms) ease-out, scale var(--pace-draw, 300ms) var(--ease-out); +} +.js-ink [data-type]:not(.typed) .cite.t:not(.on) { + scale: 0.6; +} +.js-ink [data-seq]:not(.done) .pop { + opacity: 0; + translate: 0 4px; +} +.js-ink [data-seq] .pop { + transition: opacity var(--pace-fade, 160ms) ease-out, translate var(--pace-draw, 300ms) var(--ease-out); +} +/* The caret, only while something is typing. */ +.t.at { + position: relative; +} +.t.at::after { + content: ''; + position: absolute; + left: calc(100% + 1px); + top: 0.12em; + width: 2px; + height: 1.05em; + border-radius: 1px; + background: var(--hl-ink); + animation: caret 900ms steps(1) infinite; +} +@keyframes caret { + 50% { + opacity: 0; + } +} + +/* The page frame: centred, with the gutter on each side. */ +.frame { + width: 100%; + max-width: calc(var(--frame) + 2 * var(--gutter)); + margin-inline: auto; + padding-inline: var(--gutter); +} + +/* Buttons, as in the app: 8px radius, no border; one highlighter button per screen. */ +.button { + display: inline-flex; + align-items: center; + gap: var(--s-2); + min-height: 40px; + padding: 0 var(--s-4); + border-radius: var(--r-md); + font: 500 15px/1 var(--font); + text-decoration: none; + transition: background-color 100ms; + white-space: nowrap; +} +.button svg { + width: 16px; + height: 16px; +} +.button-highlighter { + background: var(--hl-fill); + color: var(--on-hl); +} +.button-highlighter:hover { + background: color-mix(in oklab, var(--hl-fill), #000 8%); +} +.button-highlighter:active { + background: color-mix(in oklab, var(--hl-fill), #000 14%); +} +.button-quiet { + background: var(--hover); + color: var(--ink); +} +.button-quiet:hover { + background: var(--selected); +} +.button-ghost { + color: var(--ink-2); +} +.button-ghost:hover { + background: var(--hover); + color: var(--ink); +} +.button-large { + min-height: 48px; + padding: 0 var(--s-5); + font-size: 16px; +} + +/* The marker stroke: a wash behind ink, slightly ragged at its ends like a real one. */ +.wash { + background: linear-gradient(var(--hl-wash), var(--hl-wash)) no-repeat 0 55% / 100% 88%; + border-radius: 3px 5px 4px 6px; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; + padding: 0 2px; + margin: 0 -2px; +} + +/* A citation, as the app draws it: the source's number as a raised chip. */ +.cite { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 16px; + height: 16px; + padding: 0 4px; + margin-left: 2px; + border-radius: var(--r-sm); + background: var(--selected); + color: var(--ink-2); + font: 500 11px/1 var(--font); + vertical-align: 0.45em; + text-decoration: none; +} +a.cite:hover { + background: var(--hl-wash); + color: var(--ink); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} + +.hand-note[hidden] { + display: none; +} + +/* A headline's one key phrase, underlined or ringed by hand (components/landing/text.ts). */ +.key { + position: relative; + white-space: nowrap; +} diff --git a/apps/landing/src/styles/landing.css b/apps/landing/src/styles/landing.css new file mode 100644 index 00000000..df68d8bc --- /dev/null +++ b/apps/landing/src/styles/landing.css @@ -0,0 +1,146 @@ +/* The landing page's shared pieces, used by more than one section in components/landing/: + * the page's rhythm, the notebook tints and their chips, links onward, tiles and paper. + * Anything only one section uses lives in that section's scoped style. */ + +.landing { + display: grid; + gap: clamp(var(--s-8), 8.25vw, 126px); +} + +/* Notebook tabs: a section's tint on its tiles and icon chips. */ +.tint-blue { + --t-bg: var(--blue-bg); + --t-chip: var(--blue-chip); + --t-ink: var(--blue-ink); +} +.tint-green { + --t-bg: var(--green-bg); + --t-chip: var(--green-chip); + --t-ink: var(--green-ink); +} +.tint-orange { + --t-bg: var(--orange-bg); + --t-chip: var(--orange-chip); + --t-ink: var(--orange-ink); +} +.tint-purple { + --t-bg: var(--purple-bg); + --t-chip: var(--purple-chip); + --t-ink: var(--purple-ink); +} +.tint-pink { + --t-bg: var(--pink-bg); + --t-chip: var(--pink-chip); + --t-ink: var(--pink-ink); +} +.chip { + display: inline-grid; + place-items: center; + flex: none; + width: 24px; + height: 24px; + border-radius: var(--r-sm); + background: var(--t-chip, var(--selected)); + color: var(--t-ink, var(--ink-2)); +} +.chip-lg { + width: 40px; + height: 40px; + margin-bottom: var(--s-5); + border-radius: var(--r-md); +} + +/* A link onward, to the docs or the code. */ +.more { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: var(--s-4); + color: var(--ink-2); + font-size: 14px; + font-weight: 500; + text-decoration: none; +} +.more:hover { + color: var(--ink); +} +.more svg { + transition: transform 160ms var(--ease-out); +} +.more:hover svg { + transform: translateX(3px); +} + +/* A tile in its section's tint, and a sheet of paper laid on one. */ +.tile { + position: relative; + margin: 0; + padding: clamp(var(--s-4), 2vw, var(--s-6)); + border-radius: var(--r-xl); + background: var(--t-bg, var(--fill)); +} +.paper { + padding: var(--s-5); + border-radius: var(--r-lg); + background: var(--canvas); + font-size: 16px; + line-height: 1.55; +} +.paper-head { + display: flex; + align-items: center; + gap: var(--s-2); + color: var(--ink-3); + font-size: 13px; +} +@media (max-width: 480px) { + .paper { + padding: var(--s-4); + } +} + +/* Something the app shows arriving, and a marker wash drawn across. */ +@keyframes arrive { + from { + opacity: 0; + transform: translateY(6px); + filter: blur(2px); + } +} +@keyframes wash-in { + from { + background-size: 0% 86%; + } +} + +/* Anchored ink (scripts/anchor.ts): an arrow or mark measured against what it names. Each is a + * point at its scope's corner that draws outside itself, so it adds nothing to the layout and + * never catches the pointer. Without the script it stays empty. */ +:where([data-ink-scope]) { + position: relative; +} +svg[data-arrow], +svg[data-mark] { + position: absolute; + left: 0; + top: 0; + width: 1px; + height: 1px; + overflow: visible; + pointer-events: none; + color: var(--ink-2); + fill: none; + stroke: currentColor; + stroke-width: 1.9; + stroke-linecap: round; + stroke-linejoin: round; +} +/* The pen's second, lighter pass, a hair off the first. */ +svg[data-arrow] .pass, +svg[data-mark] .pass { + stroke-width: 1.1; + opacity: 0.5; +} +svg[data-ink-hidden] { + display: none; +} diff --git a/apps/landing/tsconfig.json b/apps/landing/tsconfig.json new file mode 100644 index 00000000..8bf91d3b --- /dev/null +++ b/apps/landing/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +} diff --git a/assets/demo.gif b/assets/demo.gif deleted file mode 100644 index 623861cd..00000000 Binary files a/assets/demo.gif and /dev/null differ diff --git a/assets/logo/study-small.svg b/assets/logo/study-small.svg new file mode 100644 index 00000000..2d0c249c --- /dev/null +++ b/assets/logo/study-small.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/logo/study.svg b/assets/logo/study.svg new file mode 100644 index 00000000..d95f1b87 --- /dev/null +++ b/assets/logo/study.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/crates/study-localization/src/catalog/english.rs b/crates/study-localization/src/catalog/english.rs index 78227d9e..a5d9168e 100644 --- a/crates/study-localization/src/catalog/english.rs +++ b/crates/study-localization/src/catalog/english.rs @@ -6,7 +6,6 @@ pub(super) fn text(message: Message) -> &'static str { match message { // App and window: menus, shortcuts and the title bar Message::AppName => "Study", - Message::BrandMark => "S", Message::CloseWindow => "Close window", Message::Quit => "Quit Study", Message::ViewMenu => "View", diff --git a/crates/study-localization/src/catalog/italian.rs b/crates/study-localization/src/catalog/italian.rs index 26dbadba..5298425c 100644 --- a/crates/study-localization/src/catalog/italian.rs +++ b/crates/study-localization/src/catalog/italian.rs @@ -6,7 +6,6 @@ pub(super) fn text(message: Message) -> &'static str { match message { // App and window: menus, shortcuts and the title bar Message::AppName => "Study", - Message::BrandMark => "S", Message::CloseWindow => "Chiudi finestra", Message::Quit => "Esci da Study", Message::ViewMenu => "Vista", diff --git a/crates/study-localization/src/message.rs b/crates/study-localization/src/message.rs index 3021ca1b..c0b2511f 100644 --- a/crates/study-localization/src/message.rs +++ b/crates/study-localization/src/message.rs @@ -10,7 +10,6 @@ pub enum Message { // App and window: menus, shortcuts and the title bar AppName, - BrandMark, CloseWindow, Quit, ViewMenu, diff --git a/crates/study-seed/src/main.rs b/crates/study-seed/src/main.rs index d841ae59..509b5e4b 100644 --- a/crates/study-seed/src/main.rs +++ b/crates/study-seed/src/main.rs @@ -1,12 +1,18 @@ //! Fills the application database with sample data if it is empty. `just reset-data` uses it -//! after deleting the development database. +//! after deleting the development database; `study-seed showcase` writes the showcase sample +//! instead (every file read, no failures), the one the website's screenshots are taken on. #![allow(clippy::print_stderr)] // A command-line tool: printing is its output. fn main() -> study_core::Result<()> { let path = study_core::db::Database::default_path()?; let database = study_core::db::Database::open(&path)?; - if study_seed::demo(&database)? { + let seeded = if std::env::args().nth(1).as_deref() == Some("showcase") { + study_seed::showcase(&database)? + } else { + study_seed::demo(&database)? + }; + if seeded { eprintln!("seeded {}", path.display()); } else { eprintln!("{} already has projects; left as it is", path.display()); diff --git a/crates/study-showcase/Cargo.toml b/crates/study-showcase/Cargo.toml index e7508a9a..235f00c0 100644 --- a/crates/study-showcase/Cargo.toml +++ b/crates/study-showcase/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "study-showcase" -description = "Records the README demo GIF: Study on sample data, driven on a private virtual desktop" +description = "Records the README demo GIF and the website's docs media: Study on sample data, driven on a private virtual desktop" edition.workspace = true version.workspace = true license.workspace = true diff --git a/crates/study-showcase/src/desktop.rs b/crates/study-showcase/src/desktop.rs index 0fa0582d..13e1b6d3 100644 --- a/crates/study-showcase/src/desktop.rs +++ b/crates/study-showcase/src/desktop.rs @@ -1,5 +1,5 @@ -//! The private desktop the demo runs on: a headless sway of its own, the app, the screen -//! recorder, and the few ways to look at the screen and to press keys and buttons. +//! The private desktop the showcase runs on: a headless sway of its own, the app, the screen +//! recorder, and the few ways to look at the screen, capture it and press keys and buttons. //! //! It copies the shape of `.devcontainer/desktop` (one fixed output, no bar, no borders, a //! virtual pointer plugged in by `seat.py`) but shares nothing with it, so recording never @@ -15,9 +15,45 @@ use std::{ use study_core::{Context as _, Error, Result}; -/// The output's size; the GIF shows exactly these pixels at scale 1. -pub const WIDTH: u32 = 1920; -pub const HEIGHT: u32 = 1080; +/// The output: its size in logical pixels, which is what every coordinate means, and how +/// many real pixels each one is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Screen { + pub width: u32, + pub height: u32, + pub scale: u32, +} + +impl Screen { + /// The GIF's: it shows exactly these pixels at scale 1. + pub const DEMO: Self = Self { + width: 1920, + height: 1080, + scale: 1, + }; + /// The docs': a laptop-sized window at twice the density, so stills stay sharp on a + /// high-density screen. + pub const DOCS: Self = Self { + width: 1440, + height: 900, + scale: 2, + }; + + /// Where the pointer rests when the app is ready: on the welcome tour's "Skip". + pub const fn start(self) -> (u32, u32) { + (self.width - 40, 22) + } + + /// Where the welcome tour's "Skip" sits. + const fn skip(self) -> Region { + Region { + x: self.width - 75, + y: 10, + width: 70, + height: 24, + } + } +} /// The app's window ID, a copy of the one in `apps/desktop/src/app/desktop.rs`: this crate /// cannot depend on the binary. If it changes there, the wait for the window times out here. @@ -27,18 +63,19 @@ const APP_ID: &str = "io.github.nicksan222.Study"; /// settle after it. const BEFORE_SKIP: Duration = Duration::from_secs(1); const AFTER_SKIP: Duration = Duration::from_secs(2); -/// Time for the recorder to write its first frame before the tour starts. +/// Time for the recorder to write its first frame before the steps start. pub const FIRST_FRAME: Duration = Duration::from_millis(800); /// How long a button is held down. pub const PRESS_MS: u64 = 60; - -/// Where the welcome tour's "Skip" sits, and the rail's first row once the shell shows. -const SKIP: Region = Region { - x: WIDTH - 75, - y: 10, - width: 70, - height: 24, -}; +/// How long a still waits for the screen to stop changing before it is taken anyway, and +/// for a hidden pointer to leave the picture. +const SETTLE: Duration = Duration::from_secs(6); +const POINTER_GONE: Duration = Duration::from_millis(400); +/// How far apart the darkest and brightest pixel of a region must be for something to be +/// drawn there, in either theme: text on its background, not one flat color. +const CONTRAST: u8 = 100; + +/// The rail's first row once the shell shows. const RAIL: Region = Region { x: RAIL_X, y: RAIL_FIRST - 14, @@ -51,13 +88,11 @@ pub const RAIL_X: u32 = 8; pub const RAIL_WIDTH: u32 = 244; pub const RAIL_FIRST: u32 = 70; pub const RAIL_STEP: u32 = 34; -/// Where the pointer rests when the recording starts and ends: on the welcome tour's "Skip". -pub const START: (u32, u32) = (WIDTH - 40, 22); -const SKIP_CLICK: (u32, u32) = START; /// A running sway with its environment. Stops sway when dropped. pub struct Desktop { sway: Child, + screen: Screen, /// The virtual pointer and the keyboard holder, which live as long as sway. helpers: Vec, runtime_dir: String, @@ -132,7 +167,7 @@ fn stop(child: &mut Child) { child.wait().ok(); } -/// A rectangle of the screen, in pixels. +/// A rectangle of the screen, in logical pixels. #[derive(Clone, Copy)] struct Region { x: u32, @@ -142,14 +177,14 @@ struct Region { } impl Desktop { - /// Starts sway with a runtime directory and configuration under `work`. - pub fn start(repo: &Path, work: &Path) -> Result { + /// Starts sway on `screen`, with a runtime directory and configuration under `work`. + pub fn start(repo: &Path, work: &Path, screen: Screen) -> Result { let runtime = work.join("rt"); fs::remove_dir_all(&runtime).ok(); fs::create_dir_all(&runtime)?; set_private(&runtime)?; let config = work.join("sway.config"); - fs::write(&config, sway_config())?; + fs::write(&config, sway_config(screen))?; let log = fs::File::create(work.join("sway.log"))?; let sway = Command::new("sway") @@ -168,6 +203,7 @@ impl Desktop { .context("cannot start sway")?; let mut desktop = Self { sway, + screen, helpers: Vec::new(), runtime_dir: runtime.to_string_lossy().into_owned(), display: String::new(), @@ -251,8 +287,8 @@ impl Desktop { .is_ok_and(|tree| tree.contains(APP_ID)) } - /// Whether any pixel in `region` is bright: text or an icon is drawn there. - fn is_lit(&self, region: Region) -> bool { + /// Whether something is drawn in `region`: it is not one flat color. + fn is_drawn(&self, region: Region) -> bool { let geometry = format!( "{},{} {}x{}", region.x, region.y, region.width, region.height @@ -260,7 +296,7 @@ impl Desktop { self.command("grim") .args(["-t", "ppm", "-g", &geometry, "-"]) .output() - .is_ok_and(|output| output.status.success() && any_lit(&output.stdout)) + .is_ok_and(|output| output.status.success() && has_contrast(&output.stdout)) } /// Waits for the window and the first-launch measurement, skips the welcome tour in the @@ -274,13 +310,14 @@ impl Desktop { // The measurement screen has no "Skip"; the tour that follows it does. wait(Duration::from_secs(600), "the welcome tour", || { app.check_running(work)?; - Ok(self.is_lit(SKIP).then_some(())) + Ok(self.is_drawn(self.screen.skip()).then_some(())) })?; thread::sleep(BEFORE_SKIP); - self.click(SKIP_CLICK.0, SKIP_CLICK.1)?; + let (x, y) = self.screen.start(); + self.click(x, y)?; wait(Duration::from_secs(60), "the app's shell", || { app.check_running(work)?; - Ok(self.is_lit(RAIL).then_some(())) + Ok(self.is_drawn(RAIL).then_some(())) })?; thread::sleep(AFTER_SKIP); Ok(()) @@ -294,12 +331,63 @@ impl Desktop { /// Presses and releases the left button where the pointer is. pub fn press(&self) -> Result<()> { - self.swaymsg(&["seat", "-", "cursor", "press", "button1"])?; + self.button(true)?; thread::sleep(Duration::from_millis(PRESS_MS)); - self.swaymsg(&["seat", "-", "cursor", "release", "button1"]) + self.button(false) + } + + /// Holds the left button down, or lets it go: the two ends of a drag. + pub fn button(&self, down: bool) -> Result<()> { + let action = if down { "press" } else { "release" }; + self.swaymsg(&["seat", "-", "cursor", action, "button1"]) .map(drop) } + /// Takes a still of the whole output at its full density into `path` (a PNG), once + /// the screen has stopped changing, without the pointer in it. A screen that never + /// settles (a spinner) is taken anyway after a few seconds, with a warning. + pub fn capture(&self, path: &Path) -> Result<()> { + self.swaymsg(&["seat", "-", "hide_cursor", "1"])?; + thread::sleep(POINTER_GONE); + let settled = self.settle(); + let taken = self.run("grim", &["-t", "png", &path.to_string_lossy()]); + self.swaymsg(&["seat", "-", "hide_cursor", "0"])?; + if !settled? { + eprintln!( + "warning: the screen kept changing; took {} anyway", + path.display() + ); + } + taken + } + + /// Waits until two grabs of the screen a moment apart are the same, and says whether + /// they ever were before [`SETTLE`] passed. + fn settle(&self) -> Result { + let grab = || -> Result> { + let output = self + .command("grim") + .args(["-t", "ppm", "-s", "1", "-"]) + .output() + .context("cannot run grim")?; + if !output.status.success() { + return Err(Error::msg(format!("grim failed: {}", output.status))); + } + Ok(output.stdout) + }; + let start = Instant::now(); + let mut last = grab()?; + while start.elapsed() < SETTLE { + thread::sleep(Duration::from_millis(300)); + let next = grab()?; + if next == last { + return Ok(true); + } + last = next; + } + Ok(false) + } + fn click(&self, x: u32, y: u32) -> Result<()> { self.warp(x, y)?; thread::sleep(Duration::from_millis(200)); @@ -346,14 +434,14 @@ impl Recording { } /// Asks the recorder to finish the file and waits for it. A still screen sends the - /// recorder no frame to notice the request on, so the pointer is nudged until it ends, - /// and comes back to where the recording began. - pub fn stop(mut self, desktop: &Desktop) -> Result<()> { + /// recorder no frame to notice the request on, so the pointer, resting at `at`, is + /// nudged until it ends and comes back there. + pub fn stop(mut self, desktop: &Desktop, at: (u32, u32)) -> Result<()> { signal(&self.process.0, "INT").context("cannot stop wf-recorder")?; let ended = wait(Duration::from_secs(10), "wf-recorder to finish", || { let moved = desktop - .warp(START.0 - 2, START.1) - .and_then(|()| desktop.warp(START.0, START.1)); + .warp(at.0.saturating_sub(2), at.1) + .and_then(|()| desktop.warp(at.0, at.1)); moved?; Ok(self.process.ended()) })?; @@ -392,9 +480,15 @@ fn set_private(dir: &Path) -> Result<()> { } /// sway's configuration: the shared desktop's, with a fixed output. -fn sway_config() -> String { +fn sway_config(screen: Screen) -> String { + let Screen { + width, + height, + scale, + } = screen; + let (width, height) = (width * scale, height * scale); format!( - "output HEADLESS-1 resolution {WIDTH}x{HEIGHT} scale 1 position 0 0 bg #3b4252 solid_color\n\ + "output HEADLESS-1 resolution {width}x{height} scale {scale} position 0 0 bg #3b4252 solid_color\n\ default_border none\n\ default_floating_border none\n\ focus_follows_mouse no\n\ @@ -416,8 +510,9 @@ fn discover(runtime: &Path, pid: u32) -> Option<(String, String)> { Some((display, socket.to_string_lossy().into_owned())) } -/// Whether a binary PPM (`P6`) has a pixel brighter than text on the dark background. -fn any_lit(ppm: &[u8]) -> bool { +/// Whether a binary PPM (`P6`) holds both a dark and a bright pixel, [`CONTRAST`] apart in +/// some channel: text on its background in a light or a dark theme. +fn has_contrast(ppm: &[u8]) -> bool { // The header is three whitespace-separated lines: magic, size and maximum value. let mut newlines = 0; let Some(start) = ppm.iter().position(|&byte| { @@ -426,11 +521,12 @@ fn any_lit(ppm: &[u8]) -> bool { }) else { return false; }; - ppm[start + 1..] - .as_chunks::<3>() - .0 + let (low, high) = ppm[start + 1..] .iter() - .any(|pixel| pixel.iter().any(|&channel| channel > 150)) + .fold((u8::MAX, u8::MIN), |(low, high), &channel| { + (low.min(channel), high.max(channel)) + }); + high.saturating_sub(low) >= CONTRAST } #[cfg(test)] @@ -438,14 +534,17 @@ mod tests { use super::*; #[test] - fn a_bright_pixel_is_found_after_the_header() { - let mut dark = b"P6\n2 1\n255\n".to_vec(); - dark.extend([20, 20, 20, 30, 30, 30]); - assert!(!any_lit(&dark)); - let mut lit = b"P6\n2 1\n255\n".to_vec(); - lit.extend([20, 20, 20, 30, 220, 30]); - assert!(any_lit(&lit)); - assert!(!any_lit(b"P6\n")); + fn text_is_found_on_a_dark_or_a_light_background_but_not_a_flat_one() { + let ppm = |pixels: [u8; 6]| { + let mut ppm = b"P6\n2 1\n255\n".to_vec(); + ppm.extend(pixels); + ppm + }; + assert!(!has_contrast(&ppm([20, 20, 20, 30, 30, 30]))); + assert!(!has_contrast(&ppm([240, 240, 240, 250, 250, 250]))); + assert!(has_contrast(&ppm([20, 20, 20, 30, 220, 30]))); + assert!(has_contrast(&ppm([250, 250, 250, 40, 40, 40]))); + assert!(!has_contrast(b"P6\n")); } #[test] @@ -475,8 +574,9 @@ mod tests { #[test] fn the_config_pins_the_output_and_runs_nothing() { - let config = sway_config(); - assert!(config.contains(&format!("resolution {WIDTH}x{HEIGHT} scale 1"))); + let config = sway_config(Screen::DEMO); + assert!(config.contains("resolution 1920x1080 scale 1")); assert!(!config.contains("exec")); + assert!(sway_config(Screen::DOCS).contains("resolution 2880x1800 scale 2")); } } diff --git a/crates/study-showcase/src/docs.rs b/crates/study-showcase/src/docs.rs new file mode 100644 index 00000000..1a8966f7 --- /dev/null +++ b/crates/study-showcase/src/docs.rs @@ -0,0 +1,674 @@ +//! The website's docs media: `just docs-media`. +//! +//! Every still and clip the docs show is a [`Scene`] here: a name, the [`Step`]s that bring +//! the app to it, and what is taken there, a [`Take::Still`] or a [`Take::Clip`] of more +//! steps. [`run`] makes all of them from nothing in every language and both themes: for +//! each pair it seeds a fresh database, saves the language and the look through the same +//! preferences the Settings page writes, starts its own private desktop and the app, waits +//! as the GIF does for the app to be ready and the seed indexed, and plays every scene. +//! Each scene ends where the next can start: on Home, nothing open. +//! +//! Stills are taken once the screen stops changing and without the pointer; clips keep the +//! pointer, since it is what moves. Scenes that change the sample (rating a card, answering +//! a question) come last, so every other scene sees the seed as it was written. +//! +//! Files are named `--`: stills in `apps/landing/src/assets/shots` +//! as WebP, clips in `apps/landing/src/assets/clips` as MP4, each with a WebP poster of its +//! last frame. The site picks the variant for the page's language and the reader's theme. +//! Nothing here runs a model: answering a quiz question grades it at once from the seed, +//! and nothing is asked of `@study` or sent. + +use std::{ + fmt, + path::{Path, PathBuf}, + thread, + time::Instant, +}; + +use study_core::{Language, LanguagePreferences, Result, db::Database}; + +use crate::{ + desktop::{self, Desktop, Screen}, + encode, + tour::{self, Modifier, Step, chord, key, rail}, +}; + +/// How much of the still screen before a clip's first step the clip keeps, in seconds. +const LEAD_IN: f64 = 0.3; + +/// Where the site keeps its stills and its clips, from the repository's root. +const SHOTS: &str = "apps/landing/src/assets/shots"; +const CLIPS: &str = "apps/landing/src/assets/clips"; + +/// The window's look: a copy of the desktop's `Appearance` codes in +/// `apps/desktop/src/app/preferences.rs`, stored under the same scope and key. This crate +/// cannot depend on the binary; if they change there, every take comes out dark. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Theme { + Light, + Dark, +} + +impl Theme { + pub const ALL: [Self; 2] = [Self::Light, Self::Dark]; + const SCOPE: &str = "app"; + const KEY: &str = "appearance"; + + /// The stored code, which is also the file name's suffix. + pub const fn code(self) -> &'static str { + match self { + Self::Light => "light", + Self::Dark => "dark", + } + } +} + +/// One language and one theme: one fresh database, desktop and app. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Variant { + language: Language, + theme: Theme, +} + +impl fmt::Display for Variant { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}-{}", self.language.code(), self.theme.code()) + } +} + +impl Variant { + fn all() -> Vec { + Language::ALL + .iter() + .flat_map(|&language| Theme::ALL.map(|theme| Self { language, theme })) + .collect() + } + + /// Saves this language and look where the app reads them at start. + fn save(self, database: &Database) -> Result<()> { + LanguagePreferences { + language: self.language, + } + .save(database)?; + database.set_preference( + Theme::SCOPE, + Theme::KEY, + Some(&study_core::preferences::encode(&self.theme.code())?), + ) + } +} + +/// One picture or moving picture the docs show. +#[derive(Debug, Clone, PartialEq)] +pub struct Scene { + /// The file name before the language and theme, and how the docs refer to it. + pub name: &'static str, + /// What brings the app from Home to where the take starts; not recorded. + pub setup: Vec, + pub take: Take, + /// What undoes the scene before the way back Home, such as emptying what it typed. + pub after: Vec, +} + +impl Scene { + /// This scene, undone by `steps` afterwards. + fn then(self, after: Vec) -> Self { + Self { after, ..self } + } + + /// The files this scene writes in `variant`: a still, or a clip and its poster. + fn files(&self, repo: &Path, variant: Variant) -> Vec { + let file = format!("{}-{variant}", self.name); + match self.take { + Take::Still => vec![output(repo, SHOTS, &file, "webp")], + Take::Clip(_) => vec![ + output(repo, CLIPS, &file, "mp4"), + output(repo, CLIPS, &file, "webp"), + ], + } + } +} + +/// What a scene keeps. +#[derive(Debug, Clone, PartialEq)] +pub enum Take { + /// The screen as the setup leaves it. + Still, + /// These steps, recorded. + Clip(Vec), +} + +/// The rail's rows on the docs screen, counting Home as 0. +const HOME: u32 = 0; +const PROJECTS: u32 = 1; +const FLASHCARDS: u32 = 2; +const DIAGRAMS: u32 = 3; +const PRACTICE: u32 = 4; +const LIBRARY: u32 = 5; +const ACTIVITY: u32 = 6; + +/// Places on the docs screen, in logical pixels, taken from screenshots of it. +mod at { + /// Home's buttons. + pub const NEW_SESSION: (u32, u32) = (558, 160); + pub const NEW_PROJECT: (u32, u32) = (823, 160); + /// The Mitosis lecture session under Cell biology, in the Projects sidebar. + pub const MITOSIS: (u32, u32) = (104, 428); + /// The first citation in its answer, the close button of the panel it opens, and the + /// message box. + pub const CITATION: (u32, u32) = (449, 558); + pub const CLOSE_PANEL: (u32, u32) = (1381, 22); + pub const COMPOSER: (u32, u32) = (700, 792); + /// The Library's toolbar, and the textbook chapter's tile. + pub const NEW_NOTE: (u32, u32) = (1310, 23); + pub const ADD_LINK: (u32, u32) = (1346, 23); + pub const ADD_FILES: (u32, u32) = (1382, 23); + pub const TEXTBOOK: (u32, u32) = (725, 215); + /// The Cell biology diagram in the Diagrams sidebar, and two points on its canvas. + pub const CELL_DIAGRAM: (u32, u32) = (80, 370); + pub const CANVAS_FROM: (u32, u32) = (900, 560); + pub const CANVAS_TO: (u32, u32) = (640, 480); + /// Flashcards: the overview in the sidebar, its Review button, and the Cell biology + /// deck. + pub const DUE: (u32, u32) = (70, 361); + pub const REVIEW: (u32, u32) = (448, 306); + pub const CELL_DECK: (u32, u32) = (100, 400); + /// The fourth choice of the open question, and a past answer, on the Practice page. + pub const CHOICE_D: (u32, u32) = (700, 354); + pub const PAST_ANSWER: (u32, u32) = (530, 555); + /// Settings, and the pages of its sidebar. + pub const SETTINGS: (u32, u32) = (66, 838); + pub const APPEARANCE: (u32, u32) = (79, 402); + pub const PROCESSING: (u32, u32) = (80, 505); + pub const MODELS: (u32, u32) = (100, 654); +} + +fn click((x, y): (u32, u32)) -> Step { + Step::Click { x, y } +} + +/// A still taken after `setup`. +fn still(name: &'static str, setup: Vec) -> Scene { + Scene { + name, + setup, + take: Take::Still, + after: Vec::new(), + } +} + +/// A clip of `steps`, recorded after `setup`. +fn clip(name: &'static str, setup: Vec, steps: Vec) -> Scene { + Scene { + name, + setup, + take: Take::Clip(steps), + after: Vec::new(), + } +} + +/// Opens the Mitosis lecture session. +fn mitosis() -> Vec { + vec![ + rail(PROJECTS), + Step::Pause(1200), + click(at::MITOSIS), + Step::Pause(1200), + // The first time, Projects opens the last session after the click lands; the second + // click wins over it, and does nothing once the lecture is open. + click(at::MITOSIS), + Step::Pause(1000), + ] +} + +/// Starts a review of the cards due, from the Flashcards overview: the page remembers an +/// open deck, so the overview is chosen in its sidebar first. +fn review() -> Vec { + vec![ + rail(FLASHCARDS), + Step::Pause(800), + click(at::DUE), + Step::Pause(800), + click(at::REVIEW), + Step::Pause(1000), + ] +} + +/// Empties the message box, so nothing typed is left as a draft or ever sent. +fn clear_composer() -> Vec { + vec![chord(&[Modifier::Ctrl], "a"), key("BackSpace")] +} + +/// Every scene, in the order they are taken. Those that change the sample come last. +pub fn scenes() -> Vec { + use Step::Pause; + let ctrl: &'static [Modifier] = &[Modifier::Ctrl]; + let with = |mut steps: Vec, more: Vec| { + steps.extend(more); + steps + }; + vec![ + // First steps. + still("home", vec![rail(HOME), Pause(1000)]), + // Sessions and answers. + still("session", mitosis()), + still( + "citation", + with(mitosis(), vec![click(at::CITATION), Pause(1500)]), + ) + .then(vec![click(at::CLOSE_PANEL)]), + still( + "study-menu", + with( + mitosis(), + vec![click(at::COMPOSER), Step::Type { text: "@" }, Pause(800)], + ), + ) + .then(with(vec![key("Escape")], clear_composer())), + clip( + "citation-open", + mitosis(), + vec![Pause(400), click(at::CITATION), Pause(2600)], + ) + .then(vec![click(at::CLOSE_PANEL)]), + clip( + "study-mention", + mitosis(), + vec![ + click(at::COMPOSER), + Pause(300), + Step::Type { text: "@st" }, + Pause(700), + key("Tab"), + Pause(300), + Step::Type { + text: "which phase ends mitosis?", + }, + Pause(1500), + ], + ) + .then(clear_composer()), + // Search. + still( + "search", + vec![ + chord(ctrl, "k"), + Pause(300), + Step::Type { text: "prophase" }, + Pause(2000), + ], + ), + clip( + "search-typing", + vec![], + vec![ + Pause(300), + chord(ctrl, "k"), + Pause(400), + Step::Type { text: "prophase" }, + Pause(2200), + ], + ), + // Sources. + still("library", vec![rail(LIBRARY), Pause(1200)]), + still( + "new-note", + vec![rail(LIBRARY), Pause(800), click(at::NEW_NOTE), Pause(800)], + ), + still( + "add-files", + vec![rail(LIBRARY), Pause(800), click(at::ADD_FILES), Pause(800)], + ), + still( + "add-link", + vec![rail(LIBRARY), Pause(800), click(at::ADD_LINK), Pause(800)], + ), + still( + "source-details", + vec![rail(LIBRARY), Pause(800), click(at::TEXTBOOK), Pause(1800)], + ), + // Notes and diagrams. + still("diagram", vec![rail(DIAGRAMS), Pause(1500)]), + still( + "diagram-cells", + vec![ + rail(DIAGRAMS), + Pause(800), + click(at::CELL_DIAGRAM), + Pause(1500), + ], + ), + // Flashcards. + still("flashcards", vec![rail(FLASHCARDS), Pause(1000)]), + still( + "flashcard-deck", + vec![ + rail(FLASHCARDS), + Pause(800), + click(at::CELL_DECK), + Pause(1000), + ], + ), + still("flashcard", with(review(), vec![key("space"), Pause(800)])), + // Practice. + still("practice", vec![rail(PRACTICE), Pause(1200)]), + still( + "graded", + vec![ + rail(PRACTICE), + Pause(1000), + click(at::PAST_ANSWER), + Pause(1200), + ], + ), + // Settings and background work. + still("settings", vec![click(at::SETTINGS), Pause(1000)]), + still( + "settings-appearance", + vec![ + click(at::SETTINGS), + Pause(800), + click(at::APPEARANCE), + Pause(800), + ], + ), + still( + "settings-processing", + vec![ + click(at::SETTINGS), + Pause(800), + click(at::PROCESSING), + Pause(1000), + ], + ), + still( + "settings-models", + vec![ + click(at::SETTINGS), + Pause(800), + click(at::MODELS), + Pause(1000), + ], + ), + still("activity", vec![rail(ACTIVITY), Pause(1200)]), + // A new project, after everything that reads the Projects sidebar: the draft + // session it leaves there moves the rows below it. + still( + "new-session", + vec![rail(HOME), Pause(600), click(at::NEW_SESSION), Pause(1000)], + ), + still( + "new-project", + vec![ + rail(HOME), + Pause(600), + click(at::NEW_PROJECT), + Pause(800), + Step::Type { text: "Genetics" }, + Pause(500), + ], + ) + .then(clear_composer()), + // These change the sample, so they come last. + clip( + "diagram-drag", + vec![ + rail(DIAGRAMS), + Pause(800), + click(at::CELL_DIAGRAM), + Pause(1200), + ], + vec![ + Pause(300), + Step::Drag { + from: at::CANVAS_FROM, + to: at::CANVAS_TO, + }, + Pause(1000), + ], + ), + clip( + "flashcard-flip", + review(), + vec![Pause(800), key("space"), Pause(1500), key("3"), Pause(1500)], + ), + clip( + "practice-answer", + vec![rail(PRACTICE), Pause(1200)], + vec![Pause(600), click(at::CHOICE_D), Pause(2800)], + ), + still("practice-feedback", vec![rail(PRACTICE), Pause(1200)]), + // The next question the answer asked for waits for a ChatGPT sign-in. + still("activity-waiting", vec![rail(ACTIVITY), Pause(1500)]), + ] +} + +/// What brings the app back to Home with nothing open, after any scene and its own undo. +fn reset() -> Vec { + vec![key("Escape"), key("Escape"), rail(HOME), Step::Pause(800)] +} + +/// The argument that makes only the scenes with a file missing, in every variant: what a +/// fresh checkout runs before building the site, so it never starts a desktop for nothing. +pub const MISSING: &str = "missing"; + +/// Makes every scene whose name is in `only` (all if it is empty, or those with a file +/// missing if it holds [`MISSING`]) in every variant whose name is in `only` (all if none +/// is), for the app at `app`. +pub fn run(repo: &Path, work: &Path, app: &Path, only: &[String]) -> Result<()> { + let variants: Vec = { + let named: Vec<_> = Variant::all() + .into_iter() + .filter(|variant| only.contains(&variant.to_string())) + .collect(); + if named.is_empty() { + Variant::all() + } else { + named + } + }; + let scenes: Vec = { + let named: Vec<_> = scenes() + .into_iter() + .filter(|scene| only.iter().any(|name| name == scene.name)) + .collect(); + if named.is_empty() { scenes() } else { named } + }; + let scenes: Vec = if only.iter().any(|argument| argument == MISSING) { + scenes + .into_iter() + .filter(|scene| { + Variant::all() + .into_iter() + .flat_map(|variant| scene.files(repo, variant)) + .any(|file| !file.is_file()) + }) + .collect() + } else { + scenes + }; + if scenes.is_empty() { + eprintln!("every docs capture is there"); + return Ok(()); + } + let mut total = 0; + for variant in variants { + total += make(repo, work, app, variant, &scenes)?; + } + eprintln!("wrote {total} bytes of docs media"); + Ok(()) +} + +/// Seeds a database, opens the app on it in `variant` and takes `scenes`; returns the +/// bytes written. +fn make(repo: &Path, work: &Path, app: &Path, variant: Variant, scenes: &[Scene]) -> Result { + eprintln!("{variant}: seeding"); + let database = crate::seed(&work.join("data"))?; + variant.save(&Database::open(&database)?)?; + let desktop = Desktop::start(repo, work, Screen::DOCS)?; + let mut app = desktop.launch(app, work)?; + desktop.wait_until_ready(&mut app, work)?; + crate::wait_for_indexing(&database, &mut app, work)?; + + let mut pointer = Screen::DOCS.start(); + let mut written = 0; + for scene in scenes { + let alive = |app: &mut desktop::Guard| app.check_running(work); + pointer = tour::play(&desktop, &scene.setup, pointer, || alive(&mut app))?; + let file = format!("{}-{variant}", scene.name); + let png = work.join(format!("{file}.png")); + written += match &scene.take { + Take::Still => { + desktop.capture(&png)?; + encode::still(&png, &output(repo, SHOTS, &file, "webp"), None)? + } + Take::Clip(steps) => { + let raw = work.join(format!("{file}.mkv")); + let mut recording = desktop.record(&raw, &work.join("recorder.log"))?; + thread::sleep(desktop::FIRST_FRAME); + recording.check_running()?; + let began = Instant::now(); + let played = tour::play(&desktop, steps, pointer, || alive(&mut app)); + let length = LEAD_IN + began.elapsed().as_secs_f64(); + let end = *played.as_ref().unwrap_or(&pointer); + recording.stop(&desktop, end)?; + pointer = played?; + app.check_running(work)?; + desktop.capture(&png)?; + let skip = desktop::FIRST_FRAME.as_secs_f64() - LEAD_IN; + encode::clip(&raw, &output(repo, CLIPS, &file, "mp4"), skip, length)? + + encode::still( + &png, + &output(repo, CLIPS, &file, "webp"), + Some(Screen::DOCS.width), + )? + } + }; + eprintln!("{variant}: {}", scene.name); + let undo = [scene.after.as_slice(), &reset()].concat(); + pointer = tour::play(&desktop, &undo, pointer, || alive(&mut app))?; + } + Ok(written) +} + +fn output(repo: &Path, dir: &str, file: &str, extension: &str) -> PathBuf { + repo.join(dir).join(format!("{file}.{extension}")) +} + +#[cfg(test)] +mod tests { + use std::{collections::HashSet, time::Duration}; + + use super::*; + + #[test] + fn every_scene_has_its_own_name() { + let scenes = scenes(); + let names: HashSet<_> = scenes.iter().map(|scene| scene.name).collect(); + assert_eq!(names.len(), scenes.len()); + assert!(names.iter().all(|name| { + name.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + })); + } + + #[test] + fn every_clip_is_short() { + for scene in scenes() { + if let Take::Clip(steps) = &scene.take { + let length = tour::estimate(steps, Screen::DOCS.start()); + assert!( + length >= Duration::from_secs(3), + "{}: {length:?}", + scene.name + ); + assert!( + length <= Duration::from_secs(8), + "{}: {length:?}", + scene.name + ); + } + } + } + + #[test] + fn every_variant_is_made_and_named_for_its_files() { + let names: Vec<_> = Variant::all().iter().map(ToString::to_string).collect(); + assert_eq!(names, ["en-light", "en-dark", "it-light", "it-dark"]); + } + + /// Every capture the website's source names, as (name, is a clip), from what it writes + /// to name one: `![…](shot:name …)` and `clip:` in the docs' Markdown, `shot: name` in + /// their frontmatter, `) { + const MARKERS: [(&str, bool); 6] = [ + ("](shot:", false), + ("](clip:", true), + ("\nshot: ", false), + (" Result { Ok(size) } +/// Encodes `length` seconds of `recording`, after its first `skip`, into the MP4 `output`, +/// and returns its size in bytes. The recorder writes a frame only when the screen changes, +/// so the recording stops at the last change: its last frame is held until `length`. +pub fn clip(recording: &Path, output: &Path, skip: f64, length: f64) -> Result { + let partial = partial_path(recording, output); + for quality in CLIP_QUALITIES { + let encoded = ffmpeg( + &clip_arguments(recording, &partial, skip, length, quality), + "cannot encode the clip", + ); + if let Err(error) = encoded { + std::fs::remove_file(&partial).ok(); + return Err(error); + } + let size = std::fs::metadata(&partial).map_or(0, |meta| meta.len()); + if size > 0 && size <= MAX_CLIP_BYTES { + return place(&partial, output, size); + } + } + std::fs::remove_file(&partial).ok(); + Err(Error::msg(format!( + "{} stays over {MAX_CLIP_BYTES} bytes at every quality: shorten its steps", + output.display() + ))) +} + +/// Encodes the PNG `png` into the lossless WebP `output`, `width` pixels wide (its own +/// width if `None`), and returns its size in bytes. +pub fn still(png: &Path, output: &Path, width: Option) -> Result { + let partial = partial_path(png, output); + let encoded = ffmpeg( + &still_arguments(png, &partial, width), + "cannot encode the still", + ); + let size = std::fs::metadata(&partial).map_or(0, |meta| meta.len()); + if encoded.is_err() || size == 0 { + std::fs::remove_file(&partial).ok(); + return Err(encoded + .err() + .unwrap_or_else(|| Error::msg("the still is empty"))); + } + place(&partial, output, size) +} + +/// Copies the finished `partial` file to `output`, removes it, and passes `size` on. +fn place(partial: &Path, output: &Path, size: u64) -> Result { + if let Some(parent) = output.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(partial, output)?; + std::fs::remove_file(partial).ok(); + Ok(size) +} + +fn clip_arguments( + recording: &Path, + output: &Path, + skip: f64, + length: f64, + quality: u32, +) -> Vec { + let mut args: Vec = vec!["-i".into(), recording.into()]; + args.extend( + [ + "-vf", + &clip_filter(skip, length), + "-t", + &format!("{length:.3}"), + "-c:v", + "libx264", + "-preset", + "slow", + "-crf", + &quality.to_string(), + "-movflags", + "+faststart", + "-an", + "-map_metadata", + "-1", + "-f", + "mp4", + ] + .map(Into::into), + ); + args.push(output.into()); + args +} + +/// The recording's last frame held for `length` (padding first: `tpad` after `fps` stops at +/// the last frame and adds nothing), then a steady frame rate made from its changes, its +/// first `skip` seconds cut, and the docs' width in the pixel format and video range every +/// browser decodes alike (the recording is full range). +fn clip_filter(skip: f64, length: f64) -> String { + format!( + "tpad=stop_mode=clone:stop_duration={length:.3},\ + fps={CLIP_FPS},trim=start={skip:.3},setpts=PTS-STARTPTS,\ + scale={CLIP_WIDTH}:-2:flags=lanczos:out_range=tv,format=yuv420p" + ) +} + +fn still_arguments(png: &Path, output: &Path, width: Option) -> Vec { + let mut args: Vec = vec!["-i".into(), png.into()]; + if let Some(width) = width { + args.extend([ + "-vf".into(), + format!("scale={width}:-2:flags=lanczos").into(), + ]); + } + args.extend( + [ + "-c:v", + "libwebp", + "-lossless", + "1", + "-compression_level", + "6", + "-map_metadata", + "-1", + "-f", + "webp", + ] + .map(Into::into), + ); + args.push(output.into()); + args +} + /// Runs FFmpeg with `args`, and fails with `what` and its exit status if it does. fn ffmpeg(args: &[OsString], what: &str) -> Result<()> { let status = Command::new("ffmpeg") @@ -68,7 +212,7 @@ fn ffmpeg(args: &[OsString], what: &str) -> Result<()> { } } -/// Where the GIF is written until it is known to be good: beside the recording. +/// Where a file is written until it is known to be good: beside its input. fn partial_path(recording: &Path, output: &Path) -> PathBuf { let mut name = output.file_name().unwrap_or_default().to_owned(); name.push(".partial"); @@ -174,10 +318,59 @@ mod tests { assert!(filter(0.2).contains("offset=0.000")); } + fn strings(args: &[OsString]) -> Vec { + args.iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect() + } + + #[test] + fn a_clip_plays_in_every_browser_and_starts_at_once() { + let args = strings(&clip_arguments( + Path::new("in.mkv"), + Path::new("out.mp4.partial"), + 0.5, + 4.0, + 27, + )); + let after = |name: &str| &args[args.iter().position(|arg| arg == name).unwrap() + 1]; + assert_eq!(after("-t"), "4.000"); + assert!(after("-vf").contains("trim=start=0.500")); + assert!(after("-vf").starts_with("tpad=stop_mode=clone")); + assert_eq!(after("-c:v"), "libx264"); + assert_eq!(after("-crf"), "27"); + assert_eq!(after("-movflags"), "+faststart"); + assert!(after("-vf").contains("out_range=tv,format=yuv420p")); + assert!(after("-vf").contains("fps=30")); + assert!(args.iter().any(|arg| arg == "-an"), "no sound"); + } + + #[test] + fn a_still_is_lossless_and_resized_only_when_asked() { + let full = strings(&still_arguments( + Path::new("a.png"), + Path::new("a.webp"), + None, + )); + assert!(full.windows(2).any(|pair| pair == ["-lossless", "1"])); + assert!(!full.iter().any(|arg| arg == "-vf")); + let poster = strings(&still_arguments( + Path::new("a.png"), + Path::new("a.webp"), + Some(1440), + )); + assert!(poster.iter().any(|arg| arg.starts_with("scale=1440:"))); + } + + #[test] + fn every_quality_is_tried_from_the_best() { + assert!(CLIP_QUALITIES.is_sorted()); + } + #[test] fn the_partial_file_sits_beside_the_recording() { assert_eq!( - partial_path(Path::new("work/tour.mkv"), Path::new("assets/demo.gif")), + partial_path(Path::new("work/tour.mkv"), Path::new("site/demo.gif")), Path::new("work/demo.gif.partial") ); } diff --git a/crates/study-showcase/src/lib.rs b/crates/study-showcase/src/lib.rs index e83dec83..5780c2c5 100644 --- a/crates/study-showcase/src/lib.rs +++ b/crates/study-showcase/src/lib.rs @@ -1,20 +1,26 @@ -//! Records the demo GIF in the README: `just demo`. +//! Records the demo GIF in the README (`just demo`) and the website's docs media +//! (`just docs-media`). Both are build output, not sources: the Landing workflow makes them +//! again on every deploy and publishes the GIF with the site, where the README shows it. //! //! The binary starts a private headless desktop (its own sway under `target/showcase`, never //! the shared one from `just desktop`), seeds a fresh database with the sample data, opens -//! the already-built `study` app on it and waits until it is ready. Then it records the -//! screen while [`tour`] plays a scripted visit, and [`encode`] turns the recording into a -//! looping GIF. No model runs and nobody signs in: every result on screen comes from the -//! seed. Everything it starts is stopped on every exit path. +//! the already-built `study` app on it and waits until it is ready. For the GIF it records +//! the screen while [`tour`] plays a scripted visit, and [`encode`] turns the recording into +//! a looping GIF. With `docs` as its first argument it makes the stills and clips of +//! [`docs`] instead, once per language and theme; any further arguments name the scenes or +//! variants (`it-dark`) to make, for a quicker loop, or ask only for the `missing` ones. No +//! model runs and nobody signs in: every result on screen comes from the seed. Everything it +//! starts is stopped on every exit path. //! //! Not `just showcase`, which converts a clip for a pull request: this makes the README's -//! GIF from nothing. +//! GIF and the docs' media from nothing. //! //! `main.rs` only calls [`run`]. #![allow(clippy::print_stderr)] // A command-line tool: printing is its output. pub mod desktop; +pub mod docs; pub mod encode; pub mod tour; @@ -29,18 +35,20 @@ use std::{ use study_core::{Context as _, Result}; /// Where the GIF goes when no path is given. -const DEFAULT_OUTPUT: &str = "assets/demo.gif"; +const DEFAULT_OUTPUT: &str = "apps/landing/public/demo.gif"; -/// Records the GIF: the one command-line argument, if any, is where it goes. +/// The first command-line argument that makes the docs' media instead of the GIF. +const DOCS: &str = "docs"; + +/// Records the GIF, whose path is the one command-line argument if there is one, or makes +/// the docs' media when that argument is `docs`. pub fn run() -> Result<()> { let repo = repo_root(); let work = repo.join("target/showcase"); let data = work.join("data"); isolate(&data, &work.join("cache"))?; - let output = std::env::args_os() - .nth(1) - .map_or_else(|| repo.join(DEFAULT_OUTPUT), PathBuf::from); + let arguments: Vec<_> = std::env::args_os().skip(1).collect(); let app = std::env::current_exe()? .parent() .context("the showcase binary has no directory")? @@ -52,8 +60,19 @@ pub fn run() -> Result<()> { ))); } + if arguments.first().is_some_and(|first| first == DOCS) { + let only: Vec = arguments[1..] + .iter() + .map(|argument| argument.to_string_lossy().into_owned()) + .collect(); + return docs::run(&repo, &work, &app, &only); + } + let output = arguments + .first() + .map_or_else(|| repo.join(DEFAULT_OUTPUT), PathBuf::from); + let database = seed(&data)?; - let desktop = desktop::Desktop::start(&repo, &work)?; + let desktop = desktop::Desktop::start(&repo, &work, desktop::Screen::DEMO)?; let mut app = desktop.launch(&app, &work)?; desktop.wait_until_ready(&mut app, &work)?; wait_for_indexing(&database, &mut app, &work)?; @@ -64,12 +83,13 @@ pub fn run() -> Result<()> { thread::sleep(desktop::FIRST_FRAME); recording.check_running()?; let steps = tour::tour(); + let start = desktop::Screen::DEMO.start(); eprintln!( "tour of about {:.0} s", - tour::estimate(&steps).as_secs_f64() + tour::estimate(&steps, start).as_secs_f64() ); - let played = tour::play(&desktop, &steps, || app.check_running(&work)); - recording.stop(&desktop)?; + let played = tour::play(&desktop, &steps, start, || app.check_running(&work)); + recording.stop(&desktop, start)?; played?; app.check_running(&work)?; // never encode what the app's exit cut short @@ -94,7 +114,7 @@ fn isolate(data: &Path, cache: &Path) -> Result<()> { } /// Fills a new database under `data` with the showcase sample, and returns its path. -fn seed(data: &Path) -> Result { +pub(crate) fn seed(data: &Path) -> Result { std::fs::remove_dir_all(data).ok(); let path = study_core::db::Database::default_path()?; study_seed::showcase(&study_core::db::Database::open(&path)?)?; @@ -105,7 +125,11 @@ fn seed(data: &Path) -> Result { /// Waits until the app has indexed what was seeded, so the search in the tour finds it. The /// database is the truth: the app queues the indexing at start and finishes it in the /// background. -fn wait_for_indexing(database: &Path, app: &mut desktop::Guard, work: &Path) -> Result<()> { +pub(crate) fn wait_for_indexing( + database: &Path, + app: &mut desktop::Guard, + work: &Path, +) -> Result<()> { use study_core::{JobKind, JobStatus}; let log = work.join("app.log"); let store = study_core::db::Database::open(database)?; diff --git a/crates/study-showcase/src/tour.rs b/crates/study-showcase/src/tour.rs index b16d001f..0466b4d3 100644 --- a/crates/study-showcase/src/tour.rs +++ b/crates/study-showcase/src/tour.rs @@ -1,17 +1,16 @@ -//! The scripted visit that is recorded: a list of [`Step`]s played like a person would, with a -//! pointer that glides, keys that are typed one at a time and a pause to read after each page. +//! The scripted visit that is recorded for the GIF: a list of [`Step`]s played like a person +//! would, with a pointer that glides, keys that are typed one at a time and a pause to read +//! after each page. The docs' scenes in [`crate::docs`] are made of the same steps. //! //! The tour is data so its pacing can be tested without a screen: [`estimate`] adds up how -//! long a tour takes, and the tests keep it a short GIF. Coordinates are pixels of the -//! recorded output, taken from screenshots of the private desktop. +//! long a tour takes, and the tests keep it a short GIF. Coordinates are logical pixels of +//! the output, taken from screenshots of the private desktop. use std::{thread, time::Duration}; use study_core::Result; -use crate::desktop::{ - Desktop, HEIGHT, PRESS_MS, RAIL_FIRST, RAIL_STEP, RAIL_WIDTH, RAIL_X, START, WIDTH, -}; +use crate::desktop::{Desktop, PRESS_MS, RAIL_FIRST, RAIL_STEP, RAIL_WIDTH, RAIL_X, Screen}; /// One thing the visitor does. #[derive(Debug, Clone, PartialEq)] @@ -20,6 +19,8 @@ pub enum Step { Hover { x: u32, y: u32 }, /// Glide to the point and press the left button. Click { x: u32, y: u32 }, + /// Glide to `from`, hold the left button, glide slowly to `to` and let go. + Drag { from: (u32, u32), to: (u32, u32) }, /// A key, held with the modifiers. Key { modifiers: &'static [Modifier], @@ -57,8 +58,11 @@ const HOP_MS: u64 = 25; const AIM_MS: u64 = 200; const KEY_MS: u64 = 250; +/// How long a drag takes from press to release. +const DRAG_MS: u64 = 1200; + /// A key on its own. -fn key(key: &'static str) -> Step { +pub(crate) fn key(key: &'static str) -> Step { Step::Key { modifiers: &[], key, @@ -66,12 +70,12 @@ fn key(key: &'static str) -> Step { } /// A key held with `modifiers`. -fn chord(modifiers: &'static [Modifier], key: &'static str) -> Step { +pub(crate) fn chord(modifiers: &'static [Modifier], key: &'static str) -> Step { Step::Key { modifiers, key } } /// A click on row `n` of the rail on the left, counting Home as 0. -fn rail(row: u32) -> Step { +pub(crate) fn rail(row: u32) -> Step { Step::Click { x: RAIL_X + RAIL_WIDTH / 2, y: RAIL_FIRST + row * RAIL_STEP, @@ -85,6 +89,8 @@ fn rail(row: u32) -> Step { /// of those starts a model job. pub fn tour() -> Vec { use Modifier::Ctrl; + let Screen { width, height, .. } = Screen::DEMO; + let start = Screen::DEMO.start(); use Step::{Click, Hover, Pause, Type}; let (home, projects, flashcards, diagrams, practice) = (0, 1, 3, 4, 5); vec![ @@ -103,8 +109,8 @@ pub fn tour() -> Vec { Click { x: 87, y: 516 }, Pause(400), Hover { - x: (WIDTH + RAIL_WIDTH) / 2, - y: HEIGHT * 2 / 5, + x: (width + RAIL_WIDTH) / 2, + y: height * 2 / 5, }, Pause(1200), // Due cards of the nearest exam: reveal and rate two with the keyboard. @@ -136,21 +142,22 @@ pub fn tour() -> Vec { Pause(700), // Back where the recording began, so the loop closes on its first frame. Hover { - x: START.0, - y: START.1, + x: start.0, + y: start.1, }, Pause(300), ] } -/// Plays `steps` on the desktop, starting from where the pointer is after the warm-up. -/// `still_running` is asked before every step, so a dead app stops the tour. +/// Plays `steps` on the desktop, starting with the pointer at `from`, and returns where the +/// pointer ends. `still_running` is asked before every step, so a dead app stops the tour. pub fn play( desktop: &Desktop, steps: &[Step], + from: (u32, u32), mut still_running: impl FnMut() -> Result<()>, -) -> Result<()> { - let mut pointer = START; +) -> Result<(u32, u32)> { + let mut pointer = from; for step in steps { still_running()?; match step { @@ -160,6 +167,20 @@ pub fn play( thread::sleep(Duration::from_millis(AIM_MS)); desktop.press()?; } + Step::Drag { from, to } => { + glide_to(desktop, &mut pointer, *from)?; + thread::sleep(Duration::from_millis(AIM_MS)); + desktop.button(true)?; + let path = drag(*from, *to); + let hop = Duration::from_millis(DRAG_MS / path.len() as u64); + for (x, y) in path { + thread::sleep(hop); + desktop.warp(x, y)?; + } + pointer = *to; + thread::sleep(Duration::from_millis(AIM_MS)); + desktop.button(false)?; + } Step::Key { modifiers, key } => { let args = wtype_key(modifiers, key); desktop.run( @@ -174,7 +195,7 @@ pub fn play( Step::Pause(ms) => thread::sleep(Duration::from_millis(*ms)), } } - Ok(()) + Ok(pointer) } fn glide_to(desktop: &Desktop, pointer: &mut (u32, u32), to: (u32, u32)) -> Result<()> { @@ -192,6 +213,17 @@ fn glide(from: (u32, u32), to: (u32, u32)) -> Vec<(u32, u32)> { let hops = (distance / 30.0) .clamp(1.0, GLIDE_MS as f64 / HOP_MS as f64) .round() as u32; + eased(from, to, hops) +} + +/// The points of a drag: eased like a glide, but in small even hops over [`DRAG_MS`], so +/// what is dragged follows the hand. +fn drag(from: (u32, u32), to: (u32, u32)) -> Vec<(u32, u32)> { + eased(from, to, (DRAG_MS / HOP_MS) as u32) +} + +/// `hops` points from `from` (left out) to `to`, slow at both ends. +fn eased(from: (u32, u32), to: (u32, u32), hops: u32) -> Vec<(u32, u32)> { (1..=hops) .map(|hop| { let t = f64::from(hop) / f64::from(hops); @@ -220,9 +252,9 @@ fn wtype_key(modifiers: &[Modifier], key: &str) -> Vec { args } -/// About how long `steps` take to play. -pub fn estimate(steps: &[Step]) -> Duration { - let mut at = START; +/// About how long `steps` take to play with the pointer starting at `from`. +pub fn estimate(steps: &[Step], from: (u32, u32)) -> Duration { + let mut at = from; let mut total = 0; for step in steps { total += match step { @@ -236,6 +268,11 @@ pub fn estimate(steps: &[Step]) -> Duration { 0 } } + Step::Drag { from, to } => { + let hops = glide(at, *from).len() as u64; + at = *to; + hops * HOP_MS + 2 * AIM_MS + DRAG_MS + } Step::Key { .. } => KEY_MS, Step::Type { text } => text.chars().count() as u64 * TYPING_MS, Step::Pause(ms) => *ms, @@ -256,6 +293,13 @@ mod tests { assert_eq!(glide((5, 5), (5, 5)), vec![(5, 5)]); } + #[test] + fn a_drag_ends_where_it_lets_go() { + let path = drag((100, 100), (400, 160)); + assert_eq!(path.last(), Some(&(400, 160))); + assert!(path.len() > 10, "a drag moves in small hops"); + } + #[test] fn keys_release_their_modifiers_in_reverse() { assert_eq!( @@ -269,7 +313,7 @@ mod tests { #[test] fn the_tour_makes_a_short_gif() { - let length = estimate(&tour()); + let length = estimate(&tour(), Screen::DEMO.start()); assert!(length >= Duration::from_secs(3), "{length:?}"); assert!(length <= Duration::from_secs(27), "{length:?}"); } diff --git a/crates/study-ui/Cargo.toml b/crates/study-ui/Cargo.toml index bc888883..999856b0 100644 --- a/crates/study-ui/Cargo.toml +++ b/crates/study-ui/Cargo.toml @@ -13,6 +13,7 @@ study-diagram.workspace = true [dev-dependencies] gpui-kit = { workspace = true, features = ["test-support"] } +image = { version = "0.25", default-features = false, features = ["png"] } [lints] workspace = true diff --git a/crates/study-ui/src/chrome.rs b/crates/study-ui/src/chrome.rs index 2931210e..638b7ca8 100644 --- a/crates/study-ui/src/chrome.rs +++ b/crates/study-ui/src/chrome.rs @@ -1,5 +1,6 @@ //! The window chrome both full windows share: a root painted in the title-bar color, the -//! title bar itself, and the rounded surface the content sits in, set apart by tone alone. The windows are +//! title bar itself and the size of the app's mark in it, and the rounded surface the +//! content sits in, set apart by tone alone. The windows are //! [`WorkspaceShell`](crate::WorkspaceShell) (`workspace.rs`) and //! [`OnboardingFrame`](crate::OnboardingFrame) (`onboarding.rs`). @@ -11,6 +12,10 @@ use crate::theme::unit; /// Corner radius of the inset content surface, in design pixels. pub(crate) const INSET_RADIUS: f32 = crate::scale::RADIUS_LG; +/// The app's mark in the title bar, at the title bar's glyph size (`DESIGN.md`, Shapes), in +/// design pixels. +pub(crate) const TITLE_BAR_MARK: f32 = 16.; + /// The window's root: a full-size column in the title-bar color. pub(crate) fn window_root(cx: &App) -> Div { let colors = cx.theme().colors; diff --git a/crates/study-ui/src/lib.rs b/crates/study-ui/src/lib.rs index 72331dbd..459288dd 100644 --- a/crates/study-ui/src/lib.rs +++ b/crates/study-ui/src/lib.rs @@ -7,6 +7,7 @@ //! |---|---| //! | `theme` | [`configure_theme`], [`set_appearance`], [`set_zoom`], [`ZOOM_RANGE`], [`scaled_px`], [`units`], [`scale`], [`palette()`], [`Palette`], [`float_shadow`]: colors, fonts, the size scales, zoom | //! | `icon` | [`icon()`]: Study's own SVG drawings, falling back to GPUI Kit's | +//! | `logo` | [`logo()`]: the app's mark, in full colour | //! | `button` | [`button()`], [`highlighter_button`], [`icon_button`]: the standard button sizes, and the one learning action a screen may fill with the highlighter | //! | `workspace` | [`WorkspaceShell`]: the main window | //! | `onboarding` | [`OnboardingFrame`]: the first-run tour's window | @@ -56,6 +57,7 @@ mod gallery; mod icon; mod ids; mod keys; +mod logo; mod menu; mod motion; mod navigation; @@ -76,6 +78,7 @@ pub use diagram_canvas::{DiagramCanvas, DiagramLabels}; pub use gallery::Gallery; pub use icon::icon; pub use keys::is_shortcut; +pub use logo::logo; pub use menu::{MenuItem, MenuSection}; pub use motion::{Rise, Shimmer}; pub use navigation::{NavigationRail, navigation_row}; diff --git a/crates/study-ui/src/logo.rs b/crates/study-ui/src/logo.rs new file mode 100644 index 00000000..06f9c858 --- /dev/null +++ b/crates/study-ui/src/logo.rs @@ -0,0 +1,142 @@ +//! Study's mark: the highlighter-yellow tile with the open book, in full colour. The drawings +//! are the masters in the repository's `assets/logo/`, which the README and the website use +//! too: the full mark from [`FULL_MARK_FROM`] up, and a simplified one, with thicker strokes +//! and no text lines, below it. The tile stays yellow in light and dark alike, because it is +//! the highlighter's own colour. +//! +//! Icons go through [`icon()`](crate::icon()), which draws an SVG as a mask tinted by the +//! text colour; a two-colour drawing can't. The mark is instead rasterised in colour by +//! GPUI's SVG renderer at exactly the device pixels it covers (its size at the current zoom +//! times the window's scale factor), so it stays crisp on any display, and each rasterised +//! size is kept for the next frame. + +use std::collections::HashMap; +use std::sync::Arc; + +use gpui_kit::{ + App, DevicePixels, Global, ImageSource, Img, RenderImage, Styled as _, SvgSize, img, size, +}; + +/// Below this design size the simplified drawing reads better than the full one. The +/// website's `Logo.astro` switches at the same size. +const FULL_MARK_FROM: f32 = 40.; + +/// Study's mark, `size` design pixels square at the current zoom. +pub fn logo(size: f32, cx: &App) -> Img { + let side = crate::theme::unit(cx, size); + let mark = if size < FULL_MARK_FROM { + Mark::Small + } else { + Mark::Full + }; + img(ImageSource::Custom(Arc::new(move |window, cx| { + let pixels = (f32::from(side) * window.scale_factor()).ceil() as i32; + Some(Ok(rasterised(mark, pixels.max(1), cx)?)) + }))) + .size(side) + .flex_none() +} + +/// Which of the two drawings. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum Mark { + Full, + Small, +} + +impl Mark { + fn svg(self) -> &'static [u8] { + match self { + Mark::Full => include_bytes!("../../../assets/logo/study.svg"), + Mark::Small => include_bytes!("../../../assets/logo/study-small.svg"), + } + } +} + +/// Every mark rasterised so far, by drawing and side in device pixels. +#[derive(Default)] +struct Rasterised(HashMap<(Mark, i32), Arc>); + +impl Global for Rasterised {} + +/// `mark` as an image `pixels` device pixels square, rasterised the first time it's asked +/// for. `None` only if a bundled drawing fails to parse, which a test rules out. +fn rasterised(mark: Mark, pixels: i32, cx: &mut App) -> Option> { + if let Some(image) = cx.default_global::().0.get(&(mark, pixels)) { + return Some(image.clone()); + } + let renderer = cx.svg_renderer(); + let side = size(DevicePixels(pixels), DevicePixels(pixels)); + let image = renderer + .parse_svg(mark.svg()) + .and_then(|svg| renderer.render_parsed(&svg, SvgSize::ExactSize(side))) + .ok()?; + cx.default_global::() + .0 + .insert((mark, pixels), image.clone()); + Some(image) +} + +#[cfg(test)] +mod tests { + use super::{Mark, rasterised}; + use gpui_kit::{DevicePixels, TestAppContext, size}; + + /// Both drawings rasterise at exactly the asked size, in colour: the tile's edge is the + /// highlighter yellow, not a tinted mask. + #[gpui_kit::test] + fn drawings_rasterise_in_colour(cx: &mut TestAppContext) { + for mark in [Mark::Full, Mark::Small] { + let image = cx + .update(|cx| rasterised(mark, 76, cx)) + .expect("the logo rasterises"); + assert!(image.size(0) == size(DevicePixels(76), DevicePixels(76))); + let bytes = image.as_bytes(0).expect("one frame"); + // Halfway down the left edge, inside the tile; GPUI stores BGRA. + let at = (38 * 76 + 2) * 4; + assert_eq!(&bytes[at..at + 4], &[0x4b, 0xd2, 0xf2, 0xff]); + } + } + + /// The installers' icon, `apps/desktop/packaging/icon.png`, is the full mark 448 pixels + /// square in the middle of 512, the margin macOS expects of an app icon. When the mark + /// changes this fails and writes the new icon, to copy over the old one. + #[gpui_kit::test] + fn the_installers_icon_is_the_mark(cx: &mut TestAppContext) { + const SIDE: u32 = 512; + const MARK: u32 = 448; + let mark = cx + .update(|cx| rasterised(Mark::Full, MARK as i32, cx)) + .expect("the logo rasterises"); + let bgra = mark.as_bytes(0).expect("one frame"); + let inset = (SIDE - MARK) / 2; + let mut expected = image::RgbaImage::new(SIDE, SIDE); + for (index, pixel) in (0..).zip(bgra.as_chunks::<4>().0) { + let rgba = image::Rgba([pixel[2], pixel[1], pixel[0], pixel[3]]); + expected.put_pixel(inset + index % MARK, inset + index / MARK, rgba); + } + + let icon = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../apps/desktop/packaging/icon.png"); + let committed = image::open(&icon) + .expect("the installers' icon is readable") + .to_rgba8(); + let close = |a: &image::Rgba, b: &image::Rgba| { + a.0.iter().zip(b.0).all(|(a, b)| a.abs_diff(b) <= 2) + }; + let same = committed.dimensions() == expected.dimensions() + && committed + .pixels() + .zip(expected.pixels()) + .all(|(a, b)| close(a, b)); + if !same { + let fresh = std::env::temp_dir().join("study-icon.png"); + expected.save(&fresh).expect("the new icon is written"); + panic!( + "{} is not the mark in assets/logo/study.svg: copy {} over it", + icon.display(), + fresh.display() + ); + } + } +} diff --git a/crates/study-ui/src/onboarding.rs b/crates/study-ui/src/onboarding.rs index 741ad6f2..860807d6 100644 --- a/crates/study-ui/src/onboarding.rs +++ b/crates/study-ui/src/onboarding.rs @@ -11,7 +11,7 @@ use gpui_kit::{ }; use crate::button::title_bar_sized; -use crate::chrome::{inset_surface, title_bar, window_root}; +use crate::chrome::{TITLE_BAR_MARK, inset_surface, title_bar, window_root}; use crate::ids; /// A full window for one step of a guided tour. @@ -252,7 +252,7 @@ fn stage(column: Div, cx: &App) -> impl IntoElement { .child(column) } -/// The title bar: the app's name, and the way out of the tour. +/// The title bar: the app's mark and name, and the way out of the tour. fn title_bar_row(brand: SharedString, skip: Option