From b22536eaf48a0f975c1988e76709fad8384489e7 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Wed, 15 Jul 2026 20:28:00 +0200 Subject: [PATCH 01/15] fix(ui): keep username draft stable while editing --- .../com/vnidrop/app/feature/settings/SettingsViewModel.kt | 7 +++---- .../kotlin/com/vnidrop/app/feature/ViewModelsTest.kt | 8 ++++++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt index a3b6a4f..7f450e2 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt @@ -101,18 +101,16 @@ class SettingsViewModel( val effectFlow = effects.receiveAsFlow() private var enableNotificationsAfterSettings = false private var usernamePersistJob: Job? = null + private var hasLocalUsernameDraft = false init { viewModelScope.launch { preferencesRepository.preferences.collect { preferences -> val previousFolder = _state.value.receiveFolder val receiveFolder = fileSystemService.effectiveReceiveFolder(preferences.receiveFolder) - // While the user is typing, keep the in-progress value. DataStore - // echoes can race keystrokes and trim trailing spaces mid-edit. - val editingUsername = usernamePersistJob?.isActive == true _state.update { current -> current.copy( - username = if (editingUsername) current.username else preferences.username, + username = if (hasLocalUsernameDraft) current.username else preferences.username, receiveFolder = receiveFolder, themeMode = preferences.themeMode, notificationsEnabled = preferences.notificationsEnabled, @@ -140,6 +138,7 @@ class SettingsViewModel( } fun setUsername(value: String) { + hasLocalUsernameDraft = true _state.update { it.copy(username = value) } usernamePersistJob?.cancel() usernamePersistJob = viewModelScope.launch { diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt index 661b136..c026019 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -90,7 +90,7 @@ class ViewModelsTest { } @Test - fun settingsUsernameKeepsSpacesWhileTypingAndPersistsAfterDebounce() = runTest { + fun settingsUsernameDraftIsNotOverwrittenByPersistedEcho() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) val preferences = preferences() val viewModel = settingsViewModel(preferences) @@ -104,7 +104,11 @@ class ViewModelsTest { testScheduler.advanceTimeBy(400) advanceUntilIdle() assertEquals("Ada", preferences.mutablePreferences.value.username) - assertEquals("Ada", viewModel.state.value.username) + assertEquals("Ada ", viewModel.state.value.username) + + preferences.setThemeMode(ThemeMode.Dark) + advanceUntilIdle() + assertEquals("Ada ", viewModel.state.value.username) } @Test From fbabb213f1fae241cade42073180e122818f8b19 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Wed, 15 Jul 2026 22:35:42 +0200 Subject: [PATCH 02/15] docs: establish project documentation and licensing --- CODE_OF_CONDUCT.md | 161 ++++++++++++++++++++ CONTRIBUTING.md | 167 +++++++++++++++++++++ LICENSE | 202 ++++++++++++++++++++++++++ README.md | 159 ++++++++++++++++---- SECURITY.md | 102 +++++++++++++ crates/vnidrop/Cargo.toml | 1 + services/diagnostics-api/package.json | 1 + 7 files changed, 767 insertions(+), 26 deletions(-) create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 SECURITY.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..55c0122 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,161 @@ +# VniDrop Code of Conduct + +## Our Pledge + +We pledge to make our community welcoming, safe, and equitable for all. + +We are committed to fostering an environment that respects and promotes the +dignity, rights, and contributions of all individuals, regardless of +characteristics including race, ethnicity, caste, color, age, physical +characteristics, neurodiversity, disability, sex or gender, gender identity or +expression, sexual orientation, language, philosophy or religion, national or +social origin, socio-economic position, level of education, or other status. +The same privileges of participation are extended to everyone who participates +in good faith and in accordance with this Code of Conduct. + +## Encouraged Behaviors + +While acknowledging differences in social norms, we all strive to meet our +community's expectations for positive behavior. We also understand that our +words and actions may be interpreted differently than we intend based on +culture, background, or native language. + +With these considerations in mind, we agree to behave mindfully toward each +other and act in ways that center our shared values, including: + +1. Respecting the purpose of our community, our activities, and our ways of + gathering. +2. Engaging kindly and honestly with others. +3. Respecting different viewpoints and experiences. +4. Taking responsibility for our actions and contributions. +5. Gracefully giving and accepting constructive feedback. +6. Committing to repairing harm when it occurs. +7. Behaving in other ways that promote and sustain the well-being of our + community. + +## Restricted Behaviors + +We agree to restrict the following behaviors in our community. Instances, +threats, and promotion of these behaviors are violations of this Code of +Conduct. + +1. **Harassment.** Violating explicitly expressed boundaries or engaging in + unnecessary personal attention after any clear request to stop. +2. **Character attacks.** Making insulting, demeaning, or pejorative comments + directed at a community member or group of people. +3. **Stereotyping or discrimination.** Characterizing anyone's personality or + behavior on the basis of immutable identities or traits. +4. **Sexualization.** Behaving in a way that would generally be considered + inappropriately intimate in the context or purpose of the community. +5. **Violating confidentiality.** Sharing or acting on someone's personal or + private information without their permission. +6. **Endangerment.** Causing, encouraging, or threatening violence or other + harm toward any person or group. +7. **Behaving in other ways that threaten the well-being of our community.** + +### Other Restrictions + +1. **Misleading identity.** Impersonating someone else for any reason, or + pretending to be someone else to evade enforcement actions. +2. **Failing to credit sources.** Not properly crediting the sources of content + you contribute. +3. **Promotional materials.** Sharing marketing or other commercial content in + a way that is outside the norms of the community. +4. **Irresponsible communication.** Failing to responsibly present content + which includes, links, or describes any other restricted behaviors. + +## Reporting an Issue + +Tensions can occur between community members even when they are trying their +best to collaborate. Not every conflict represents a Code of Conduct violation, +and this Code of Conduct reinforces encouraged behaviors and norms that can help +avoid conflicts and minimize harm. + +To report a possible violation, contact a VniDrop maintainer privately using the +contact information on their GitHub profile. Do not disclose sensitive details +in a public issue. If a report concerns a maintainer, contact a different +maintainer who is not involved in the incident. + +Include the relevant links, dates, context, and any supporting material you are +comfortable sharing. Community Moderators will take reports seriously and make +every effort to respond promptly. They will investigate reports by reviewing +available messages, logs, and other evidence, or by interviewing witnesses and +participants. They will keep investigation and enforcement actions as +transparent as possible while prioritizing safety and confidentiality. + +Enforcement actions are carried out privately with the involved parties, but +communicating to the whole community may be part of a mutually agreed-upon +resolution. + +## Addressing and Repairing Harm + +If an investigation finds that this Code of Conduct has been violated, the +following enforcement ladder may be used to determine how best to repair harm, +based on the incident's impact on the individuals involved and the community as +a whole. Depending on the severity of a violation, lower rungs on the ladder may +be skipped. + +### 1. Warning + +- **Event:** A violation involving a single incident or series of incidents. +- **Consequence:** A private, written warning from the Community Moderators. +- **Repair:** Examples include a private written apology, acknowledgement of + responsibility, and seeking clarification on expectations. + +### 2. Temporarily Limited Activities + +- **Event:** A repeated incidence of a violation that previously resulted in a + warning, or the first incidence of a more serious violation. +- **Consequence:** A private, written warning with a time-limited cooldown + period designed to underscore the seriousness of the situation and give the + community members involved time to process the incident. The cooldown period + may be limited to particular communication channels or interactions with + particular community members. +- **Repair:** Examples include making an apology, using the cooldown period to + reflect on actions and impact, and being thoughtful about re-entering + community spaces after the period is over. + +### 3. Temporary Suspension + +- **Event:** A pattern of repeated violations which the Community Moderators + have tried to address with warnings, or a single serious violation. +- **Consequence:** A private written warning with conditions for return from + suspension. In general, temporary suspensions give the person being suspended + time to reflect upon their behavior and possible corrective actions. +- **Repair:** Examples include respecting the spirit of the suspension, meeting + the specified conditions for return, and being thoughtful about how to + reintegrate with the community when the suspension is lifted. + +### 4. Permanent Ban + +- **Event:** A pattern of repeated Code of Conduct violations that other steps + on the ladder have failed to resolve, or a violation so serious that the + Community Moderators determine there is no way to keep the community safe + with this person as a member. +- **Consequence:** Access to all community spaces, tools, and communication + channels is removed. Permanent bans should be rarely used, have strong + reasoning behind them, and only be used if other remedies have failed to + change the behavior. +- **Repair:** There is no possible repair in cases of this severity. + +This enforcement ladder is a guideline. It does not limit the ability of +Community Moderators to use their discretion and judgment in the best interests +of the VniDrop community. + +## Scope + +This Code of Conduct applies within all VniDrop community spaces and also when +an individual is officially representing VniDrop in public or other spaces. +Examples of representation include using an official email address, posting via +an official social media account, or acting as an appointed representative at +an online or offline event. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant, version +3.0](https://www.contributor-covenant.org/version/3/0/). + +Contributor Covenant is stewarded by the Organization for Ethical Source. This +adapted Code of Conduct is licensed under [CC BY-SA +4.0](https://creativecommons.org/licenses/by-sa/4.0/). The enforcement ladder +was inspired by the work of Mozilla's code of conduct team. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..58aaf30 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,167 @@ +# Contributing to VniDrop + +Thank you for helping improve VniDrop. Contributions may include bug reports, +feature proposals, documentation, tests, design feedback, and code. + +By participating, you agree to follow the project +[Code of Conduct](CODE_OF_CONDUCT.md). + +## Before You Start + +- Search existing issues and pull requests before opening a duplicate. +- For a substantial feature or architecture change, open an issue first so the + approach and platform impact can be discussed. +- Keep each change focused. Avoid unrelated refactors, dependency upgrades, or + repository-wide formatting. +- Report suspected vulnerabilities through the private process in + [`SECURITY.md`](SECURITY.md), never in a public issue with technical details. +- Never include secrets, private tickets, file contents, key material, or + passphrases in an issue, log, test fixture, commit, or pull request. + +## Development Setup + +Clone the repository and create a branch from an up-to-date `master`: + +```bash +git clone https://github.com/vnidrop/vnidrop.git +cd vnidrop +git switch master +git pull --ff-only +git switch -c feat/short-description +``` + +Use a branch name that describes the outcome, such as +`feat/folder-share`, `fix/cancel-export-hang`, or `docs/contributing`. + +Install the tools needed for the area you plan to change: + +- JDK 17 or newer for Gradle and application builds +- Rust stable with `rustfmt` and Clippy for the transfer core +- Android SDK and NDK for Android builds +- Xcode on macOS for iOS builds and simulator tests +- Node.js 22.12 or newer for the optional diagnostics service + +The first Rust and Gradle builds may take several minutes while dependencies are +downloaded and native components are compiled. + +## Repository Structure + +| Path | Purpose | +|------|---------| +| `crates/vnidrop/` | Rust transfer core, persistence, approval, and streaming | +| `shared/` | Shared Kotlin Multiplatform UI and platform bridges | +| `androidApp/` | Android application shell | +| `iosApp/` | iOS application shell | +| `desktopApp/` | Desktop JVM application shell | +| `services/diagnostics-api/` | Optional Cloudflare diagnostics service | + +Read the nearest contributor guidance before editing: + +- [`AGENTS.md`](AGENTS.md) contains repository-wide engineering rules. +- [`crates/vnidrop/AGENTS.md`](crates/vnidrop/AGENTS.md) covers the Rust core. +- [`shared/AGENTS.md`](shared/AGENTS.md) covers Compose and Kotlin + Multiplatform work. + +## Engineering Expectations + +VniDrop follows a few important design constraints: + +- File transfer payloads are streamed by Rust and should not be routed through + the Kotlin heap as the primary design. +- Android directory sharing expands SAF trees into individual file descriptors; + directory file descriptors are not passed to Rust. +- Approval and access checks must not be weakened for convenience. +- Receive publishing must preserve the existing no-overwrite behavior. +- Locks and synchronous guards must not be held across Rust `.await` points. +- Bug fixes require a regression test at the lowest layer that demonstrates the + failure. + +Match the style of nearby code. Comments should explain non-obvious invariants, +platform constraints, concurrency behavior, or design decisions instead of +restating the code. + +## Testing + +Run checks from the repository root. Choose the suite for the files you changed. + +### Rust Core + +```bash +cargo fmt --all +cargo clippy --workspace --all-targets -- -D warnings +cargo test -p vnidrop +``` + +For cancel, export, or output-sink changes, also run: + +```bash +cargo test -p vnidrop --test output_sink +``` + +For broader core changes, run the complete workspace suite: + +```bash +cargo test --workspace --all-targets +``` + +### Shared Kotlin and Compose + +```bash +./gradlew :shared:jvmTest +``` + +Platform-specific checks may also be appropriate: + +```bash +./gradlew :shared:testAndroidHostTest +./gradlew :shared:iosSimulatorArm64Test +./gradlew :androidApp:assembleDebug +``` + +### Diagnostics Service + +```bash +cd services/diagnostics-api +npm ci +npm run check +``` + +If a required check cannot run on your machine, explain why in the pull request +and list the checks you did run. + +## Commits + +Use concise commit messages that describe the outcome. The repository commonly +uses Conventional Commit-style subjects: + +```text +feat(core): add folder transfer metadata +fix(ui): preserve receive progress after rotation +docs: clarify desktop setup +``` + +Create signed commits when your repository configuration requires signing. Do +not bypass a signing requirement with an unsigned commit. + +## Pull Requests + +Open pull requests against `master`. A good pull request should: + +1. Explain what changed and why. +2. Stay limited to one coherent outcome. +3. Link the relevant issue, when one exists. +4. Describe platform or compatibility implications. +5. Include regression coverage for bug fixes and behavior changes. +6. Provide an executable test plan with the exact commands or concrete manual + scenarios used for verification. +7. Avoid generated files, unrelated formatting, and dependency changes unless + they are required by the contribution. + +Review feedback is part of the collaboration process. Keep follow-up commits +focused, and resolve review threads only after the concern has been addressed. + +## Licensing + +VniDrop is distributed under the [Apache License 2.0](LICENSE). Unless explicitly +stated otherwise, contributions accepted into this repository are distributed +under the same license. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 49b0343..454ee63 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,143 @@ -This is a Kotlin Multiplatform project targeting Android, iOS, Desktop (JVM). +

+ VniDrop app icon +

-* [/iosApp](./iosApp/iosApp) contains an iOS application. Even if you’re sharing your UI with Compose Multiplatform, - you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project. +

VniDrop

-* [/shared](./shared/src) is for code that will be shared across your Compose Multiplatform applications. - It contains several subfolders: - - [commonMain](./shared/src/commonMain/kotlin) is for code that’s common for all targets. - - Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name. - For example, if you want to use Apple’s CoreCrypto for the iOS part of your Kotlin app, - the [iosMain](./shared/src/iosMain/kotlin) folder would be the right place for such calls. - Similarly, if you want to edit the Desktop (JVM) specific part, the [jvmMain](./shared/src/jvmMain/kotlin) - folder is the appropriate location. +

+ Send files directly. Stay in control of who receives them. +

-### Running the apps +

+ Cross-platform file transfer for Android, iOS, macOS, Windows, and Linux. +

-Use the run configurations provided by the run widget in your IDE's toolbar. You can also use these commands and -options: +

+ Rust core status + Shared KMP status + Early development + Apache 2.0 license +

-- Android app: `./gradlew :androidApp:assembleDebug` -- Desktop app: - - Hot reload: `./gradlew :desktopApp:hotRun --auto` - - Standard run: `./gradlew :desktopApp:run` -- iOS app: open the [/iosApp](./iosApp) directory in Xcode and run it from there. +VniDrop moves files and folders from one device to another without first +uploading them to a file-hosting service. Choose what to send, decide who may +receive it, and share a small invitation. The receiving device uses that +invitation to find the sender and request the files. -### Running tests +There is no account to create and no cloud copy of the transfer waiting after +you are done. The sender remains in control and can stop sharing at any time. -Use the run button in your IDE's editor gutter, or run tests using Gradle tasks: +## How a transfer works -- Android tests: `./gradlew :shared:testAndroidHostTest` -- Desktop tests: `./gradlew :shared:jvmTest` -- iOS tests: `./gradlew :shared:iosSimulatorArm64Test` +1. **Choose files or a folder.** VniDrop prepares the selection on the sender's + device and keeps the original folder structure. +2. **Create an invitation.** The app produces a small VniDrop invitation that + describes the transfer and how to reach the sender. Share it as a QR code, an + NFC tag, or a `.vnd` file. +3. **Connect to the sender.** The receiver opens the invitation. Iroh helps the + devices find each other and establishes an authenticated, end-to-end + encrypted connection. +4. **Request access.** By default, the sender sees who wants the transfer and + chooses whether to approve or refuse the request. +5. **Stream and verify.** After access is granted, `iroh-blobs` streams the files + and verifies their content while it arrives. VniDrop saves each file directly + to the chosen destination without replacing an existing file. +6. **Stay in control.** The sender can follow each receiver's progress, cancel a + transfer, or stop sharing so the invitation can no longer be used. ---- +Iroh tries to connect the devices directly, including across home routers and +mobile networks. If a direct path cannot be established, it can forward the +same end-to-end encrypted connection through a relay. The relay forwards +encrypted packets; it is not a VniDrop file store. -Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html)… +## Why Iroh and `iroh-blobs`? + +VniDrop combines a networking layer with its own sharing rules: + +| Layer | What it does | +|-------|--------------| +| [Iroh](https://docs.iroh.computer/) | Gives each device a secure identity, helps devices find one another, creates encrypted connections, and falls back to relays when a direct path is unavailable. | +| [`iroh-blobs`](https://docs.rs/iroh-blobs/0.103.0/iroh_blobs/) | Turns files into content-addressed, verified streams, so corrupted or unexpected data is detected while receiving. Multiple files are grouped into one collection. | +| **VniDrop** | Adds human-friendly invitations, receiver approval, per-transfer access rules, progress, history, cancellation, and safe saving on each operating system. | + +Content addressing is useful here because the invitation identifies exactly +what was shared. The receiver does not simply trust a filename or claimed size: +the incoming content must match its expected hash. + +## Approval is part of the transfer + +A VniDrop invitation helps two devices meet, but the default invitation is not +automatic permission to download. + +| Access mode | Behavior | +|-------------|----------| +| **Ask before each download** | The default. Every new receiver asks first, and the sender can approve or refuse the request. Approval gives that device temporary access to this transfer. | +| **Anyone with this transfer** | No interactive approval is required. Anyone holding the invitation may receive the files until the sender stops sharing. This mode is intended only for non-sensitive items. | + +VniDrop starts from a deny-by-default position: it serves only the content in an +active share, and only when that receiver's access mode allows it. Unknown +content requests are rejected. + +Treat an invitation like a private access link. Share it only with the intended +people, especially when using **Anyone with this transfer**. + +## What VniDrop supports + +- Individual files, multiple files, and complete folders +- QR codes, NFC tags, and portable `.vnd` invitation files +- Per-receiver requests, approvals, progress, and delivery status +- Cancel, stop sharing, and local transfer history +- Safe receive destinations that do not silently overwrite existing files +- Android, iOS, and desktop apps built from a shared Compose Multiplatform UI +- Opt-in diagnostics with transfer contents, invitations, and file paths + excluded + +## Privacy by design + +- **No hosted transfer copy.** VniDrop does not upload file contents to its + diagnostics service or a VniDrop storage bucket. +- **Encrypted in transit.** Iroh connections are authenticated and encrypted + end to end, including when a relay is needed. +- **Local control.** Transfer history and sharing state stay on the device. +- **Sensitive invitations.** An invitation can grant access, so it is + deliberately excluded from product logs and diagnostics. +- **Explicit access.** Approval is required by default, and stopping a share + removes access immediately. + +Please report suspected vulnerabilities through the private process in +[`SECURITY.md`](SECURITY.md), not through a public issue. + +## Project status + +VniDrop is in early development. The transfer engine, application experience, +and stored history format may change before a stable release. Build from source +if you want to try the current version. + +```bash +git clone https://github.com/vnidrop/vnidrop.git +cd vnidrop + +# Desktop +./gradlew :desktopApp:run + +# Android debug build +./gradlew :androidApp:assembleDebug + +# iOS +open iosApp/iosApp.xcodeproj +``` + +See [`CONTRIBUTING.md`](CONTRIBUTING.md) for prerequisites, development setup, +testing, and pull request guidance. + +## Learn more + +- [`crates/vnidrop/CORE_FLOW.md`](crates/vnidrop/CORE_FLOW.md) — protocol, + approval, durability, and file-handling details +- [`CONTRIBUTING.md`](CONTRIBUTING.md) — development and contribution guide +- [`SECURITY.md`](SECURITY.md) — security policy and private reporting +- [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md) — community standards + +## License + +VniDrop is available under the [Apache License 2.0](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..61f77eb --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,102 @@ +# VniDrop Security Policy + +VniDrop transfers files directly between devices and treats invitations as +capabilities. Security reports are taken seriously, especially when they affect +transfer authorization, file integrity, peer privacy, or the handling of local +files. + +## Supported Versions + +VniDrop is currently pre-release software and does not yet have tagged stable +releases. Security fixes are developed for the latest commit on `master`. + +| Version | Supported | +|---------|-----------| +| Latest `master` | Yes | +| Older commits and unofficial builds | No | + +Before reporting an issue, check whether it is reproducible on the latest +`master` when it is safe to do so. This table will be updated when versioned +releases are published. + +## Reporting a Vulnerability + +Do not disclose a suspected vulnerability, proof of concept, invitation ticket, +private file, or sensitive log in a public issue or discussion. + +To make a private report: + +1. Contact a VniDrop maintainer privately using the contact information on their + GitHub profile. +2. If no private contact method is available, open a public issue titled + **Security contact request**. Include no vulnerability details. A maintainer + will arrange a private channel for the report. + +Code of Conduct incidents should instead follow the private reporting process in +[`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md). + +### What to Include + +Provide as much of the following information as you safely can: + +- A concise description of the vulnerability and its potential impact +- The affected commit, platform, and application version +- Reproduction steps or a minimal proof of concept using test data +- Required access, permissions, or user interaction +- Relevant logs or screenshots with tickets, paths, endpoint identifiers, + credentials, and personal data redacted +- Any suggested mitigation or fix +- Whether the issue has been disclosed to anyone else + +Do not attach real user files or reusable invitation tickets. Generate isolated +test fixtures where possible. + +## Security-Sensitive Areas + +Reports are especially useful when they involve: + +- Bypassing transfer approval, access policy, or provider authorization +- Forging, leaking, replaying, or incorrectly accepting invitation tickets +- Serving blobs that are not registered for an active share +- Path traversal, symlink attacks, file overwrite, or unsafe temporary-file + publication +- Unsafe handling of Android file descriptors, SAF permissions, or iOS + security-scoped resources +- Remote code execution, memory-safety failures, or denial of service caused by + untrusted peer input +- Exposure of file contents, local paths, tickets, endpoint identifiers, + credentials, or other sensitive values through diagnostics or logs +- Authentication, authorization, or data-isolation failures in the diagnostics + service + +The transfer protocol and file-publication invariants are documented in +[`crates/vnidrop/CORE_FLOW.md`](crates/vnidrop/CORE_FLOW.md). + +## Coordinated Disclosure + +After receiving a report, maintainers will aim to: + +1. Confirm receipt and establish a private communication channel. +2. Reproduce and assess the issue, including affected platforms and versions. +3. Develop and verify a fix without weakening existing security boundaries. +4. Coordinate the release and public disclosure with the reporter. +5. Credit the reporter if they want public acknowledgement. + +Response and remediation times depend on severity and complexity. Please allow +maintainers a reasonable opportunity to investigate and release a fix before +publishing technical details. + +## Research Guidelines + +When investigating VniDrop: + +- Use devices, accounts, files, and peers that you own or have permission to + test. +- Minimize access to personal data and stop testing if you encounter data that + does not belong to you. +- Avoid privacy violations, service disruption, data destruction, and testing + that affects other users. +- Keep vulnerability details confidential until disclosure is coordinated. +- Follow applicable laws and the project [Code of Conduct](CODE_OF_CONDUCT.md). + +Thank you for helping keep VniDrop and its users safe. diff --git a/crates/vnidrop/Cargo.toml b/crates/vnidrop/Cargo.toml index 7c1e77c..9df0499 100644 --- a/crates/vnidrop/Cargo.toml +++ b/crates/vnidrop/Cargo.toml @@ -2,6 +2,7 @@ name = "vnidrop" version = "0.1.0" edition = "2021" +license = "Apache-2.0" [lib] name = "vnidrop" diff --git a/services/diagnostics-api/package.json b/services/diagnostics-api/package.json index 91a7635..d08a7d5 100644 --- a/services/diagnostics-api/package.json +++ b/services/diagnostics-api/package.json @@ -2,6 +2,7 @@ "name": "vnidrop-diagnostics-api", "private": true, "version": "0.1.0", + "license": "Apache-2.0", "type": "module", "engines": { "node": ">=22.12" From d2d3c45180238fabb42f5fd68a7e96a7fd6d98a7 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Wed, 15 Jul 2026 23:20:22 +0200 Subject: [PATCH 03/15] fix(core): make import cancellation test deterministic --- crates/vnidrop/tests/lifecycle.rs | 95 +++++++++++++++++++++---------- 1 file changed, 65 insertions(+), 30 deletions(-) diff --git a/crates/vnidrop/tests/lifecycle.rs b/crates/vnidrop/tests/lifecycle.rs index ca8a0a5..a6bdd5f 100644 --- a/crates/vnidrop/tests/lifecycle.rs +++ b/crates/vnidrop/tests/lifecycle.rs @@ -1,10 +1,13 @@ mod support; -use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::Duration; use support::{share_path, CoreGuard, RecordingSink, TestNode}; -use vnidrop::{CoreLimits, ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode}; +use vnidrop::{ + CoreEvent, CoreEventSink, CoreLimits, ShareMetadataInput, ShareSource, SourceKind, + TransferAccessMode, +}; #[test] fn share_creation_persists_selected_access_mode_atomically() { @@ -324,55 +327,87 @@ fn source_limit_rejection_creates_no_transfer_state() { #[test] fn cancellation_during_import_is_durable() { let source_dir = tempfile::tempdir().unwrap(); - let source_path = source_dir.path().join("large.bin"); - std::fs::File::create(&source_path) - .unwrap() - .set_len(256 * 1024 * 1024) - .unwrap(); - let sender = TestNode::new(); - let core = sender.core.arc(); + let source_path = source_dir.path().join("gated.bin"); + std::fs::write(&source_path, vec![0u8; 256 * 1024]).unwrap(); + let data_dir = tempfile::tempdir().unwrap(); + let import_gate = Arc::new(ImportStartedGate::default()); + let sender = CoreGuard::start(data_dir.path(), import_gate.clone()); + let core = sender.arc(); let worker = std::thread::spawn(move || { core.share_files( vec![ShareSource { kind: SourceKind::Path, value: source_path.to_string_lossy().to_string(), - display_name: Some("large.bin".to_string()), + display_name: Some("gated.bin".to_string()), is_directory: false, }], ShareMetadataInput { transfer_id: 25, - transfer_name: Some("large".to_string()), + transfer_name: Some("gated".to_string()), sender_name: None, access_mode: TransferAccessMode::ApprovalRequired, }, ) }); - let started = Instant::now(); - loop { - if sender - .core - .list_transfers() - .unwrap() - .iter() - .any(|transfer| transfer.transfer_id == 25 && transfer.status == "importing") - { - break; - } - assert!(started.elapsed() < Duration::from_secs(10)); - std::thread::sleep(Duration::from_millis(10)); - } - sender.core.cancel_transfer(25).unwrap(); + // The synchronous event callback holds the worker after active-transfer + // registration, so cancellation cannot race a fast import to completion. + import_gate.wait_until_blocked(); + let cancel_result = sender.cancel_transfer(25); + import_gate.release(); + cancel_result.unwrap(); assert!(worker.join().unwrap().is_err()); let transfer = sender - .core .list_transfers() .unwrap() .into_iter() .find(|transfer| transfer.transfer_id == 25) .unwrap(); assert_eq!(transfer.status, "cancelled"); - assert_eq!(sender.core.status().active_transfers, 0); - assert_eq!(sender.core.status().active_shares, 0); + assert_eq!(sender.status().active_transfers, 0); + assert_eq!(sender.status().active_shares, 0); +} + +#[derive(Default)] +struct ImportStartedGate { + state: Mutex, + changed: Condvar, +} + +#[derive(Default)] +struct ImportGateState { + blocked: bool, + released: bool, +} + +impl ImportStartedGate { + fn wait_until_blocked(&self) { + let state = self.state.lock().unwrap(); + let (state, _) = self + .changed + .wait_timeout_while(state, Duration::from_secs(5), |state| !state.blocked) + .unwrap(); + assert!(state.blocked, "import did not reach the start gate"); + } + + fn release(&self) { + let mut state = self.state.lock().unwrap(); + state.released = true; + self.changed.notify_all(); + } +} + +impl CoreEventSink for ImportStartedGate { + fn on_event(&self, event: CoreEvent) { + if event.phase != "import" || event.kind != "started" { + return; + } + let mut state = self.state.lock().unwrap(); + state.blocked = true; + self.changed.notify_all(); + while !state.released { + state = self.changed.wait(state).unwrap(); + } + } } From e40a4bfee258162c1922b7e88fb0e3c9073ddc82 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Wed, 15 Jul 2026 23:30:13 +0200 Subject: [PATCH 04/15] fix(app): standardize branding and disable telemetry --- androidApp/src/main/res/values/strings.xml | 2 +- .../src/main/kotlin/com/vnidrop/app/main.kt | 6 ++-- gradle.properties | 5 +-- iosApp/Configuration/Config.xcconfig | 4 +-- iosApp/iosApp.xcodeproj/project.pbxproj | 6 ++-- shared/build.gradle.kts | 8 ++--- .../app/diagnostics/DiagnosticsCoordinator.kt | 8 +++-- .../diagnostics/HttpDiagnosticsTransport.kt | 3 ++ .../app/diagnostics/TelemetryRecorder.kt | 25 ++++++++------ .../app/feature/settings/SettingsViewModel.kt | 3 +- .../app/diagnostics/DiagnosticsTest.kt | 34 +++++++++++++++++++ .../com/vnidrop/app/feature/ViewModelsTest.kt | 18 +++++++++- 12 files changed, 92 insertions(+), 30 deletions(-) diff --git a/androidApp/src/main/res/values/strings.xml b/androidApp/src/main/res/values/strings.xml index 6a46d21..2125c4f 100644 --- a/androidApp/src/main/res/values/strings.xml +++ b/androidApp/src/main/res/values/strings.xml @@ -1,3 +1,3 @@ - vnidrop + VniDrop diff --git a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt index 585b8d0..b9a56b6 100644 --- a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt +++ b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt @@ -77,7 +77,7 @@ fun main(args: Array) { Window( onCloseRequest = ::exitApplication, state = windowState, - title = "vnidrop", + title = "VniDrop", // Compose keeps edge resizers active for this client-decorated Linux window. undecorated = linux, ) { @@ -166,7 +166,7 @@ private fun WindowScope.MacOsTitleBar() { ) { Box(modifier = Modifier.fillMaxSize().padding(end = MacOsTrafficLightsWidth)) { BasicText( - text = "vnidrop", + text = "VniDrop", modifier = Modifier.align(Alignment.Center), style = TextStyle( color = colors.foregroundDefault, @@ -195,7 +195,7 @@ private fun WindowScope.LinuxTitleBar( .background(colors.backgroundSurface200), ) { BasicText( - text = "vnidrop", + text = "VniDrop", modifier = Modifier.align(Alignment.Center), style = TextStyle( color = colors.foregroundDefault, diff --git a/gradle.properties b/gradle.properties index 6b35196..34d6c2f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,8 +16,9 @@ android.useAndroidX=true # VniDrop: compile-time diagnostics/telemetry product surface. # false → no Share-diagnostics toggle, no telemetry or crash auto-upload stack. # Bug report UI remains available (user-initiated). -# Override per build: ./gradlew … -Pvnidrop.diagnostics.included=false -vnidrop.diagnostics.included=true +# Enable per build only when endpoint and ingest key are configured: +# ./gradlew … -Pvnidrop.diagnostics.included=true +vnidrop.diagnostics.included=false # Cloudflare Worker base URL (no trailing slash). Both endpoint/key empty → NoOp transport. # Example: https://vnidrop-diagnostics..workers.dev vnidrop.diagnostics.endpoint= diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index b2f475d..92bef3e 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -1,7 +1,7 @@ TEAM_ID= -PRODUCT_NAME=vnidrop +PRODUCT_NAME=VniDrop PRODUCT_BUNDLE_IDENTIFIER=com.vnidrop.app.vnidrop$(TEAM_ID) CURRENT_PROJECT_VERSION=1 -MARKETING_VERSION=1.0 \ No newline at end of file +MARKETING_VERSION=1.0 diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 02dd757..f4eaddb 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* Begin PBXFileReference section */ - FA325F1B4E7D8FFDF19A5C4A /* vnidrop.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = vnidrop.app; sourceTree = BUILT_PRODUCTS_DIR; }; + FA325F1B4E7D8FFDF19A5C4A /* VniDrop.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VniDrop.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -59,7 +59,7 @@ C13A067056BA9F38FD87A539 /* Products */ = { isa = PBXGroup; children = ( - FA325F1B4E7D8FFDF19A5C4A /* vnidrop.app */, + FA325F1B4E7D8FFDF19A5C4A /* VniDrop.app */, ); name = Products; sourceTree = ""; @@ -87,7 +87,7 @@ packageProductDependencies = ( ); productName = iosApp; - productReference = FA325F1B4E7D8FFDF19A5C4A /* vnidrop.app */; + productReference = FA325F1B4E7D8FFDF19A5C4A /* VniDrop.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 7b799cd..04cd882 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -41,12 +41,12 @@ plugins { // included=false: no Share-diagnostics toggle, no telemetry/crash auto-upload stack. // endpoint/key both empty: transport is NoOp (safe default until Cloudflare is deployed). val diagnosticsIncluded: Boolean = - (findProperty("vnidrop.diagnostics.included") as String?)?.toBooleanStrictOrNull() ?: true + (findProperty("vnidrop.diagnostics.included") as String?)?.toBooleanStrictOrNull() ?: false val diagnosticsEndpoint: String = (findProperty("vnidrop.diagnostics.endpoint") as String?)?.trim().orEmpty() val diagnosticsIngestKey: String = (findProperty("vnidrop.diagnostics.ingestKey") as String?)?.trim().orEmpty() -check(diagnosticsEndpoint.isEmpty() == diagnosticsIngestKey.isEmpty()) { +check(!diagnosticsIncluded || diagnosticsEndpoint.isEmpty() == diagnosticsIngestKey.isEmpty()) { "vnidrop.diagnostics.endpoint and vnidrop.diagnostics.ingestKey must be configured together" } @@ -56,8 +56,8 @@ val generateDiagnosticsBuildConfig by tasks.registering { description = "Generates DiagnosticsBuildConfig from vnidrop.diagnostics.* properties" val outputDir = diagnosticsBuildConfigDir val included = diagnosticsIncluded - val endpoint = diagnosticsEndpoint - val ingestKey = diagnosticsIngestKey + val endpoint = if (included) diagnosticsEndpoint else "" + val ingestKey = if (included) diagnosticsIngestKey else "" inputs.property("vnidrop.diagnostics.included", included) inputs.property("vnidrop.diagnostics.endpoint", endpoint) inputs.property("vnidrop.diagnostics.ingestKey", ingestKey) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsCoordinator.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsCoordinator.kt index 0173f63..4b3cd8c 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsCoordinator.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsCoordinator.kt @@ -18,13 +18,14 @@ class DiagnosticsCoordinator( val crashReporter: CrashReporter, val bugReports: BugReportService, private val scope: CoroutineScope, + private val included: Boolean = DiagnosticsBuildConfig.INCLUDED, ) { fun start() { // Install id is useful for bug-report correlation even without telemetry. scope.launch { preferencesRepository.ensureDiagnosticsInstallId() } - if (!DiagnosticsBuildConfig.INCLUDED) return + if (!included) return crashReporter.startObservingPreferences() crashReporter.installUnhandledExceptionHandler() scope.launch { @@ -33,7 +34,7 @@ class DiagnosticsCoordinator( } fun record(name: String, properties: Map = emptyMap()) { - if (!DiagnosticsBuildConfig.INCLUDED) return + if (!included) return telemetry.record(name, properties) } @@ -45,6 +46,7 @@ class DiagnosticsCoordinator( preferencesRepository: PreferencesRepository, scope: CoroutineScope, transport: DiagnosticsTransport = NoOpDiagnosticsTransport(), + included: Boolean = DiagnosticsBuildConfig.INCLUDED, ): DiagnosticsCoordinator { val breadcrumbs = BreadcrumbBuffer() val crashStore = createPendingCrashStore(appDataDir) @@ -53,6 +55,7 @@ class DiagnosticsCoordinator( transport = transport, breadcrumbs = breadcrumbs, scope = scope, + included = included, ) val crashReporter = CrashReporter( store = crashStore, @@ -78,6 +81,7 @@ class DiagnosticsCoordinator( crashReporter = crashReporter, bugReports = bugReports, scope = scope, + included = included, ) } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/HttpDiagnosticsTransport.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/HttpDiagnosticsTransport.kt index 193da04..14e68e4 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/HttpDiagnosticsTransport.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/HttpDiagnosticsTransport.kt @@ -119,6 +119,7 @@ fun createDiagnosticsTransport( platform: String, installIdProvider: suspend () -> String, ): DiagnosticsTransport = buildDiagnosticsTransport( + included = DiagnosticsBuildConfig.INCLUDED, endpoint = DiagnosticsBuildConfig.ENDPOINT, ingestKey = DiagnosticsBuildConfig.INGEST_KEY, appVersion = appVersion, @@ -127,12 +128,14 @@ fun createDiagnosticsTransport( ) internal fun buildDiagnosticsTransport( + included: Boolean = true, endpoint: String, ingestKey: String, appVersion: String, platform: String, installIdProvider: suspend () -> String, ): DiagnosticsTransport { + if (!included) return NoOpDiagnosticsTransport() val normalizedEndpoint = endpoint.trim() val normalizedIngestKey = ingestKey.trim() if (normalizedEndpoint.isEmpty() && normalizedIngestKey.isEmpty()) return NoOpDiagnosticsTransport() diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/TelemetryRecorder.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/TelemetryRecorder.kt index 8e04ad7..66b0e06 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/TelemetryRecorder.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/TelemetryRecorder.kt @@ -31,6 +31,7 @@ class TelemetryRecorder( private val flushIntervalMillis: Long = DefaultFlushIntervalMillis, private val retryBackoffMillis: Long = DefaultRetryBackoffMillis, private val automaticRetryCount: Int = DefaultAutomaticRetryCount, + private val included: Boolean = true, ) { private val bufferMutex = Mutex() private val state = AtomicReference(TelemetryState()) @@ -42,22 +43,24 @@ class TelemetryRecorder( require(flushIntervalMillis > 0) { "flushIntervalMillis must be positive" } require(retryBackoffMillis > 0) { "retryBackoffMillis must be positive" } require(automaticRetryCount >= 0) { "automaticRetryCount must not be negative" } - scope.launch { - preferencesRepository.preferences - .map { it.diagnosticsEnabled } - .distinctUntilChanged() - .collect { isEnabled -> - updateState { current -> - if (isEnabled) current.copy(enabled = true) else TelemetryState(enabled = false) + if (included) { + scope.launch { + preferencesRepository.preferences + .map { it.diagnosticsEnabled } + .distinctUntilChanged() + .collect { isEnabled -> + updateState { current -> + if (isEnabled) current.copy(enabled = true) else TelemetryState(enabled = false) + } + flushSignals.trySend(Unit) } - flushSignals.trySend(Unit) - } + } + scope.launch { runAutomaticFlushes() } } - scope.launch { runAutomaticFlushes() } } fun record(name: String, properties: Map = emptyMap()) { - if (!DiagnosticsBuildConfig.INCLUDED) return + if (!included) return val sanitizedName = sanitizeDiagnosticName(name) if (sanitizedName.isBlank()) return val sanitizedProperties = sanitizeDiagnosticProperties(properties) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt index 7f450e2..1cfc999 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt @@ -88,6 +88,7 @@ class SettingsViewModel( private val messages: UiMessageController, private val bugReports: BugReportService, private val diagnostics: DiagnosticsCoordinator? = null, + private val diagnosticsIncluded: Boolean = DiagnosticsBuildConfig.INCLUDED, ) : ViewModel() { private val _state = MutableStateFlow( SettingsState( @@ -200,7 +201,7 @@ class SettingsViewModel( } fun setDiagnosticsEnabled(enabled: Boolean) { - if (!DiagnosticsBuildConfig.INCLUDED) return + if (!diagnosticsIncluded) return viewModelScope.launch { preferencesRepository.setDiagnosticsEnabled(enabled) diagnostics?.record( diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTest.kt index c83dad3..0c9bf90 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTest.kt @@ -273,6 +273,19 @@ class DiagnosticsTest { assertIs(transport) } + @Test + fun excludedTransportIgnoresIncompleteConfiguration() { + val transport = buildDiagnosticsTransport( + included = false, + endpoint = "https://diag.example", + ingestKey = "", + appVersion = "1.0", + platform = "Test", + installIdProvider = { "id" }, + ) + assertIs(transport) + } + @Test fun noOpTransportReportsUnavailableDelivery() = runTest { val result = NoOpDiagnosticsTransport().sendEvents( @@ -329,6 +342,27 @@ class DiagnosticsTest { assertEquals(1, breadcrumbs.snapshot().size) } + @Test + fun excludedTelemetryDoesNotStartOrRecordEvents() = runTest { + val transport = RecordingDiagnosticsTransport() + val breadcrumbs = BreadcrumbBuffer() + val recorder = TelemetryRecorder( + preferencesRepository = fakePrefs(diagnosticsEnabled = true), + transport = transport, + breadcrumbs = breadcrumbs, + scope = TestScope(UnconfinedTestDispatcher(testScheduler)), + flushThreshold = 1, + included = false, + ) + + recorder.record("app_open") + advanceUntilIdle() + + assertEquals(0, recorder.pendingCount()) + assertTrue(transport.events.isEmpty()) + assertTrue(breadcrumbs.snapshot().isEmpty()) + } + @Test fun telemetryRetainsColdStartEventsUntilConsentLoads() = runTest { val backing = fakePrefs(diagnosticsEnabled = true) diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt index c026019..c2843d7 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -158,7 +158,7 @@ class ViewModelsTest { fun settingsTogglesDiagnosticsPreference() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) val preferences = preferences() - val viewModel = settingsViewModel(preferences) + val viewModel = settingsViewModel(preferences, diagnosticsIncluded = true) advanceUntilIdle() assertFalse(viewModel.state.value.diagnosticsEnabled) viewModel.setDiagnosticsEnabled(true) @@ -167,6 +167,20 @@ class ViewModelsTest { assertTrue(viewModel.state.value.diagnosticsEnabled) } + @Test + fun settingsIgnoresDiagnosticsOptInWhenExcluded() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val preferences = preferences() + val viewModel = settingsViewModel(preferences) + advanceUntilIdle() + + viewModel.setDiagnosticsEnabled(true) + advanceUntilIdle() + + assertFalse(preferences.mutablePreferences.value.diagnosticsEnabled) + assertFalse(viewModel.state.value.diagnosticsEnabled) + } + @Test fun settingsSubmitsBugReportAndClearsForm() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) @@ -647,6 +661,7 @@ class ViewModelsTest { notifications: FakeNotificationService = FakeNotificationService(), transport: DiagnosticsTransport = RecordingDiagnosticsTransport(), fileSystem: FakeFileSystemService = FakeFileSystemService(folder), + diagnosticsIncluded: Boolean = false, ) = SettingsViewModel( environment(), { DeviceInfo("Device", "Model", "OS", "Wi-Fi", "80%") }, @@ -662,6 +677,7 @@ class ViewModelsTest { platform = "Test", logReader = { "sample log line" }, ), + diagnosticsIncluded = diagnosticsIncluded, ) private fun receivedTransfer(id: ULong, status: TransferStatus) = Transfer( From d61d6f3c5ba035ea38639794942ac2157862172a Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Thu, 16 Jul 2026 16:38:30 +0200 Subject: [PATCH 05/15] feat(docs): add animated product website --- docs/.gitignore | 10 + docs/README.md | 22 + docs/app/base.css | 325 ++ docs/app/footer.css | 103 + docs/app/home.css | 701 +++ docs/app/icon.svg | 10 + docs/app/layout.tsx | 71 + docs/app/page.tsx | 292 ++ docs/app/privacy/page.tsx | 381 ++ docs/app/privacy/privacy-document.css | 660 +++ docs/app/privacy/privacy-responsive.css | 156 + docs/app/responsive.css | 322 ++ docs/components/approval-demo.tsx | 86 + docs/components/approval-scene.css | 267 + docs/components/brand.tsx | 65 + docs/components/hero-illustration.tsx | 216 + docs/components/hero-scene.css | 247 + docs/components/icons.tsx | 140 + docs/components/illustration-motion.css | 276 + docs/components/invitation-scene.css | 405 ++ docs/components/invitation-showcase.tsx | 153 + docs/components/network-illustration.tsx | 51 + docs/components/network-scene.css | 190 + docs/components/platform-orbit.tsx | 25 + docs/components/platform-scene.css | 103 + docs/components/reveal.tsx | 52 + docs/components/site-footer.tsx | 47 + docs/components/site-header.tsx | 71 + docs/components/transfer-flow.css | 278 + docs/components/transfer-flow.tsx | 108 + docs/eslint.config.mjs | 9 + docs/next-env.d.ts | 6 + docs/next.config.ts | 8 + docs/package-lock.json | 6082 ++++++++++++++++++++++ docs/package.json | 30 + docs/public/brand-mark.svg | 21 + docs/public/site.webmanifest | 16 + docs/tsconfig.json | 41 + 38 files changed, 12046 insertions(+) create mode 100644 docs/.gitignore create mode 100644 docs/README.md create mode 100644 docs/app/base.css create mode 100644 docs/app/footer.css create mode 100644 docs/app/home.css create mode 100644 docs/app/icon.svg create mode 100644 docs/app/layout.tsx create mode 100644 docs/app/page.tsx create mode 100644 docs/app/privacy/page.tsx create mode 100644 docs/app/privacy/privacy-document.css create mode 100644 docs/app/privacy/privacy-responsive.css create mode 100644 docs/app/responsive.css create mode 100644 docs/components/approval-demo.tsx create mode 100644 docs/components/approval-scene.css create mode 100644 docs/components/brand.tsx create mode 100644 docs/components/hero-illustration.tsx create mode 100644 docs/components/hero-scene.css create mode 100644 docs/components/icons.tsx create mode 100644 docs/components/illustration-motion.css create mode 100644 docs/components/invitation-scene.css create mode 100644 docs/components/invitation-showcase.tsx create mode 100644 docs/components/network-illustration.tsx create mode 100644 docs/components/network-scene.css create mode 100644 docs/components/platform-orbit.tsx create mode 100644 docs/components/platform-scene.css create mode 100644 docs/components/reveal.tsx create mode 100644 docs/components/site-footer.tsx create mode 100644 docs/components/site-header.tsx create mode 100644 docs/components/transfer-flow.css create mode 100644 docs/components/transfer-flow.tsx create mode 100644 docs/eslint.config.mjs create mode 100644 docs/next-env.d.ts create mode 100644 docs/next.config.ts create mode 100644 docs/package-lock.json create mode 100644 docs/package.json create mode 100644 docs/public/brand-mark.svg create mode 100644 docs/public/site.webmanifest create mode 100644 docs/tsconfig.json diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..d422c86 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,10 @@ +.next/ +out/ +.vercel/ +*.tsbuildinfo +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.env* +!.env.example diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..170405e --- /dev/null +++ b/docs/README.md @@ -0,0 +1,22 @@ +# VniDrop website + +The product website for VniDrop, built with Next.js and exported as a static site. + +## Local development + +```bash +npm install +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000). + +## Checks + +```bash +npm run lint +npm run typecheck +npm run build +``` + +The production build is written to `out/` and can be hosted by any static web server. diff --git a/docs/app/base.css b/docs/app/base.css new file mode 100644 index 0000000..f47fd3f --- /dev/null +++ b/docs/app/base.css @@ -0,0 +1,325 @@ +:root { + --brand-700: #6f27e9; + --brand-600: #842fee; + --brand-500: #a855f7; + --brand-400: #c084fc; + --brand-300: #d8b4fe; + --brand-200: #ead4ff; + --brand-100: #f5eaff; + --ink: #171419; + --ink-soft: #4f4854; + --ink-muted: #786f7d; + --paper: #fbfafc; + --paper-warm: #f7f4f8; + --surface: #ffffff; + --surface-soft: #f3eff5; + --line: #e5dfe8; + --line-strong: #d4cad8; + --dark: #17131a; + --dark-raised: #211a25; + --dark-line: #362c3c; + --success: #24a66b; + --warning: #f59e0b; + --font-sans: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-display: "Avenir Next", Avenir, "Segoe UI", ui-sans-serif, sans-serif; + --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + --shadow-sm: 0 8px 30px rgba(41, 25, 48, 0.07); + --shadow-lg: 0 30px 90px rgba(49, 25, 60, 0.14); + --shell: 1220px; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; + scroll-padding-top: 92px; +} + +body { + margin: 0; + overflow-x: hidden; + background: var(--paper); + color: var(--ink); + font-family: var(--font-sans); + font-size: 16px; + line-height: 1.6; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; +} + +::selection { + background: var(--brand-300); + color: #28132f; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +textarea, +select { + font: inherit; +} + +button, +a { + -webkit-tap-highlight-color: transparent; +} + +button { + color: inherit; +} + +svg { + display: block; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +h1, +h2, +h3 { + font-family: var(--font-display); + text-wrap: balance; +} + +p { + text-wrap: pretty; +} + +:focus-visible { + outline: 3px solid rgba(168, 85, 247, 0.55); + outline-offset: 3px; +} + +.page-shell { + width: min(calc(100% - 48px), var(--shell)); + margin-inline: auto; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.skip-link { + position: fixed; + z-index: 1000; + top: 12px; + left: 12px; + transform: translateY(-140%); + padding: 10px 16px; + border-radius: 10px; + background: var(--ink); + color: white; + font-weight: 700; + transition: transform 180ms ease; +} + +.skip-link:focus { + transform: translateY(0); +} + +.site-header { + position: fixed; + z-index: 100; + top: 16px; + right: 0; + left: 0; + width: min(calc(100% - 32px), 1260px); + margin-inline: auto; + border: 1px solid rgba(220, 212, 224, 0.82); + border-radius: 18px; + background: rgba(251, 250, 252, 0.82); + box-shadow: 0 12px 40px rgba(42, 26, 49, 0.08); + backdrop-filter: blur(20px) saturate(1.4); + -webkit-backdrop-filter: blur(20px) saturate(1.4); +} + +.header-inner { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + height: 64px; + padding: 0 10px 0 17px; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 10px; + width: max-content; + font-family: var(--font-display); + font-size: 19px; + font-weight: 750; + letter-spacing: -0.04em; +} + +.brand-mark-shell { + display: grid; + width: 38px; + height: 38px; + place-items: center; + border: 1px solid #eadcf1; + border-radius: 11px; + background: white; + box-shadow: 0 5px 15px rgba(105, 42, 145, 0.08); +} + +.brand-mark-shell svg { + width: 27px; + height: 27px; +} + +.brand-name { + transform: translateY(-1px); +} + +.desktop-nav { + display: flex; + align-items: center; + gap: 3px; + padding: 4px; + border-radius: 13px; + background: rgba(239, 235, 241, 0.7); +} + +.desktop-nav a { + padding: 7px 14px; + border-radius: 10px; + color: var(--ink-soft); + font-size: 13px; + font-weight: 650; + transition: background 160ms ease, color 160ms ease; +} + +.desktop-nav a:hover { + background: white; + color: var(--ink); +} + +.header-github { + justify-self: end; +} + +.menu-button { + display: none; + width: 42px; + height: 42px; + place-items: center; + border: 0; + border-radius: 11px; + background: var(--surface-soft); + cursor: pointer; +} + +.menu-button svg { + width: 21px; +} + +.mobile-nav { + display: none; +} + +.button { + display: inline-flex; + min-height: 52px; + align-items: center; + justify-content: center; + gap: 10px; + padding: 0 21px; + border: 1px solid transparent; + border-radius: 14px; + font-size: 14px; + font-weight: 730; + line-height: 1; + cursor: pointer; + transition: transform 180ms ease, box-shadow 180ms ease, background 180ms ease, + border-color 180ms ease; +} + +.button:hover { + transform: translateY(-2px); +} + +.button svg { + width: 18px; + height: 18px; +} + +.button-small { + min-height: 44px; + padding-inline: 16px; + border-radius: 11px; + font-size: 13px; +} + +.button-primary { + background: linear-gradient(135deg, var(--brand-500), var(--brand-700)); + box-shadow: 0 12px 28px rgba(132, 47, 238, 0.24), inset 0 1px rgba(255, 255, 255, 0.35); + color: white; +} + +.button-primary:hover { + box-shadow: 0 16px 34px rgba(132, 47, 238, 0.3), inset 0 1px rgba(255, 255, 255, 0.35); +} + +.button-ghost { + border-color: var(--line); + background: rgba(255, 255, 255, 0.72); + color: var(--ink); +} + +.button-ghost:hover { + border-color: var(--line-strong); + background: white; + box-shadow: var(--shadow-sm); +} + +.button-dark { + background: var(--ink); + color: white; +} + +.button-light { + background: white; + color: #211925; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18); +} + +.button-dark-ghost { + border-color: rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.06); + color: white; +} + +.motion-ready .reveal { + opacity: 0; + transform: translateY(28px); + transition: opacity 700ms cubic-bezier(0.2, 0.75, 0.25, 1), + transform 700ms cubic-bezier(0.2, 0.75, 0.25, 1); + transition-delay: var(--reveal-delay, 0ms); +} + +.motion-ready .reveal.is-visible { + opacity: 1; + transform: translateY(0); +} + diff --git a/docs/app/footer.css b/docs/app/footer.css new file mode 100644 index 0000000..e52838a --- /dev/null +++ b/docs/app/footer.css @@ -0,0 +1,103 @@ +.site-footer { + padding-top: 75px; + background: var(--paper); +} + +.footer-main { + display: grid; + grid-template-columns: 1fr auto; + gap: 60px; + padding-bottom: 70px; +} + +.footer-brand { + display: flex; + align-items: flex-start; + gap: 15px; +} + +.footer-mark { + display: grid; + width: 48px; + height: 48px; + flex: 0 0 auto; + place-items: center; + border: 1px solid var(--line); + border-radius: 14px; + background: white; +} + +.footer-mark svg { + width: 32px; + height: 32px; +} + +.footer-brand p { + margin-bottom: 0; + color: var(--ink-muted); + font-size: 13px; +} + +.footer-brand .footer-name { + margin-bottom: 3px; + color: var(--ink); + font-family: var(--font-display); + font-size: 21px; + font-weight: 750; + letter-spacing: -0.04em; +} + +.footer-links { + display: grid; + grid-template-columns: repeat(2, minmax(140px, 1fr)); + gap: 70px; +} + +.footer-links > div { + display: flex; + flex-direction: column; + gap: 9px; +} + +.footer-links .footer-label { + margin-bottom: 7px; + color: var(--ink-muted); + font-family: var(--font-mono); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.footer-links a { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--ink-soft); + font-size: 13px; + font-weight: 600; +} + +.footer-links a:hover { + color: var(--brand-600); +} + +.footer-links svg { + width: 13px; +} + +.footer-bottom { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 22px; + padding-bottom: 28px; + border-top: 1px solid var(--line); + color: var(--ink-muted); + font-size: 11px; +} + +.footer-bottom p { + margin: 0; +} + diff --git a/docs/app/home.css b/docs/app/home.css new file mode 100644 index 0000000..55d1bea --- /dev/null +++ b/docs/app/home.css @@ -0,0 +1,701 @@ +.hero-section { + position: relative; + min-height: 850px; + overflow: hidden; + padding: 150px 0 86px; + background: + radial-gradient(circle at 82% 18%, rgba(211, 165, 255, 0.24), transparent 28%), + radial-gradient(circle at 12% 76%, rgba(184, 108, 255, 0.12), transparent 25%), + linear-gradient(180deg, #fdfcfe 0%, #faf7fb 100%); +} + +.hero-section::before { + position: absolute; + inset: 0; + background-image: radial-gradient(rgba(93, 71, 102, 0.1) 0.7px, transparent 0.7px); + background-size: 19px 19px; + content: ""; + mask-image: linear-gradient(to bottom, black, transparent 82%); + opacity: 0.38; +} + +.hero-ambient { + position: absolute; + border: 1px solid rgba(168, 85, 247, 0.18); + border-radius: 999px; + pointer-events: none; +} + +.hero-ambient-one { + top: 120px; + right: -80px; + width: 420px; + height: 420px; + animation: ambient-drift 15s ease-in-out infinite alternate; +} + +.hero-ambient-two { + bottom: -190px; + left: -120px; + width: 400px; + height: 400px; + animation: ambient-drift 18s ease-in-out -5s infinite alternate-reverse; +} + +.hero-layout { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: minmax(0, 0.88fr) minmax(540px, 1.12fr); + align-items: center; + gap: 30px; +} + +.hero-copy { + position: relative; + z-index: 3; + padding-top: 12px; +} + +.eyebrow-badge { + display: inline-flex; + align-items: center; + gap: 9px; + margin-bottom: 25px; + padding: 7px 11px; + border: 1px solid #dfd4e4; + border-radius: 999px; + background: rgba(255, 255, 255, 0.72); + color: #665c6b; + font-family: var(--font-mono); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; +} + +.eyebrow-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--warning); + box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.13); +} + +.hero-copy h1 { + margin-bottom: 28px; + font-size: clamp(3.5rem, 5.1vw, 5.4rem); + font-weight: 760; + letter-spacing: -0.072em; + line-height: 0.98; +} + +.hero-copy h1 span { + background: linear-gradient(100deg, var(--brand-600), #ba62f8 58%, #7c2aef); + background-clip: text; + -webkit-background-clip: text; + color: transparent; +} + +.hero-lead { + max-width: 610px; + margin-bottom: 30px; + color: var(--ink-soft); + font-size: 18px; + line-height: 1.7; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 11px; +} + +.hero-platforms { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 14px; + margin-top: 35px; + color: var(--ink-muted); + font-size: 12px; +} + +.hero-platforms > span { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.hero-platforms ul { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin: 0; + padding: 0; + list-style: none; +} + +.hero-platforms li { + padding: 4px 8px; + border: 1px solid var(--line); + border-radius: 7px; + background: rgba(255, 255, 255, 0.65); +} + +.hero-art-reveal { + min-width: 0; +} + +.trust-section { + position: relative; + z-index: 3; + margin-top: -32px; +} + +.trust-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + overflow: hidden; + border: 1px solid var(--line); + border-radius: 23px; + background: rgba(255, 255, 255, 0.94); + box-shadow: var(--shadow-lg); + backdrop-filter: blur(12px); +} + +.trust-item { + display: flex; + min-height: 124px; + align-items: center; + gap: 19px; + padding: 24px 28px; +} + +.trust-item + .trust-item { + border-left: 1px solid var(--line); +} + +.trust-number { + color: var(--brand-500); + font-family: var(--font-mono); + font-size: 11px; + font-weight: 800; +} + +.trust-item strong { + display: block; + margin-bottom: 3px; + font-family: var(--font-display); + font-size: 18px; + letter-spacing: -0.025em; +} + +.trust-item p { + margin: 0; + color: var(--ink-muted); + font-size: 13px; + line-height: 1.45; +} + +.word-stream { + overflow: hidden; + padding: 92px 0 28px; + color: #ebe5ee; + font-family: var(--font-display); + font-size: clamp(3.5rem, 7vw, 7.6rem); + font-weight: 800; + letter-spacing: -0.055em; + line-height: 1; + white-space: nowrap; +} + +.word-stream > div { + display: flex; + width: max-content; + align-items: center; + gap: 35px; + animation: word-stream 36s linear infinite; +} + +.word-stream i { + color: var(--brand-300); + font-size: 0.18em; + font-style: normal; +} + +.section { + position: relative; + padding: 132px 0; +} + +.section-heading { + margin-bottom: 64px; +} + +.section-heading h2, +.privacy-intro h2, +.network-copy h2, +.platforms-copy h2, +.final-cta h2, +.approval-copy h2 { + margin-bottom: 20px; + font-size: clamp(2.7rem, 4.4vw, 4.8rem); + font-weight: 740; + letter-spacing: -0.06em; + line-height: 1.02; +} + +.section-heading p, +.section-lead { + color: var(--ink-soft); + font-size: 17px; + line-height: 1.72; +} + +.section-heading-centered { + max-width: 810px; + margin-right: auto; + margin-left: auto; + text-align: center; +} + +.section-heading-centered p { + max-width: 620px; + margin-right: auto; + margin-bottom: 0; + margin-left: auto; +} + +.section-heading-split { + display: grid; + grid-template-columns: 1.1fr 0.72fr; + align-items: end; + gap: 90px; +} + +.section-heading-split h2 { + margin-bottom: 0; +} + +.section-heading-split p { + margin-bottom: 6px; +} + +.kicker { + display: inline-block; + margin-bottom: 18px; + color: var(--brand-600); + font-family: var(--font-mono); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.13em; +} + +.kicker-light { + color: #d89bff; +} + +.flow-section { + padding-top: 105px; +} + +.invitation-section { + overflow: hidden; + background: var(--paper-warm); +} + +.approval-section { + padding: 150px 0; +} + +.approval-layout { + display: grid; + grid-template-columns: minmax(0, 0.86fr) minmax(480px, 1.14fr); + align-items: center; + gap: 100px; +} + +.approval-copy h2 { + max-width: 620px; +} + +.approval-copy .section-lead { + max-width: 570px; + margin-bottom: 27px; +} + +.check-list { + display: grid; + gap: 13px; + margin: 0 0 25px; + padding: 0; + list-style: none; +} + +.check-list li { + display: flex; + align-items: center; + gap: 11px; + color: #3c3540; + font-size: 14px; + font-weight: 600; +} + +.check-list svg { + width: 20px; + height: 20px; + padding: 4px; + border-radius: 50%; + background: var(--brand-100); + color: var(--brand-600); + stroke-width: 2.5; +} + +.approval-note { + max-width: 555px; + margin: 0; + padding: 15px 17px; + border: 1px solid #eadff0; + border-radius: 12px; + background: #faf7fb; + color: var(--ink-muted); + font-size: 12px; + line-height: 1.55; +} + +.approval-demo-wrap { + position: relative; +} + +.demo-hint { + position: absolute; + right: 22px; + bottom: -45px; + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--brand-600); + font-family: var(--font-mono); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.07em; + transform: rotate(-2deg); +} + +.demo-hint svg { + width: 15px; +} + +.privacy-principles-section { + position: relative; + overflow: hidden; + padding: 145px 0 135px; + background: + radial-gradient(circle at 90% 8%, rgba(168, 85, 247, 0.18), transparent 25%), + radial-gradient(circle at 5% 90%, rgba(124, 42, 239, 0.12), transparent 25%), + var(--dark); + color: white; +} + +.privacy-principles-section::after { + position: absolute; + inset: 0; + background-image: radial-gradient(rgba(255, 255, 255, 0.12) 0.7px, transparent 0.7px); + background-size: 22px 22px; + content: ""; + opacity: 0.13; + pointer-events: none; +} + +.privacy-principles-section .page-shell { + position: relative; + z-index: 1; +} + +.privacy-intro { + max-width: 890px; + margin-bottom: 65px; +} + +.privacy-intro h2 { + max-width: 840px; +} + +.privacy-intro > p { + max-width: 700px; + margin-bottom: 0; + color: #bdb4c2; + font-size: 18px; + line-height: 1.72; +} + +.privacy-point-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; +} + +.privacy-point { + min-height: 275px; + padding: 24px; + border: 1px solid var(--dark-line); + border-radius: 20px; + background: rgba(255, 255, 255, 0.035); + transition: border-color 200ms ease, transform 200ms ease, background 200ms ease; +} + +.privacy-point:hover { + transform: translateY(-5px); + border-color: rgba(192, 132, 252, 0.45); + background: rgba(255, 255, 255, 0.055); +} + +.privacy-point-top { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 43px; +} + +.privacy-point-top > span { + display: grid; + width: 44px; + height: 44px; + place-items: center; + border: 1px solid rgba(192, 132, 252, 0.28); + border-radius: 12px; + background: rgba(168, 85, 247, 0.1); + color: var(--brand-400); +} + +.privacy-point-top svg { + width: 22px; +} + +.privacy-point-top small { + color: #706775; + font-family: var(--font-mono); + font-size: 10px; +} + +.privacy-point h3 { + margin-bottom: 10px; + font-size: 19px; + letter-spacing: -0.025em; +} + +.privacy-point p { + margin-bottom: 0; + color: #a99fac; + font-size: 13px; + line-height: 1.65; +} + +.privacy-policy-link { + display: flex; + align-items: center; + justify-content: space-between; + gap: 30px; + margin-top: 38px; + padding-top: 27px; + border-top: 1px solid var(--dark-line); +} + +.privacy-policy-link p { + margin: 0; + color: #a99fac; + font-size: 13px; +} + +.privacy-policy-link a { + display: inline-flex; + align-items: center; + gap: 10px; + color: #dca7ff; + font-size: 14px; + font-weight: 750; +} + +.privacy-policy-link svg { + width: 18px; + transition: transform 180ms ease; +} + +.privacy-policy-link a:hover svg { + transform: translateX(4px); +} + +.network-section { + padding: 150px 0; + background: #0f0c11; + color: white; +} + +.network-layout { + display: grid; + grid-template-columns: minmax(0, 0.78fr) minmax(580px, 1.22fr); + align-items: center; + gap: 75px; +} + +.network-copy h2 { + font-size: clamp(2.6rem, 4vw, 4.2rem); +} + +.network-copy > p { + margin-bottom: 35px; + color: #aaa0ae; + font-size: 16px; + line-height: 1.75; +} + +.network-copy blockquote { + position: relative; + margin: 0; + padding: 20px 0 20px 25px; + border-left: 2px solid var(--brand-500); + color: #f0e9f3; + font-family: var(--font-display); + font-size: 20px; + font-weight: 630; + line-height: 1.45; +} + +.network-copy blockquote span { + position: absolute; + top: -14px; + left: 12px; + color: var(--brand-500); + font-family: Georgia, serif; + font-size: 44px; + opacity: 0.55; +} + +.platforms-section { + overflow: hidden; + padding: 155px 0; +} + +.platforms-layout { + display: grid; + grid-template-columns: minmax(0, 0.86fr) minmax(520px, 1.14fr); + align-items: center; + gap: 75px; +} + +.platforms-copy .section-lead { + max-width: 570px; + margin-bottom: 29px; +} + +.platform-feature-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.platform-feature-list span { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 11px; + border: 1px solid var(--line); + border-radius: 9px; + background: white; + color: var(--ink-soft); + font-size: 12px; + font-weight: 620; +} + +.platform-feature-list svg { + width: 16px; + color: var(--brand-600); +} + +.orbit-wrap { + min-width: 0; +} + +.final-cta-section { + padding: 0 0 40px; +} + +.final-cta { + position: relative; + overflow: hidden; + padding: 100px 50px; + border-radius: 31px; + background: + linear-gradient(125deg, rgba(255, 255, 255, 0.06), transparent 40%), + linear-gradient(135deg, #25172f, #171019 55%, #28143a); + color: white; + text-align: center; +} + +.final-cta::after { + position: absolute; + inset: 0; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: inherit; + content: ""; + pointer-events: none; +} + +.final-cta h2 { + position: relative; + z-index: 1; + max-width: 850px; + margin-right: auto; + margin-left: auto; + font-size: clamp(2.8rem, 5vw, 5.4rem); +} + +.final-cta > p { + position: relative; + z-index: 1; + max-width: 600px; + margin: 0 auto 31px; + color: #bfb4c4; + font-size: 16px; +} + +.final-cta > .kicker { + position: relative; + z-index: 1; +} + +.final-cta-actions { + position: relative; + z-index: 1; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 10px; +} + +.final-cta-orb { + position: absolute; + border-radius: 50%; + background: var(--brand-500); + filter: blur(90px); + opacity: 0.18; +} + +.final-cta-orb-one { + top: -140px; + left: -70px; + width: 380px; + height: 380px; + animation: cta-orb 12s ease-in-out infinite alternate; +} + +.final-cta-orb-two { + right: -80px; + bottom: -200px; + width: 430px; + height: 430px; + animation: cta-orb 15s ease-in-out -4s infinite alternate-reverse; +} + diff --git a/docs/app/icon.svg b/docs/app/icon.svg new file mode 100644 index 0000000..eaf36b9 --- /dev/null +++ b/docs/app/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/docs/app/layout.tsx b/docs/app/layout.tsx new file mode 100644 index 0000000..1ce3158 --- /dev/null +++ b/docs/app/layout.tsx @@ -0,0 +1,71 @@ +import type { Metadata, Viewport } from "next"; +import type { ReactNode } from "react"; +import { SiteFooter } from "@/components/site-footer"; +import { SiteHeader } from "@/components/site-header"; +import "./base.css"; +import "./home.css"; +import "./footer.css"; +import "./privacy/privacy-document.css"; +import "../components/hero-scene.css"; +import "../components/transfer-flow.css"; +import "../components/invitation-scene.css"; +import "../components/approval-scene.css"; +import "../components/network-scene.css"; +import "../components/platform-scene.css"; +import "./responsive.css"; +import "./privacy/privacy-responsive.css"; +import "../components/illustration-motion.css"; + +export const metadata: Metadata = { + title: { + default: "VniDrop — Direct file transfer, on your terms", + template: "%s · VniDrop", + }, + description: + "Send files and folders directly across Android, iOS, macOS, Windows, and Linux—with approval by default and no hosted transfer copy.", + applicationName: "VniDrop", + manifest: "/site.webmanifest", + keywords: [ + "peer-to-peer file transfer", + "encrypted file sharing", + "cross-platform file transfer", + "open source", + ], + openGraph: { + type: "website", + siteName: "VniDrop", + title: "VniDrop — Direct file transfer, on your terms", + description: + "Move files from your device to theirs, with no account and no hosted transfer copy.", + }, + twitter: { + card: "summary", + title: "VniDrop — Direct file transfer, on your terms", + description: + "Move files from your device to theirs, with no account and no hosted transfer copy.", + }, +}; + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#fbfafc" }, + { media: "(prefers-color-scheme: dark)", color: "#17131a" }, + ], +}; + +export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) { + return ( + + + + Skip to content + + + {children} + + + + ); +} diff --git a/docs/app/page.tsx b/docs/app/page.tsx new file mode 100644 index 0000000..98d936b --- /dev/null +++ b/docs/app/page.tsx @@ -0,0 +1,292 @@ +import Link from "next/link"; +import { ApprovalDemo } from "@/components/approval-demo"; +import { HeroIllustration } from "@/components/hero-illustration"; +import { Icon, type IconName } from "@/components/icons"; +import { InvitationShowcase } from "@/components/invitation-showcase"; +import { NetworkIllustration } from "@/components/network-illustration"; +import { PlatformOrbit } from "@/components/platform-orbit"; +import { Reveal } from "@/components/reveal"; +import { TransferFlow } from "@/components/transfer-flow"; + +const trustItems = [ + { value: "No account", detail: "Open the app and start sharing" }, + { value: "No hosted copy", detail: "Your transfer is not parked in our cloud" }, + { value: "No silent overwrite", detail: "Existing files stay untouched" }, +]; + +const privacyPoints: Array<{ + icon: IconName; + number: string; + title: string; + text: string; +}> = [ + { + icon: "lock", + number: "01", + title: "Encrypted in transit", + text: "Each connection is authenticated and end-to-end encrypted, including through a relay.", + }, + { + icon: "shield", + number: "02", + title: "Approval by default", + text: "An invitation helps devices meet. It is not automatic permission to download.", + }, + { + icon: "verified", + number: "03", + title: "Verified on arrival", + text: "Content addressing checks that incoming bytes match exactly what was offered.", + }, + { + icon: "stop", + number: "04", + title: "Revocable access", + text: "Stop a share at any time and the invitation can no longer be used.", + }, +]; + +export default function HomePage() { + return ( +
+
+
+ +
+
+ {trustItems.map((item, index) => ( + + 0{index + 1} +
+ {item.value} +

{item.detail}

+
+
+ ))} +
+
+ + + +
+
+ + ONE DROP, FIVE MOMENTS +

A handoff that stays in your hands.

+

+ VniDrop keeps the transfer understandable from first selection to final verified file. +

+
+ + + +
+
+ +
+
+ +
+ MAKE THE INTRODUCTION +

One transfer. Three ways to meet.

+
+

+ The invitation is small enough to scan, tap, or send. Your files stay where they are + until a receiver connects and you allow the transfer. +

+
+ + + +
+
+ +
+
+ + CONTROL IS BUILT IN +

A link is not permission.

+

+ Every new receiver knocks by default. Approve people you recognize, refuse the rest, + and follow progress for each device. +

+
    +
  • See the receiver before anything moves
  • +
  • Cancel a transfer already in progress
  • +
  • Stop sharing to revoke future access
  • +
+

+ Need an open handoff? An optional “Anyone with this transfer” mode is available for + non-sensitive files. +

+
+ + + Try the controls + +
+
+ +
+
+ + PRIVACY BY DESIGN +

The file never becomes our file.

+

+ VniDrop coordinates a secure handoff. It does not turn your transfer into a hosted + download sitting on someone else’s storage. +

+
+
+ {privacyPoints.map((point, index) => ( + +
+ + {point.number} +
+

{point.title}

+

{point.text}

+
+ ))} +
+ +

Diagnostics are optional and exclude invitations, file paths, and transfer contents.

+ + Read the full privacy policy + + +
+
+
+ +
+
+ + DIRECT WHEN POSSIBLE +

The shortest safe path, chosen automatically.

+

+ VniDrop tries to connect devices directly—even across home routers and mobile + networks. If that path is not available, a relay forwards the same encrypted + connection without becoming a file store. +

+
+ + The relay can move the envelope. It cannot open it. +
+
+ + + +
+
+ +
+
+ + ONE FAMILIAR HANDOFF +

At home on every screen.

+

+ Android, iOS, macOS, Windows, and Linux share one clear transfer experience while each + platform keeps control of its own file picker and save destination. +

+
+ Cross-platform by design + Native file destinations + Open-source core +
+
+ + + +
+
+ +
+
+ +
+
+ ); +} diff --git a/docs/app/privacy/page.tsx b/docs/app/privacy/page.tsx new file mode 100644 index 0000000..298a4d3 --- /dev/null +++ b/docs/app/privacy/page.tsx @@ -0,0 +1,381 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { BrandMark } from "@/components/brand"; +import { Icon, type IconName } from "@/components/icons"; +import { Reveal } from "@/components/reveal"; + +export const metadata: Metadata = { + title: "Privacy policy", + description: + "How VniDrop handles transfers, local app data, optional diagnostics, bug reports, and website visits.", +}; + +const summaries: Array<{ icon: IconName; label: string; value: string }> = [ + { icon: "devices", label: "Transfers", value: "Device to device" }, + { icon: "globe", label: "Accounts", value: "None" }, + { icon: "shield", label: "Diagnostics", value: "Optional" }, + { icon: "folder", label: "Hosted file copy", value: "None" }, +]; + +const sections = [ + ["scope", "Scope"], + ["transfers", "Transfers"], + ["local-data", "Local data"], + ["diagnostics", "Diagnostics"], + ["website", "Website"], + ["permissions", "Permissions"], + ["providers", "Service providers"], + ["retention", "Retention"], + ["choices", "Your choices"], + ["security", "Security"], + ["changes", "Changes"], + ["contact", "Contact"], +]; + +export default function PrivacyPage() { + return ( +
+
+
+ +
+
+ {summaries.map((summary, index) => ( + + + {summary.label} + {summary.value} + + ))} +
+
+ +
+
+ + +
+
+ +
+ The short version +

+ VniDrop has no user accounts and does not upload your transfer to a VniDrop file + store. Files travel over an authenticated, end-to-end encrypted connection. + Product diagnostics are opt-in; a bug report is sent only when you submit one. +

+
+
+ +
+ 01 +

Scope and who “VniDrop” means

+

+ This policy covers the official VniDrop website, the VniDrop applications for + Android, iOS, macOS, Windows, and Linux, and the diagnostics service configured by + the official project. In this policy, “VniDrop,” “we,” and “us” refer to the + maintainers of the official VniDrop project and the official builds they distribute. +

+

+ VniDrop is open-source software. A build distributed or operated by someone else + may use different networking infrastructure, diagnostics settings, or website + hosting. That distributor is responsible for explaining its own practices. +

+
+ +
+ 02 +

What happens during a transfer

+

File contents

+

+ The sender chooses files or folders on their device. VniDrop streams those bytes to + an approved receiver and does not first upload them to a VniDrop-hosted storage + bucket. The receiver saves the files to a destination they choose. Relayed traffic + remains end-to-end encrypted. +

+

Invitations and transfer metadata

+

+ A QR code, NFC tag, or .vnd file contains a transfer invitation. The + invitation includes connection and content identifiers plus transfer metadata such + as the transfer name, optional sender name, creation time, file count, and total + size. It is a capability: anyone who receives it may be able to request the transfer + while the share is active. Treat it like a private access link. +

+

What peers and relays can see

+

+ A receive request can disclose the receiver’s chosen display or device name, + application version, and a technical endpoint identifier to the sender. A direct + connection exposes the peers’ IP addresses to one another. When a public relay is + used, its operator can observe connection metadata such as source and destination IP + addresses, connection time, and the amount of relayed data, but cannot read the + encrypted transfer contents. +

+
+ +

+ Approval is required by default. If the sender selects “Anyone with this transfer,” + anyone holding the invitation may receive the files until sharing stops. +

+
+
+ +
+ 03 +

Information kept on your device

+

VniDrop stores the information needed to operate the app locally, including:

+
    +
  • device identity and networking keys used to establish secure connections;
  • +
  • active shares, transfer history, receiver requests, progress, and status;
  • +
  • app preferences, including access and diagnostics choices;
  • +
  • download destinations and locally managed transfer data; and
  • +
  • an anonymous installation identifier used only for diagnostics correlation.
  • +
+

+ This information remains until you remove the relevant history, stop or delete a + share, clear the app’s data, or uninstall the app, subject to operating-system file + behavior. Removing VniDrop history does not delete a file you already downloaded; + delete that file through your operating system if you no longer want it. +

+
+ +
+ 04 +

Optional diagnostics and bug reports

+

Automatic product diagnostics

+

+ When an official build includes diagnostics, automatic usage events and crash + reports are disabled until you enable “Share diagnostics.” If enabled, VniDrop may + send an anonymous installation ID, app version, platform, sparse event names and + properties, crash type and message, a redacted stack trace, timestamps, and recent + in-app breadcrumbs. You can turn this off at any time; doing so also removes pending + local crash reports. +

+

User-submitted bug reports

+

+ A bug report is separate from the diagnostics toggle and is sent only when you press + submit. It can contain what you say happened, what you expected, reproduction steps, + an optional contact email, app and platform versions, an anonymous installation ID, + device name and model, operating system, network and battery information, recent + breadcrumbs, and optional recent logs. You can exclude logs before submitting. +

+

Data deliberately excluded

+

+ Automatic diagnostics are designed to exclude transfer contents, invitations, and + file paths. Before diagnostic text or optional logs are sent, VniDrop applies rules + intended to redact invitation tokens, endpoint identifiers, absolute paths, file and + content URIs, and platform document identifiers. No redaction system is perfect, so + review anything you type into a bug report and avoid including secrets. +

+
+ +
+ 05 +

The VniDrop website

+

+ This website is a static product site. It does not provide an account, contact form, + advertising, behavioral analytics, marketing pixels, or non-essential cookies. It + does not ask the browser for access to your files, camera, contacts, location, or + nearby devices. +

+

+ The hosting and security infrastructure may process routine request information—such + as IP address, time, requested page, referrer, and browser user agent—to deliver the + site, maintain reliability, and prevent abuse. The live hosting provider must be + identified in this policy before public deployment if it differs from the providers + described below. +

+
+ +
+ 06 +

Device permissions

+
+
Files & folders

Choose what to send and where received files are saved.

+
Camera / scanner

Scan a QR invitation when you choose that receive method.

+
NFC

Read or write an invitation through a compatible NFC tag.

+
Network & notifications

Connect peers and alert you to background receiver requests.

+
+

+ VniDrop requests a platform permission only for the related feature. On Android, QR + scanning may be provided through Google Play services Code Scanner. Platform-level + permission prompts and service-provider terms also apply. +

+
+ +
+ 07 +

Infrastructure and external services

+
+
+ I +
Iroh / public relay operators

Device discovery, connection establishment, and encrypted relay fallback. Relays process connection metadata but cannot decrypt transfer contents.

+
+
+ C +
Cloudflare

The project’s diagnostics design uses Cloudflare Workers, D1, and R2. Cloudflare also processes source IPs for request delivery and abuse controls.

+
+
+ G +
Google Play services

May provide the QR code scanner on supported Android devices when you choose to scan an invitation.

+
+
+ GH +
GitHub

Hosts the source repository, issue tracker, and external pages linked from this site. GitHub’s own privacy terms apply after you follow those links.

+
+
+

+ Provider policies: {" "} + Iroh, {" "} + Cloudflare, {" "} + Google, and {" "} + GitHub. +

+
+ +
+ 08 +

Retention and deletion

+
+
+ DataTypical retention +
+
+ Transfer history and settingsUntil you delete them, clear app data, or uninstall +
+
+ Pending local crash reportsUp to 30 days and 20 reports; deleted when diagnostics is disabled +
+
+ Server diagnostics and bug reportsThe current project configuration is 90 days, with scheduled deletion +
+
+ Downloaded filesUntil you delete them through your operating system +
+
+

+ Operational backups, provider logs, and deletion backlogs may persist briefly beyond + the stated period where necessary for security, integrity, or legal obligations. If + the production diagnostics retention configuration changes, this policy should be + updated to match it. +

+
+ +
+ 09 +

Your choices and rights

+
    +
  • Enable or disable “Share diagnostics” in VniDrop settings.
  • +
  • Submit a bug report only when you choose, omit contact information, and exclude logs.
  • +
  • Approve or refuse each receiver, cancel a transfer, or stop sharing.
  • +
  • Delete individual transfer history or clear completed, failed, and cancelled receive history.
  • +
  • Delete downloaded files using your operating system, or clear all app data by uninstalling or resetting the app.
  • +
+

+ Depending on where you live, privacy law may provide rights to access, correct, + delete, restrict, or object to processing of personal information. Because VniDrop + has no account and automatic diagnostics use an anonymous installation ID, we may + not be able to connect a server record to you without additional information. Use + the contact method below and provide only what is needed to locate your submission. +

+
+ +
+ 10 +

Security

+

+ VniDrop uses authenticated end-to-end encrypted connections, content verification, + deny-by-default share access, bounded diagnostics payloads, redaction, and safe file + publishing that avoids silently replacing an existing file. No system can guarantee + absolute security. Keep invitations private, verify receiver names, keep your device + updated, and stop sharing when a transfer is finished. +

+

+ Please report a suspected vulnerability through the private process in the {" "} + VniDrop security policy, not in a public issue. +

+
+ +
+ 11 +

Changes to this policy

+

+ VniDrop is in early development. Features and data practices may change. When this + policy changes, we will update the effective date and version at the top of the page + and publish the revised text with the project. Material changes should be called out + in release notes or the application where practical. +

+
+ +
+ 12 +

Contact

+

+ VniDrop is currently maintained as an open-source project and does not yet publish a + dedicated privacy email or postal address. For a privacy question or request, open a + request in the project issue tracker. Do not put an invitation, file content, + credentials, or other sensitive information in a public issue. +

+ + Contact the maintainers + + +
+
+
+
+
+ ); +} diff --git a/docs/app/privacy/privacy-document.css b/docs/app/privacy/privacy-document.css new file mode 100644 index 0000000..c722f99 --- /dev/null +++ b/docs/app/privacy/privacy-document.css @@ -0,0 +1,660 @@ +.privacy-page { + background: #fcfbfd; +} + +.privacy-hero { + position: relative; + overflow: hidden; + padding: 165px 0 105px; + background: + radial-gradient(circle at 80% 20%, rgba(192, 132, 252, 0.2), transparent 30%), + linear-gradient(180deg, #fdfcfe, #f7f3f9); +} + +.privacy-hero-grid { + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(114, 84, 128, 0.055) 1px, transparent 1px), + linear-gradient(90deg, rgba(114, 84, 128, 0.055) 1px, transparent 1px); + background-size: 32px 32px; + mask-image: linear-gradient(to bottom, black, transparent 90%); +} + +.privacy-hero-layout { + position: relative; + display: grid; + grid-template-columns: minmax(0, 0.92fr) minmax(430px, 0.72fr); + align-items: center; + gap: 95px; +} + +.privacy-back { + display: flex; + width: max-content; + align-items: center; + gap: 8px; + margin-bottom: 42px; + color: var(--ink-muted); + font-size: 12px; + font-weight: 650; +} + +.privacy-hero-copy > .kicker { + display: block; + width: max-content; +} + +.privacy-back svg { + width: 16px; + transform: rotate(180deg); + transition: transform 180ms ease; +} + +.privacy-back:hover { + color: var(--brand-600); +} + +.privacy-back:hover svg { + transform: rotate(180deg) translateX(4px); +} + +.privacy-hero-copy h1 { + margin: 0 0 25px; + font-size: clamp(3.7rem, 6.2vw, 6.5rem); + font-weight: 760; + letter-spacing: -0.075em; + line-height: 0.98; +} + +.privacy-hero-copy > p { + max-width: 680px; + margin-bottom: 28px; + color: var(--ink-soft); + font-size: 18px; + line-height: 1.72; +} + +.privacy-meta { + display: flex; + align-items: center; + gap: 12px; + color: var(--ink-muted); + font-family: var(--font-mono); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.privacy-meta i { + width: 3px; + height: 3px; + border-radius: 50%; + background: var(--brand-500); +} + +.privacy-hero-art { + position: relative; + min-height: 435px; +} + +.privacy-art-orbit { + position: absolute; + top: 50%; + left: 50%; + border: 1px solid rgba(168, 85, 247, 0.18); + border-radius: 50%; + transform: translate(-50%, -50%); +} + +.privacy-art-orbit::before, +.privacy-art-orbit::after { + position: absolute; + border-radius: 50%; + background: var(--brand-400); + content: ""; +} + +.privacy-art-orbit-one { + width: 420px; + height: 420px; + animation: privacy-orbit 28s linear infinite; +} + +.privacy-art-orbit-one::before { + top: 11%; + left: 16%; + width: 7px; + height: 7px; + box-shadow: 0 0 0 7px rgba(168, 85, 247, 0.09); +} + +.privacy-art-orbit-one::after { + right: 8%; + bottom: 25%; + width: 5px; + height: 5px; +} + +.privacy-art-orbit-two { + width: 295px; + height: 295px; + border-style: dashed; + animation: privacy-orbit 21s linear infinite reverse; +} + +.privacy-art-orbit-two::before { + top: 48%; + left: -4px; + width: 7px; + height: 7px; +} + +.privacy-art-orbit-two::after { + top: 4%; + right: 22%; + width: 5px; + height: 5px; +} + +.privacy-mark-card { + position: absolute; + z-index: 2; + top: 50%; + left: 50%; + display: grid; + width: 180px; + height: 180px; + place-items: center; + border: 1px solid #dfd2e6; + border-radius: 45px; + background: rgba(255, 255, 255, 0.91); + box-shadow: 0 35px 90px rgba(65, 30, 80, 0.17); + transform: translate(-50%, -50%) rotate(-3deg); + backdrop-filter: blur(14px); +} + +.privacy-mark-card > svg { + position: relative; + z-index: 2; + width: 112px; + height: 112px; +} + +.privacy-mark-glow { + position: absolute; + inset: -35px; + border-radius: 50%; + background: rgba(168, 85, 247, 0.2); + filter: blur(42px); + animation: privacy-glow 5s ease-in-out infinite; +} + +.privacy-mark-lock { + position: absolute; + right: -12px; + bottom: -10px; + display: grid; + width: 49px; + height: 49px; + place-items: center; + border: 4px solid #f8f4fa; + border-radius: 15px; + background: var(--ink); + box-shadow: 0 12px 30px rgba(42, 25, 50, 0.2); + color: white; + transform: rotate(3deg); +} + +.privacy-mark-lock svg { + width: 21px; +} + +.privacy-data-pill { + position: absolute; + z-index: 4; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 8px 11px; + border: 1px solid #dfd3e4; + border-radius: 10px; + background: rgba(255, 255, 255, 0.9); + box-shadow: 0 12px 35px rgba(58, 33, 69, 0.11); + color: var(--brand-600); + font-family: var(--font-mono); + font-size: 8px; + font-weight: 800; + letter-spacing: 0.07em; + backdrop-filter: blur(10px); + animation: privacy-pill 5s ease-in-out infinite; +} + +.privacy-data-pill svg { + width: 15px; +} + +.privacy-data-pill-one { + top: 11%; + left: 4%; + transform: rotate(-5deg); +} + +.privacy-data-pill-two { + top: 29%; + right: -2%; + transform: rotate(4deg); + animation-delay: -2s; +} + +.privacy-data-pill-three { + bottom: 6%; + left: 7%; + transform: rotate(3deg); + animation-delay: -3.5s; +} + +.privacy-summary { + position: relative; + z-index: 4; + margin-top: -37px; +} + +.privacy-summary-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + overflow: hidden; + border: 1px solid var(--line); + border-radius: 22px; + background: rgba(255, 255, 255, 0.95); + box-shadow: var(--shadow-lg); + backdrop-filter: blur(12px); +} + +.privacy-summary-card { + display: grid; + min-height: 151px; + align-content: center; + padding: 25px 27px; +} + +.privacy-summary-card + .privacy-summary-card { + border-left: 1px solid var(--line); +} + +.privacy-summary-card > span { + display: grid; + width: 37px; + height: 37px; + margin-bottom: 17px; + place-items: center; + border-radius: 10px; + background: var(--brand-100); + color: var(--brand-600); +} + +.privacy-summary-card svg { + width: 19px; +} + +.privacy-summary-card small { + margin-bottom: 1px; + color: var(--ink-muted); + font-family: var(--font-mono); + font-size: 8px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.privacy-summary-card strong { + font-family: var(--font-display); + font-size: 16px; + letter-spacing: -0.02em; +} + +.privacy-document-section { + padding: 135px 0 155px; +} + +.privacy-document-layout { + display: grid; + grid-template-columns: 210px minmax(0, 780px); + justify-content: center; + gap: 105px; +} + +.privacy-toc { + position: sticky; + top: 112px; + align-self: start; +} + +.privacy-toc > p { + margin-bottom: 17px; + color: var(--ink); + font-family: var(--font-mono); + font-size: 9px; + font-weight: 800; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.privacy-toc nav { + display: flex; + flex-direction: column; +} + +.privacy-toc a { + display: grid; + grid-template-columns: 26px 1fr; + align-items: center; + gap: 6px; + padding: 7px 0; + color: #79707e; + font-size: 11px; + font-weight: 600; + transition: color 160ms ease, transform 160ms ease; +} + +.privacy-toc a:hover { + color: var(--brand-600); + transform: translateX(3px); +} + +.privacy-toc a span { + color: #b5aabb; + font-family: var(--font-mono); + font-size: 8px; +} + +.privacy-document { + min-width: 0; +} + +.privacy-callout { + display: grid; + grid-template-columns: auto 1fr; + gap: 17px; + margin-bottom: 75px; + padding: 25px; + border: 1px solid #dfd0e7; + border-radius: 17px; + background: linear-gradient(145deg, #fbf6ff, #f7f1fa); +} + +.privacy-callout > svg { + width: 24px; + color: var(--brand-600); +} + +.privacy-callout strong { + display: block; + margin-bottom: 5px; + font-family: var(--font-display); + font-size: 14px; +} + +.privacy-callout p { + margin: 0; + color: var(--ink-soft); + font-size: 12px; + line-height: 1.7; +} + +.policy-section { + position: relative; + scroll-margin-top: 110px; + padding: 0 0 72px; +} + +.policy-section + .policy-section { + padding-top: 3px; + border-top: 1px solid var(--line); +} + +.policy-number { + display: inline-flex; + margin-top: 0; + margin-bottom: 15px; + color: var(--brand-600); + font-family: var(--font-mono); + font-size: 9px; + font-weight: 800; + letter-spacing: 0.08em; +} + +.policy-section + .policy-section .policy-number { + margin-top: 65px; +} + +.policy-section h2 { + margin-bottom: 27px; + font-size: clamp(2rem, 3.3vw, 3.15rem); + font-weight: 720; + letter-spacing: -0.052em; + line-height: 1.08; +} + +.policy-section h3 { + margin: 34px 0 10px; + font-size: 16px; + font-weight: 720; + letter-spacing: -0.018em; +} + +.policy-section p, +.policy-section li { + color: #514956; + font-size: 14px; + line-height: 1.82; +} + +.policy-section p { + margin-bottom: 19px; +} + +.policy-section ul { + display: grid; + gap: 8px; + margin: 17px 0 22px; + padding-left: 22px; +} + +.policy-section li::marker { + color: var(--brand-500); +} + +.policy-section code { + padding: 2px 6px; + border: 1px solid #e7dceb; + border-radius: 5px; + background: #f8f3fa; + color: var(--brand-700); + font-family: var(--font-mono); + font-size: 0.86em; +} + +.policy-section a:not(.button) { + color: var(--brand-700); + font-weight: 650; + text-decoration: underline; + text-decoration-color: rgba(132, 47, 238, 0.28); + text-underline-offset: 3px; +} + +.policy-note { + display: grid; + grid-template-columns: auto 1fr; + gap: 13px; + margin-top: 28px; + padding: 18px; + border: 1px solid #e2d6e8; + border-radius: 13px; + background: #faf7fb; +} + +.policy-note svg { + width: 21px; + color: var(--brand-600); +} + +.policy-note p { + margin: 0; + color: #5e5264; + font-size: 12px; + line-height: 1.65; +} + +.permission-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin: 30px 0; +} + +.permission-grid > div { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 5px 12px; + padding: 17px; + border: 1px solid var(--line); + border-radius: 13px; + background: white; +} + +.permission-grid svg { + width: 21px; + grid-row: 1 / 3; + color: var(--brand-600); +} + +.permission-grid strong { + font-size: 11px; +} + +.permission-grid p { + margin: 0; + color: var(--ink-muted); + font-size: 9px; + line-height: 1.5; +} + +.provider-list { + display: grid; + gap: 9px; + margin: 28px 0; +} + +.provider-list > div { + display: grid; + grid-template-columns: auto 1fr; + gap: 15px; + padding: 17px; + border: 1px solid var(--line); + border-radius: 13px; + background: white; +} + +.provider-list > div > span { + display: grid; + width: 38px; + height: 38px; + place-items: center; + border-radius: 10px; + background: var(--brand-100); + color: var(--brand-700); + font-family: var(--font-mono); + font-size: 9px; + font-weight: 850; +} + +.provider-list strong { + display: block; + margin-bottom: 3px; + font-size: 11px; +} + +.provider-list p { + margin: 0; + color: var(--ink-muted); + font-size: 10px; + line-height: 1.55; +} + +.provider-links { + font-size: 11px !important; +} + +.retention-table { + overflow: hidden; + margin: 28px 0; + border: 1px solid var(--line); + border-radius: 14px; + background: white; +} + +.retention-row { + display: grid; + grid-template-columns: minmax(190px, 0.72fr) 1.28fr; +} + +.retention-row + .retention-row { + border-top: 1px solid var(--line); +} + +.retention-row span { + padding: 14px 16px; + color: var(--ink-soft); + font-size: 10px; + line-height: 1.55; +} + +.retention-row span + span { + border-left: 1px solid var(--line); + color: var(--ink-muted); +} + +.retention-head { + background: #f4eff6; +} + +.retention-head span { + color: var(--ink) !important; + font-family: var(--font-mono); + font-size: 8px; + font-weight: 800; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.policy-contact { + overflow: hidden; + padding: 54px !important; + border: 1px solid #dfd0e7 !important; + border-radius: 21px; + background: + radial-gradient(circle at 100% 0%, rgba(168, 85, 247, 0.14), transparent 35%), + linear-gradient(145deg, #fbf7fe, #f6f0f9); +} + +.policy-contact .policy-number { + margin-top: 0 !important; +} + +.policy-contact h2 { + margin-bottom: 16px; +} + +.policy-contact p { + max-width: 620px; + margin-bottom: 26px; +} + +.policy-contact .button { + color: white; + text-decoration: none; +} + diff --git a/docs/app/privacy/privacy-responsive.css b/docs/app/privacy/privacy-responsive.css new file mode 100644 index 0000000..151a56c --- /dev/null +++ b/docs/app/privacy/privacy-responsive.css @@ -0,0 +1,156 @@ +@keyframes privacy-orbit { + from { transform: translate(-50%, -50%) rotate(0); } + to { transform: translate(-50%, -50%) rotate(360deg); } +} + +@keyframes privacy-glow { + 0%, 100% { opacity: 0.5; transform: scale(0.84); } + 50% { opacity: 1; transform: scale(1.14); } +} + +@keyframes privacy-pill { + 0%, 100% { margin-top: 0; } + 50% { margin-top: -8px; } +} + +@media (max-width: 1050px) { + .privacy-hero-layout { + grid-template-columns: minmax(0, 1fr) 390px; + gap: 45px; + } + + .privacy-hero-copy h1 { + font-size: clamp(3.6rem, 6.8vw, 5.5rem); + } + + .privacy-document-layout { + grid-template-columns: 180px minmax(0, 720px); + gap: 60px; + } +} + +@media (max-width: 820px) { + .privacy-hero { + padding-top: 135px; + } + + .privacy-hero-layout { + grid-template-columns: 1fr; + } + + .privacy-hero-copy { + max-width: 690px; + } + + .privacy-hero-art { + width: min(100%, 520px); + margin: 0 auto; + } + + .privacy-summary-grid { + grid-template-columns: 1fr 1fr; + } + + .privacy-summary-card:nth-child(3) { + border-top: 1px solid var(--line); + border-left: 0; + } + + .privacy-summary-card:nth-child(4) { + border-top: 1px solid var(--line); + } + + .privacy-document-layout { + grid-template-columns: 1fr; + } + + .privacy-toc { + position: relative; + top: auto; + padding-bottom: 25px; + border-bottom: 1px solid var(--line); + } + + .privacy-toc nav { + display: grid; + grid-template-columns: repeat(3, 1fr); + } +} + +@media (max-width: 560px) { + .privacy-hero { + padding: 120px 0 90px; + } + + .privacy-back { + margin-bottom: 32px; + } + + .privacy-hero-copy h1 { + font-size: clamp(3rem, 15vw, 4.5rem); + } + + .privacy-hero-copy > p { + font-size: 15px; + } + + .privacy-hero-art { + min-height: 360px; + transform: scale(0.86); + } + + .privacy-summary-grid { + grid-template-columns: 1fr; + } + + .privacy-summary-card { + min-height: 120px; + } + + .privacy-summary-card + .privacy-summary-card { + border-top: 1px solid var(--line); + border-left: 0; + } + + .privacy-document-section { + padding: 95px 0 105px; + } + + .privacy-toc nav { + grid-template-columns: 1fr 1fr; + } + + .privacy-callout { + padding: 20px; + } + + .policy-section h2 { + font-size: 2.25rem; + } + + .policy-section p, + .policy-section li { + font-size: 13px; + } + + .permission-grid { + grid-template-columns: 1fr; + } + + .retention-row { + grid-template-columns: 1fr; + } + + .retention-row span + span { + padding-top: 0; + border-left: 0; + } + + .retention-head { + display: none; + } + + .policy-contact { + padding: 35px 24px !important; + } +} diff --git a/docs/app/responsive.css b/docs/app/responsive.css new file mode 100644 index 0000000..95b1ca1 --- /dev/null +++ b/docs/app/responsive.css @@ -0,0 +1,322 @@ +@keyframes ambient-drift { + from { transform: translate3d(-10px, -8px, 0) rotate(0deg); } + to { transform: translate3d(24px, 18px, 0) rotate(8deg); } +} + +@keyframes word-stream { + to { transform: translateX(-50%); } +} + +@keyframes cta-orb { + from { transform: translate3d(-20px, -10px, 0) scale(0.9); } + to { transform: translate3d(40px, 25px, 0) scale(1.12); } +} + +@media (max-width: 1120px) { + .hero-layout { + grid-template-columns: minmax(0, 0.88fr) minmax(470px, 1.12fr); + } + + .hero-copy h1 { + font-size: clamp(3.2rem, 5.4vw, 4.5rem); + } + + .approval-layout, + .network-layout, + .platforms-layout { + gap: 55px; + } + + .privacy-point-grid { + grid-template-columns: repeat(2, 1fr); + } + + .privacy-point { + min-height: 230px; + } +} + +@media (max-width: 920px) { + .desktop-nav, + .header-github { + display: none; + } + + .header-inner { + grid-template-columns: 1fr auto; + } + + .menu-button { + display: grid; + } + + .mobile-nav { + display: grid; + max-height: 0; + overflow: hidden; + padding: 0 12px; + opacity: 0; + visibility: hidden; + transition: max-height 250ms ease, padding 250ms ease, opacity 180ms ease, visibility 250ms; + } + + .mobile-nav.is-open { + max-height: 280px; + padding: 4px 12px 12px; + opacity: 1; + visibility: visible; + } + + .mobile-nav a { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 10px; + border-top: 1px solid var(--line); + color: var(--ink-soft); + font-size: 13px; + font-weight: 650; + } + + .mobile-nav svg { + width: 17px; + } + + .hero-section { + padding-top: 128px; + } + + .hero-layout { + grid-template-columns: 1fr; + gap: 35px; + } + + .hero-copy { + max-width: 720px; + margin: 0 auto; + text-align: center; + } + + .hero-lead { + margin-right: auto; + margin-left: auto; + } + + .hero-actions, + .hero-platforms { + justify-content: center; + } + + .trust-grid { + grid-template-columns: 1fr; + } + + .trust-item { + min-height: 102px; + } + + .trust-item + .trust-item { + border-top: 1px solid var(--line); + border-left: 0; + } + + .section-heading-split { + grid-template-columns: 1fr; + gap: 20px; + } + + .section-heading-split p { + max-width: 640px; + } + + .approval-layout, + .network-layout, + .platforms-layout { + grid-template-columns: 1fr; + } + + .approval-copy, + .network-copy, + .platforms-copy { + max-width: 690px; + } + + .network-layout { + gap: 75px; + } + + .platforms-layout { + gap: 65px; + } +} + +@media (max-width: 680px) { + .page-shell { + width: min(calc(100% - 32px), var(--shell)); + } + + .site-header { + top: 10px; + width: calc(100% - 20px); + border-radius: 15px; + } + + .header-inner { + height: 58px; + padding-left: 11px; + } + + .brand-name { + font-size: 17px; + } + + .brand-mark-shell { + width: 36px; + height: 36px; + } + + .hero-section { + min-height: auto; + padding: 115px 0 72px; + } + + .hero-copy h1 { + font-size: clamp(2.75rem, 14vw, 4.25rem); + letter-spacing: -0.065em; + } + + .hero-lead { + font-size: 16px; + } + + .hero-actions { + display: grid; + } + + .hero-actions .button { + width: 100%; + } + + .hero-platforms { + display: grid; + justify-items: center; + } + + .hero-platforms ul { + justify-content: center; + } + + .trust-section { + margin-top: -18px; + } + + .trust-grid { + border-radius: 18px; + } + + .trust-item { + padding: 20px; + } + + .word-stream { + padding-top: 70px; + font-size: 3.6rem; + } + + .section { + padding: 95px 0; + } + + .section-heading { + margin-bottom: 42px; + } + + .section-heading h2, + .privacy-intro h2, + .network-copy h2, + .platforms-copy h2, + .final-cta h2, + .approval-copy h2 { + font-size: clamp(2.35rem, 12vw, 3.5rem); + } + + .section-heading p, + .section-lead { + font-size: 15px; + } + + .approval-section, + .platforms-section, + .network-section, + .privacy-principles-section { + padding: 100px 0; + } + + .approval-layout { + gap: 55px; + } + + .privacy-point-grid { + grid-template-columns: 1fr; + } + + .privacy-point { + min-height: 0; + } + + .privacy-point-top { + margin-bottom: 30px; + } + + .privacy-policy-link { + align-items: flex-start; + flex-direction: column; + } + + .final-cta-section { + padding-bottom: 24px; + } + + .final-cta { + padding: 76px 22px; + border-radius: 23px; + } + + .final-cta-actions { + display: grid; + } + + .footer-main { + grid-template-columns: 1fr; + } + + .footer-links { + gap: 35px; + } + + .footer-bottom { + align-items: flex-start; + flex-direction: column; + gap: 5px; + } +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + + .motion-ready .reveal { + opacity: 1; + transform: none; + } +} diff --git a/docs/components/approval-demo.tsx b/docs/components/approval-demo.tsx new file mode 100644 index 0000000..74ffb18 --- /dev/null +++ b/docs/components/approval-demo.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useState } from "react"; +import { Icon } from "@/components/icons"; + +type ApprovalState = "pending" | "approved" | "refused"; + +export function ApprovalDemo() { + const [state, setState] = useState("pending"); + + return ( +
+
+ + + + Receiver requests +
+
+
+
+ + +
+
+ + {state === "pending" ? "NEW REQUEST" : state === "approved" ? "IN PROGRESS" : "REQUEST CLOSED"} + +

+ {state === "pending" && "Maya’s phone wants to receive"} + {state === "approved" && "Sending securely to Maya"} + {state === "refused" && "Request refused"} +

+
+
+ +
+ + + + + Summer photos + 42 files · 1.8 GB + + {state === "approved" && 68%} +
+ + {state === "pending" && ( +
+ + +
+ )} + + {state === "approved" && ( +
+
+ +
+
+ Direct connection + +
+
+ )} + + {state === "refused" && ( +
+ +

This device was not given access.

+ +
+ )} +
+
+ + Approval is required by default +
+
+ ); +} diff --git a/docs/components/approval-scene.css b/docs/components/approval-scene.css new file mode 100644 index 0000000..2edd2e0 --- /dev/null +++ b/docs/components/approval-scene.css @@ -0,0 +1,267 @@ +/* Interactive receiver approval */ +.approval-demo { + overflow: hidden; + border: 1px solid #ded6e2; + border-radius: 24px; + background: #fbf9fc; + box-shadow: 0 35px 90px rgba(49, 29, 58, 0.16); + transform: rotate(1deg); + transition: transform 280ms ease, border-color 280ms ease; +} + +.approval-demo:hover { transform: rotate(0deg) translateY(-3px); } +.approval-demo.state-approved { border-color: rgba(45, 185, 117, 0.4); } +.approval-demo.state-refused { border-color: rgba(221, 92, 75, 0.35); } + +.approval-window-bar { + display: flex; + height: 48px; + align-items: center; + gap: 6px; + padding: 0 16px; + border-bottom: 1px solid #ded6e2; + background: #f0ecf2; +} + +.approval-window-bar > span { + width: 8px; + height: 8px; + border-radius: 50%; + background: #d1c7d5; +} + +.approval-window-bar > span:nth-child(1) { background: #f1a191; } +.approval-window-bar > span:nth-child(2) { background: #f1ca72; } +.approval-window-bar > span:nth-child(3) { background: #72cf9b; } + +.approval-window-bar small { + margin-left: auto; + color: #8a7f8f; + font-family: var(--font-mono); + font-size: 8px; + font-weight: 700; + letter-spacing: 0.06em; +} + +.approval-body { + min-height: 385px; + padding: 35px; + background: + radial-gradient(circle at 90% 0%, rgba(192, 132, 252, 0.16), transparent 27%), + white; +} + +.approval-heading { + display: flex; + align-items: center; + gap: 17px; + margin-bottom: 30px; +} + +.device-avatar { + position: relative; + width: 52px; + height: 60px; + flex: 0 0 auto; + border: 4px solid #352e39; + border-radius: 13px; + background: #211d24; + box-shadow: 0 10px 25px rgba(42, 30, 48, 0.16); +} + +.device-avatar::before { + position: absolute; + top: 4px; + left: 50%; + width: 16px; + height: 3px; + border-radius: 999px; + background: #0c0a0d; + content: ""; + transform: translateX(-50%); +} + +.device-avatar-screen { + position: absolute; + inset: 14px 8px 8px; + border-radius: 7px; + background: linear-gradient(145deg, #b55efa, #6f27e9); +} + +.device-avatar-glow { + position: absolute; + inset: -5px; + z-index: -1; + border-radius: 16px; + box-shadow: 0 0 0 0 rgba(168, 85, 247, 0.25); + animation: avatar-pulse 2.7s ease-out infinite; +} + +.approval-heading h3 { + max-width: 360px; + margin: 5px 0 0; + font-size: 21px; + letter-spacing: -0.035em; + line-height: 1.22; +} + +.approval-file { + display: flex; + align-items: center; + gap: 13px; + padding: 16px; + border: 1px solid #e5dde8; + border-radius: 14px; + background: #f8f5f9; +} + +.approval-file-icon { + display: grid; + width: 42px; + height: 42px; + flex: 0 0 auto; + place-items: center; + border-radius: 11px; + background: var(--brand-100); + color: var(--brand-600); +} + +.approval-file-icon svg { width: 21px; } +.approval-file strong, +.approval-file small { display: block; } +.approval-file strong { font-size: 12px; } +.approval-file small { color: var(--ink-muted); font-size: 9px; } + +.approval-percent { + margin-left: auto; + color: var(--brand-600); + font-family: var(--font-mono); + font-size: 11px; + font-weight: 800; +} + +.approval-actions { + display: grid; + grid-template-columns: 1fr 1.4fr; + gap: 10px; + margin-top: 22px; +} + +.approval-actions button, +.approval-refused button { + min-height: 47px; + border: 1px solid #ded4e2; + border-radius: 12px; + font-size: 12px; + font-weight: 750; + cursor: pointer; + transition: transform 160ms ease, background 160ms ease, box-shadow 160ms ease; +} + +.approval-actions button:hover, +.approval-refused button:hover { transform: translateY(-2px); } +.approval-refuse { background: white; color: #625769; } +.approval-accept { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border-color: transparent !important; + background: linear-gradient(135deg, var(--brand-500), var(--brand-700)); + box-shadow: 0 10px 24px rgba(132, 47, 238, 0.22); + color: white; +} + +.approval-accept svg { width: 17px; } + +.approval-progress { + margin-top: 25px; + animation: approval-state-enter 350ms ease; +} + +.approval-progress-track { + overflow: hidden; + height: 8px; + border-radius: 999px; + background: #eee8f1; +} + +.approval-progress-track span { + display: block; + width: 68%; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--brand-500), var(--brand-700)); + box-shadow: 0 0 15px rgba(168, 85, 247, 0.38); + animation: progress-fill 900ms cubic-bezier(0.25, 0.8, 0.25, 1) both; +} + +.approval-progress-meta { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 13px; + color: var(--ink-muted); + font-size: 9px; +} + +.approval-progress-meta > span { + display: flex; + align-items: center; + gap: 9px; +} + +.approval-progress-meta button, +.approval-refused button { + border: 0; + background: transparent; + color: var(--brand-600); + font-size: 9px; + font-weight: 750; + cursor: pointer; +} + +.approval-refused { + display: flex; + align-items: center; + gap: 12px; + margin-top: 23px; + padding: 14px; + border: 1px solid #f0d5d0; + border-radius: 12px; + background: #fff8f7; + animation: approval-state-enter 350ms ease; +} + +.approval-refused > span { + display: grid; + width: 30px; + height: 30px; + flex: 0 0 auto; + place-items: center; + border-radius: 50%; + background: #fde7e3; + color: #d85848; +} + +.approval-refused svg { width: 15px; } +.approval-refused p { margin: 0; color: #705c59; font-size: 10px; } +.approval-refused button { margin-left: auto; } + +.approval-footer { + display: flex; + height: 50px; + align-items: center; + justify-content: center; + gap: 8px; + border-top: 1px solid #ded6e2; + background: #f2eef4; + color: #766b7b; + font-family: var(--font-mono); + font-size: 8px; + font-weight: 700; + letter-spacing: 0.04em; +} + +.approval-footer svg { width: 15px; color: var(--brand-600); } + diff --git a/docs/components/brand.tsx b/docs/components/brand.tsx new file mode 100644 index 0000000..ffcb2ad --- /dev/null +++ b/docs/components/brand.tsx @@ -0,0 +1,65 @@ +import Link from "next/link"; + +type BrandMarkProps = { + className?: string; + title?: string; +}; + +export function BrandMark({ className, title }: BrandMarkProps) { + return ( + + + + + + + + + + + + + + + + + ); +} + +export function Brand({ compact = false }: { compact?: boolean }) { + return ( + + + + + {!compact && VniDrop} + + ); +} diff --git a/docs/components/hero-illustration.tsx b/docs/components/hero-illustration.tsx new file mode 100644 index 0000000..37fbfd1 --- /dev/null +++ b/docs/components/hero-illustration.tsx @@ -0,0 +1,216 @@ +"use client"; + +import type { CSSProperties, PointerEvent } from "react"; +import { useRef } from "react"; +import { Icon } from "@/components/icons"; + +type StageStyle = CSSProperties & { + "--pointer-x": string; + "--pointer-y": string; +}; + +export function HeroIllustration() { + const stageRef = useRef(null); + + const updateParallax = (event: PointerEvent) => { + const stage = stageRef.current; + if (!stage || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + const bounds = stage.getBoundingClientRect(); + const x = ((event.clientX - bounds.left) / bounds.width - 0.5) * 2; + const y = ((event.clientY - bounds.top) / bounds.height - 0.5) * 2; + stage.style.setProperty("--pointer-x", x.toFixed(3)); + stage.style.setProperty("--pointer-y", y.toFixed(3)); + }; + + const resetParallax = () => { + stageRef.current?.style.setProperty("--pointer-x", "0"); + stageRef.current?.style.setProperty("--pointer-y", "0"); + }; + + return ( +
+
+ ); +} diff --git a/docs/components/hero-scene.css b/docs/components/hero-scene.css new file mode 100644 index 0000000..d46f06a --- /dev/null +++ b/docs/components/hero-scene.css @@ -0,0 +1,247 @@ +/* Hero transfer scene */ +.hero-visual { + position: relative; + width: min(100%, 730px); + margin: 0; +} + +.hero-stage { + --pointer-x: 0; + --pointer-y: 0; + position: relative; + width: 100%; + aspect-ratio: 1.13; + isolation: isolate; + perspective: 1200px; +} + +.hero-grid { + position: absolute; + z-index: -3; + inset: 9% 2% 4% 7%; + border: 1px solid rgba(168, 85, 247, 0.14); + border-radius: 50%; + background-image: + linear-gradient(rgba(121, 82, 138, 0.07) 1px, transparent 1px), + linear-gradient(90deg, rgba(121, 82, 138, 0.07) 1px, transparent 1px); + background-size: 32px 32px; + transform: rotateX(64deg) rotateZ(-8deg) translateZ(-70px); + mask-image: radial-gradient(circle, black 10%, transparent 70%); +} + +.hero-glow { + position: absolute; + z-index: -2; + border-radius: 50%; + filter: blur(60px); + pointer-events: none; +} + +.hero-glow-one { + top: 22%; + right: 8%; + width: 37%; + aspect-ratio: 1; + background: rgba(168, 85, 247, 0.23); + transform: translate(calc(var(--pointer-x) * 12px), calc(var(--pointer-y) * 12px)); +} + +.hero-glow-two { + bottom: 8%; + left: 28%; + width: 30%; + aspect-ratio: 1; + background: rgba(126, 58, 236, 0.16); + transform: translate(calc(var(--pointer-x) * -9px), calc(var(--pointer-y) * -9px)); +} + +.hero-scene { + position: absolute; + z-index: 1; + inset: 1% -3% auto -1%; + width: 105%; + overflow: visible; + transform: rotateY(calc(var(--pointer-x) * 1.7deg)) rotateX(calc(var(--pointer-y) * -1.7deg)); + transform-origin: center; + transition: transform 180ms ease-out; +} + +.scene-laptop { + transform: translate(calc(var(--pointer-x) * -4px), calc(var(--pointer-y) * -4px)); + transition: transform 200ms ease-out; +} + +.scene-phone { + transform: translate(calc(var(--pointer-x) * 7px), calc(var(--pointer-y) * 5px)); + transition: transform 200ms ease-out; +} + +.scene-orbits { + transform-origin: 385px 325px; + animation: scene-orbits 30s linear infinite; +} + +.laptop-files { + transform-origin: 190px 245px; + animation: laptop-files 6s ease-in-out infinite; +} + +.conduit-energy { + animation: conduit-energy 2s linear infinite; +} + +.approval-gate { + transform-origin: 440px 443px; + animation: gate-hover 5s ease-in-out infinite; +} + +.received-file { + transform-origin: 616px 270px; + animation: received-file 7s cubic-bezier(0.34, 1.2, 0.55, 1) infinite; +} + +.packet, +.packet-spark { + opacity: 0; + transform-box: fill-box; + transform-origin: center; +} + +.packet-one { + animation: hero-packet-one 7s cubic-bezier(0.45, 0.05, 0.35, 1) 0.6s infinite; +} + +.packet-two { + animation: hero-packet-two 7s cubic-bezier(0.45, 0.05, 0.35, 1) 3s infinite; +} + +.packet-spark-one { + animation: hero-spark-one 7s ease 0.6s infinite; +} + +.packet-spark-two { + animation: hero-spark-two 7s ease 3s infinite; +} + +.hero-status { + position: absolute; + z-index: 5; + display: flex; + align-items: center; + border: 1px solid rgba(222, 211, 228, 0.95); + background: rgba(255, 255, 255, 0.9); + box-shadow: 0 15px 45px rgba(49, 27, 61, 0.11); + backdrop-filter: blur(12px); + transition: transform 180ms ease-out; +} + +.hero-status-secure { + top: 2%; + right: 5%; + gap: 10px; + padding: 10px 13px 10px 10px; + border-radius: 14px; + transform: translate(calc(var(--pointer-x) * 12px), calc(var(--pointer-y) * 8px)) rotate(2deg); + animation: status-hover 6s ease-in-out infinite; +} + +.status-icon { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border-radius: 10px; + background: var(--brand-100); + color: var(--brand-600); +} + +.status-icon svg { + width: 17px; +} + +.hero-status strong, +.hero-status small { + display: block; +} + +.hero-status strong { + color: #2b2530; + font-size: 10px; + line-height: 1.4; +} + +.hero-status small { + color: #897f8e; + font-size: 8px; +} + +.hero-status-route { + top: 18%; + left: 9%; + gap: 8px; + padding: 7px 11px; + border-radius: 999px; + color: #534b57; + font-family: var(--font-mono); + font-size: 8px; + font-weight: 700; + letter-spacing: 0.04em; + transform: translate(calc(var(--pointer-x) * -8px), calc(var(--pointer-y) * -6px)) rotate(-3deg); +} + +.status-pulse, +.live-dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + background: #2bc477; + box-shadow: 0 0 0 4px rgba(43, 196, 119, 0.13); + animation: status-pulse 2.4s ease-in-out infinite; +} + +.hero-file-label { + position: absolute; + z-index: 4; + bottom: 7%; + left: 7%; + display: flex; + align-items: center; + gap: 11px; + padding: 10px 13px; + border: 1px solid rgba(222, 211, 228, 0.95); + border-radius: 14px; + background: rgba(255, 255, 255, 0.9); + box-shadow: 0 15px 45px rgba(49, 27, 61, 0.11); + backdrop-filter: blur(12px); + transform: translate(calc(var(--pointer-x) * -10px), calc(var(--pointer-y) * -7px)) rotate(-2deg); + transition: transform 180ms ease-out; +} + +.file-type-icon { + display: grid; + width: 37px; + height: 37px; + place-items: center; + border-radius: 10px; + background: linear-gradient(135deg, var(--brand-400), var(--brand-600)); + color: white; + font-family: var(--font-mono); + font-size: 8px; + font-weight: 800; +} + +.hero-file-label strong, +.hero-file-label small { + display: block; +} + +.hero-file-label strong { + font-size: 10px; +} + +.hero-file-label small { + color: var(--ink-muted); + font-size: 8px; +} + diff --git a/docs/components/icons.tsx b/docs/components/icons.tsx new file mode 100644 index 0000000..905d4c1 --- /dev/null +++ b/docs/components/icons.tsx @@ -0,0 +1,140 @@ +import type { SVGProps } from "react"; + +export type IconName = + | "arrow" + | "check" + | "chevron" + | "close" + | "code" + | "devices" + | "file" + | "folder" + | "github" + | "globe" + | "lock" + | "menu" + | "nfc" + | "qr" + | "shield" + | "spark" + | "stop" + | "verified" + | "x"; + +type IconProps = SVGProps & { + name: IconName; +}; + +export function Icon({ name, ...props }: IconProps) { + const paths: Record = { + arrow: ( + <> + + + + ), + check: , + chevron: , + close: ( + <> + + + + ), + code: ( + <> + + + + + ), + devices: ( + <> + + + + + ), + file: ( + <> + + + + ), + folder: ( + + ), + github: ( + + ), + globe: ( + <> + + + + ), + lock: ( + <> + + + + ), + menu: ( + <> + + + ), + nfc: ( + <> + + + + + ), + qr: ( + <> + + + + + + ), + shield: ( + <> + + + + ), + spark: ( + + ), + stop: , + verified: ( + <> + + + + ), + x: ( + <> + + + + ), + }; + + return ( + + ); +} diff --git a/docs/components/illustration-motion.css b/docs/components/illustration-motion.css new file mode 100644 index 0000000..0f80c17 --- /dev/null +++ b/docs/components/illustration-motion.css @@ -0,0 +1,276 @@ +@keyframes scene-orbits { to { transform: rotate(360deg); } } +@keyframes laptop-files { + 0%, 100% { transform: translateY(0) rotate(0); } + 50% { transform: translateY(-5px) rotate(1deg); } +} +@keyframes conduit-energy { to { stroke-dashoffset: -38; } } +@keyframes gate-hover { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-6px); } +} +@keyframes received-file { + 0%, 9% { opacity: 0.55; transform: translateY(12px) scale(0.92); } + 17%, 70% { opacity: 1; transform: translateY(0) scale(1); } + 83%, 100% { opacity: 0.55; transform: translateY(12px) scale(0.92); } +} +@keyframes hero-packet-one { + 0%, 6% { opacity: 0; transform: translate(245px, 320px) scale(0.55) rotate(-8deg); } + 14% { opacity: 1; } + 43% { opacity: 1; transform: translate(380px, 427px) scale(0.82) rotate(5deg); } + 54% { opacity: 1; transform: translate(415px, 430px) scale(0.82) rotate(2deg); } + 84% { opacity: 1; transform: translate(556px, 296px) scale(0.65) rotate(10deg); } + 92%, 100% { opacity: 0; transform: translate(585px, 270px) scale(0.35) rotate(14deg); } +} +@keyframes hero-packet-two { + 0%, 6% { opacity: 0; transform: translate(255px, 327px) scale(0.5) rotate(8deg); } + 15% { opacity: 0.85; } + 45% { opacity: 0.9; transform: translate(382px, 440px) scale(0.75) rotate(-4deg); } + 56% { opacity: 0.9; transform: translate(414px, 436px) scale(0.75) rotate(-2deg); } + 85% { opacity: 0.8; transform: translate(560px, 308px) scale(0.55) rotate(-10deg); } + 93%, 100% { opacity: 0; transform: translate(588px, 278px) scale(0.25) rotate(-15deg); } +} +@keyframes hero-spark-one { + 0%, 12% { opacity: 0; transform: translate(266px, 346px) scale(0); } + 19% { opacity: 1; transform: translate(285px, 360px) scale(1); } + 29%, 100% { opacity: 0; transform: translate(310px, 380px) scale(0); } +} +@keyframes hero-spark-two { + 0%, 77% { opacity: 0; transform: translate(545px, 307px) scale(0); } + 87% { opacity: 1; transform: translate(575px, 279px) scale(1); } + 97%, 100% { opacity: 0; transform: translate(603px, 260px) scale(0); } +} +@keyframes status-hover { + 0%, 100% { margin-top: 0; } + 50% { margin-top: -7px; } +} +@keyframes status-pulse { + 0%, 100% { box-shadow: 0 0 0 3px rgba(43, 196, 119, 0.1); } + 50% { box-shadow: 0 0 0 7px rgba(43, 196, 119, 0); } +} +@keyframes flow-glow { + 0%, 100% { transform: scale(0.8); opacity: 0.6; } + 50% { transform: scale(1.2); opacity: 1; } +} +@keyframes flow-drop { + 0%, 7% { offset-distance: 0%; opacity: 0; } + 13% { opacity: 1; } + 47% { offset-distance: 51%; opacity: 1; } + 57% { offset-distance: 51%; opacity: 1; } + 91% { offset-distance: 100%; opacity: 1; } + 97%, 100% { offset-distance: 100%; opacity: 0; } +} +@keyframes gate-breathe { + 0%, 100% { transform: scale(0.98); } + 50% { transform: scale(1.04); } +} +@keyframes complete-ring { + 0%, 100% { box-shadow: 0 0 0 0 rgba(168, 85, 247, 0.18); } + 50% { box-shadow: 0 0 0 13px rgba(168, 85, 247, 0); } +} +@keyframes invite-panel-enter { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: none; } +} +@keyframes qr-card-float { + 0%, 100% { transform: rotate(2deg) translateY(0); } + 50% { transform: rotate(-1deg) translateY(-8px); } +} +@keyframes qr-cell { + from { opacity: 0; transform: scale(0.2); } + to { opacity: 1; transform: scale(1); } +} +@keyframes qr-scan { + 0%, 100% { top: 40px; opacity: 0; } + 12%, 88% { opacity: 1; } + 50% { top: 222px; } +} +@keyframes nfc-dot { + 0%, 100% { box-shadow: 0 0 0 0 rgba(168, 85, 247, 0.2); } + 50% { box-shadow: 0 0 0 19px rgba(168, 85, 247, 0); } +} +@keyframes nfc-wave { + 0%, 100% { opacity: 0.25; transform: translateY(-50%) scale(0.88); } + 50% { opacity: 1; transform: translateY(-50%) scale(1.04); } +} +@keyframes vnd-drop { + 0%, 8% { transform: translateX(-50%) translateY(-16px) rotate(-5deg); } + 20%, 78% { transform: translateX(-50%) translateY(0) rotate(-2deg); } + 88%, 100% { transform: translateX(-50%) translateY(-16px) rotate(-5deg); } +} +@keyframes avatar-pulse { + 0% { box-shadow: 0 0 0 0 rgba(168, 85, 247, 0.24); } + 75%, 100% { box-shadow: 0 0 0 14px rgba(168, 85, 247, 0); } +} +@keyframes approval-state-enter { + from { opacity: 0; transform: translateY(7px); } + to { opacity: 1; transform: none; } +} +@keyframes progress-fill { + from { width: 0; } + to { width: 68%; } +} +@keyframes network-dash { to { stroke-dashoffset: -44; } } +@keyframes relay-ring { + 0% { opacity: 0.6; transform: translateX(-50%) scale(0.72); } + 85%, 100% { opacity: 0; transform: translateX(-50%) scale(1.2); } +} +@keyframes network-packet-top { + 0%, 8% { left: 15%; top: 40%; opacity: 0; transform: rotate(-7deg) scale(0.7); } + 15% { opacity: 1; } + 48% { left: 47%; top: 20%; opacity: 1; transform: rotate(3deg) scale(0.9); } + 85% { left: 82%; top: 40%; opacity: 1; transform: rotate(8deg) scale(0.72); } + 94%, 100% { left: 85%; top: 42%; opacity: 0; transform: rotate(10deg) scale(0.4); } +} +@keyframes network-packet-bottom { + 0%, 8% { left: 16%; top: 50%; opacity: 0; transform: rotate(8deg) scale(0.65); } + 15% { opacity: 0.8; } + 48% { left: 48%; top: 69%; opacity: 0.8; transform: rotate(-3deg) scale(0.8); } + 84% { left: 82%; top: 50%; opacity: 0.8; transform: rotate(-8deg) scale(0.7); } + 94%, 100% { left: 85%; top: 48%; opacity: 0; transform: rotate(-10deg) scale(0.4); } +} +@keyframes orbit-spin { + from { transform: translate(-50%, -50%) rotate(0deg); } + to { transform: translate(-50%, -50%) rotate(360deg); } +} +@keyframes platform-glow { + 0%, 100% { opacity: 0.45; transform: scale(0.82); } + 50% { opacity: 1; transform: scale(1.15); } +} +@keyframes platform-chip-float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-8px); } +} + +@media (max-width: 1120px) { + .flow-layout { + grid-template-columns: minmax(450px, 1fr) minmax(350px, 0.8fr); + gap: 45px; + } + + .flow-device { transform: scale(0.88); } + .flow-device-sender { transform: scale(0.88) rotate(-6deg); transform-origin: top left; } + .flow-device-receiver { transform: scale(0.88) rotate(5deg); transform-origin: bottom right; } + + .invite-panel { + grid-template-columns: minmax(0, 0.85fr) minmax(420px, 1.15fr); + } + + .invite-copy { padding: 45px; } + .network-visual { min-height: 500px; } +} + +@media (max-width: 920px) { + .hero-visual { + max-width: 720px; + margin-inline: auto; + } + + .flow-layout { + grid-template-columns: 1fr; + } + + .flow-visual { + position: relative; + top: auto; + } + + .invite-panel { + grid-template-columns: 1fr; + } + + .invite-art { + min-height: 450px; + border-top: 1px solid var(--line); + border-left: 0; + } + + .network-visual { + min-height: 520px; + } + + .platform-orbit { + max-width: 620px; + } +} + +@media (max-width: 680px) { + .hero-stage { + width: 112%; + margin-left: -6%; + } + + .hero-status-secure { + top: 0; + right: 2%; + transform: scale(0.88) rotate(2deg); + transform-origin: top right; + } + + .hero-status-route { left: 4%; transform: scale(0.88) rotate(-3deg); transform-origin: top left; } + .hero-file-label { bottom: 3%; left: 4%; transform: scale(0.88) rotate(-2deg); transform-origin: bottom left; } + + .flow-visual { + height: 540px; + border-radius: 21px; + } + + .flow-device { + width: 137px; + height: 215px; + } + + .flow-device-sender { top: 24px; left: 16px; } + .flow-device-receiver { right: 14px; bottom: 20px; } + .flow-track { inset: 0 -2%; width: 104%; height: 100%; } + .flow-annotation { display: none; } + + .flow-step { + grid-template-columns: auto 1fr; + padding: 20px; + } + + .invite-tabs button { + min-height: 59px; + flex-direction: column; + gap: 3px; + font-size: 10px; + } + + .invite-tabs svg { width: 17px; } + .invite-copy { padding: 42px 24px; } + .invite-art { min-height: 390px; } + .invite-qr-art { transform: scale(0.86) rotate(2deg); } + .invite-nfc-art { transform: scale(0.82); } + .invite-file-art { transform: scale(0.85); } + + .approval-body { min-height: 370px; padding: 28px 20px; } + .approval-heading h3 { font-size: 18px; } + .approval-actions { grid-template-columns: 1fr 1.2fr; } + + .network-visual { + min-height: 430px; + border-radius: 21px; + } + + .network-device { + top: 150px; + width: 77px; + height: 112px; + } + + .network-device-left { left: 13px; } + .network-device-right { right: 13px; } + .network-device svg { width: 24px; } + .network-relay-node { bottom: 29px; } + .network-relay-node small { max-width: 160px; text-align: center; } + .network-direct-label { top: 43px; } + + .platform-orbit { width: 112%; margin-left: -6%; } + .platform-center { width: 112px; height: 112px; border-radius: 30px; } + .platform-center svg { width: 68px; height: 68px; } + .platform-chip { padding: 8px 10px; font-size: 9px; } + .platform-chip-1 { left: 10%; } + .platform-chip-2 { right: 0; } + .platform-chip-3 { right: -2%; } + .platform-chip-4 { left: 17%; } +} diff --git a/docs/components/invitation-scene.css b/docs/components/invitation-scene.css new file mode 100644 index 0000000..e12aa8b --- /dev/null +++ b/docs/components/invitation-scene.css @@ -0,0 +1,405 @@ +/* Invitation methods */ +.invite-showcase { + overflow: hidden; + border: 1px solid var(--line); + border-radius: 25px; + background: white; + box-shadow: var(--shadow-lg); +} + +.invite-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + border-bottom: 1px solid var(--line); + background: #f7f4f8; +} + +.invite-tabs button { + display: inline-flex; + min-height: 66px; + align-items: center; + justify-content: center; + gap: 9px; + border: 0; + border-right: 1px solid var(--line); + background: transparent; + color: var(--ink-muted); + font-size: 13px; + font-weight: 700; + cursor: pointer; + transition: color 180ms ease, background 180ms ease; +} + +.invite-tabs button:last-child { border-right: 0; } +.invite-tabs button:hover { color: var(--ink); } +.invite-tabs button.is-active { + position: relative; + background: white; + color: var(--brand-600); +} + +.invite-tabs button.is-active::after { + position: absolute; + right: 20%; + bottom: -1px; + left: 20%; + height: 2px; + border-radius: 999px 999px 0 0; + background: var(--brand-500); + content: ""; +} + +.invite-tabs svg { width: 19px; } + +.invite-panel { + display: grid; + min-height: 490px; + grid-template-columns: minmax(0, 0.83fr) minmax(480px, 1.17fr); + animation: invite-panel-enter 420ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.invite-copy { + display: flex; + align-items: flex-start; + flex-direction: column; + justify-content: center; + padding: 60px; +} + +.mono-label { + color: var(--brand-600); + font-family: var(--font-mono); + font-size: 9px; + font-weight: 800; + letter-spacing: 0.11em; +} + +.invite-copy h3 { + max-width: 430px; + margin: 12px 0 18px; + font-size: clamp(2rem, 3vw, 3.15rem); + letter-spacing: -0.055em; + line-height: 1.06; +} + +.invite-copy > p { + max-width: 440px; + margin-bottom: 30px; + color: var(--ink-soft); + font-size: 15px; + line-height: 1.7; +} + +.invite-detail { + display: flex; + gap: 11px; + padding-top: 20px; + border-top: 1px solid var(--line); +} + +.invite-detail > svg { + width: 19px; + flex: 0 0 auto; + color: var(--brand-600); +} + +.invite-detail strong, +.invite-detail small { + display: block; +} + +.invite-detail strong { + margin-bottom: 3px; + color: #3c3440; + font-size: 11px; +} + +.invite-detail small { + color: var(--ink-muted); + font-size: 9px; + line-height: 1.5; +} + +.invite-art { + position: relative; + display: grid; + overflow: hidden; + place-items: center; + border-left: 1px solid var(--line); + background: + linear-gradient(rgba(116, 86, 130, 0.05) 1px, transparent 1px), + linear-gradient(90deg, rgba(116, 86, 130, 0.05) 1px, transparent 1px), + radial-gradient(circle at center, #fbf7fd, #f2edf5); + background-size: 24px 24px, 24px 24px, auto; +} + +.invite-art::before { + position: absolute; + width: 330px; + aspect-ratio: 1; + border-radius: 50%; + background: rgba(168, 85, 247, 0.14); + content: ""; + filter: blur(55px); +} + +.invite-qr-art { + position: relative; + display: grid; + width: 265px; + height: 265px; + place-items: center; + border: 1px solid #e0d5e5; + border-radius: 25px; + background: rgba(255, 255, 255, 0.95); + box-shadow: 0 30px 80px rgba(59, 35, 70, 0.18); + transform: rotate(2deg); + animation: qr-card-float 6s ease-in-out infinite; +} + +.invite-qr-grid { + display: grid; + width: 178px; + height: 178px; + grid-template-columns: repeat(9, 1fr); + grid-template-rows: repeat(9, 1fr); + gap: 4px; +} + +.invite-qr-grid span { + border-radius: 2px; + background: transparent; +} + +.invite-qr-grid span.is-filled { + background: #2b2230; + animation: qr-cell 600ms ease backwards; +} + +.invite-qr-grid span:nth-child(3n) { animation-delay: 80ms; } +.invite-qr-grid span:nth-child(4n) { animation-delay: 160ms; } +.invite-qr-grid span:nth-child(7n) { animation-delay: 230ms; } + +.qr-scan-line { + position: absolute; + right: 34px; + left: 34px; + height: 2px; + background: linear-gradient(90deg, transparent, var(--brand-500), transparent); + box-shadow: 0 0 13px var(--brand-400); + animation: qr-scan 3.2s ease-in-out infinite; +} + +.qr-corner { + position: absolute; + width: 25px; + height: 25px; + border-color: var(--brand-500); + border-style: solid; +} + +.qr-corner-one { top: 21px; left: 21px; border-width: 2px 0 0 2px; border-radius: 7px 0 0; } +.qr-corner-two { top: 21px; right: 21px; border-width: 2px 2px 0 0; border-radius: 0 7px 0 0; } +.qr-corner-three { right: 21px; bottom: 21px; border-width: 0 2px 2px 0; border-radius: 0 0 7px; } +.qr-corner-four { bottom: 21px; left: 21px; border-width: 0 0 2px 2px; border-radius: 0 0 0 7px; } + +.invite-nfc-art { + position: relative; + width: 440px; + height: 330px; +} + +.nfc-device { + position: absolute; + top: 10px; + left: 10px; + width: 155px; + height: 300px; + border: 7px solid #d7cfdc; + border-radius: 30px; + background: #211c25; + box-shadow: var(--shadow-lg); + transform: rotate(-8deg); +} + +.nfc-device::after { + position: absolute; + top: 11px; + left: 50%; + width: 50px; + height: 7px; + border-radius: 999px; + background: #09070a; + content: ""; + transform: translateX(-50%); +} + +.nfc-screen-dot { + position: absolute; + top: 50%; + left: 50%; + width: 54px; + height: 54px; + border: 1px solid rgba(192, 132, 252, 0.55); + border-radius: 50%; + background: rgba(168, 85, 247, 0.2); + transform: translate(-50%, -50%); + animation: nfc-dot 2.5s ease-in-out infinite; +} + +.nfc-tag { + position: absolute; + right: 25px; + bottom: 30px; + display: grid; + width: 138px; + height: 138px; + place-items: center; + border: 1px solid #d9cfe0; + border-radius: 28px; + background: white; + box-shadow: 0 25px 70px rgba(52, 31, 62, 0.18); + color: var(--brand-600); + transform: rotate(8deg); +} + +.nfc-tag svg { width: 46px; } +.nfc-tag small { + position: absolute; + bottom: 18px; + color: var(--ink-muted); + font-family: var(--font-mono); + font-size: 8px; + font-weight: 700; + letter-spacing: 0.08em; +} + +.nfc-waves { + position: absolute; + top: 75px; + left: 145px; + width: 185px; + height: 160px; +} + +.nfc-waves span { + position: absolute; + top: 50%; + left: 0; + border: 2px solid var(--brand-500); + border-top-color: transparent; + border-bottom-color: transparent; + border-left-color: transparent; + border-radius: 50%; + transform: translateY(-50%); + animation: nfc-wave 2.4s ease-in-out infinite; +} + +.nfc-waves span:nth-child(1) { width: 55px; height: 70px; } +.nfc-waves span:nth-child(2) { width: 98px; height: 112px; animation-delay: 180ms; } +.nfc-waves span:nth-child(3) { width: 142px; height: 154px; animation-delay: 360ms; } +.nfc-waves i { + position: absolute; + top: 50%; + left: 15px; + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--brand-500); + box-shadow: 0 0 0 8px rgba(168, 85, 247, 0.13); + transform: translateY(-50%); +} + +.invite-file-art { + position: relative; + width: 410px; + height: 350px; +} + +.vnd-file { + position: absolute; + z-index: 2; + top: 13px; + left: 50%; + display: flex; + width: 218px; + height: 270px; + align-items: center; + flex-direction: column; + justify-content: center; + border: 1px solid #d8cbe0; + border-radius: 23px; + background: linear-gradient(145deg, #fff, #f7f0fb); + box-shadow: 0 30px 80px rgba(59, 35, 70, 0.2); + transform: translateX(-50%) rotate(-2deg); + animation: vnd-drop 4.8s cubic-bezier(0.3, 0.75, 0.3, 1) infinite; +} + +.vnd-fold { + position: absolute; + top: -1px; + right: -1px; + width: 58px; + height: 58px; + border-bottom: 1px solid #d1c1dc; + border-left: 1px solid #d1c1dc; + border-radius: 0 23px 0 16px; + background: #e7cdf9; + clip-path: polygon(0 0, 100% 100%, 0 100%); +} + +.vnd-file > svg { + width: 58px; + margin-bottom: 20px; + color: var(--brand-600); +} + +.vnd-file strong { + font-family: var(--font-display); + font-size: 16px; +} + +.vnd-file small { + color: var(--ink-muted); + font-size: 10px; +} + +.vnd-file code { + margin-top: 18px; + padding: 5px 10px; + border-radius: 7px; + background: var(--brand-100); + color: var(--brand-600); + font-family: var(--font-mono); + font-size: 10px; + font-weight: 800; +} + +.file-tray { + position: absolute; + right: 37px; + bottom: 7px; + left: 37px; + height: 73px; + border: 2px solid #cabbd2; + border-radius: 19px; + background: rgba(237, 228, 242, 0.85); + transform: perspective(250px) rotateX(55deg); +} + +.file-tray span { + position: absolute; + inset: 14px; + border: 1px dashed #aa8cbc; + border-radius: 10px; +} + +.file-art-orbit { + position: absolute; + border: 1px solid rgba(168, 85, 247, 0.2); + border-radius: 50%; +} + +.file-art-orbit-one { inset: 14px 32px 44px; animation: scene-orbits 24s linear infinite; } +.file-art-orbit-two { inset: 52px 70px 80px; border-style: dashed; animation: scene-orbits 18s linear infinite reverse; } + diff --git a/docs/components/invitation-showcase.tsx b/docs/components/invitation-showcase.tsx new file mode 100644 index 0000000..312b902 --- /dev/null +++ b/docs/components/invitation-showcase.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useState } from "react"; +import { Icon, type IconName } from "@/components/icons"; + +type Method = "qr" | "nfc" | "file"; + +const methods: Array<{ + id: Method; + icon: IconName; + label: string; + eyebrow: string; + title: string; + text: string; +}> = [ + { + id: "qr", + icon: "qr", + label: "QR code", + eyebrow: "IN THE SAME ROOM", + title: "Point. Scan. Connected.", + text: "Show the invitation on one screen and scan it from the other. No contact exchange required.", + }, + { + id: "nfc", + icon: "nfc", + label: "NFC", + eyebrow: "THE QUICKEST TAP", + title: "Bring devices together.", + text: "Write or open an invitation with an NFC tag for a physical, close-range handoff.", + }, + { + id: "file", + icon: "file", + label: ".vnd file", + eyebrow: "SEND IT ANYWHERE", + title: "An invitation that travels.", + text: "Save the small invitation as a .vnd file, then pass it through the channel you already use.", + }, +]; + +function QrArt() { + const filled = new Set([ + 0, 1, 2, 3, 4, 6, 8, 9, 10, 11, 12, 14, 16, 18, 20, 22, 24, 28, 29, 30, 31, 32, + 36, 38, 40, 42, 44, 48, 49, 50, 51, 52, 54, 56, 58, 60, 62, 64, 66, 67, 69, + 72, 73, 74, 76, 78, 79, 80, + ]); + return ( +
+
+ {Array.from({ length: 81 }, (_, index) => ( + + ))} +
+ + + + + +
+ ); +} + +function NfcArt() { + return ( +
+
+ +
+
+ + + + +
+
+ + VniDrop +
+
+ ); +} + +function FileArt() { + return ( +
+ + +
+ + + Weekend trip + VniDrop invitation + .vnd +
+
+ +
+
+ ); +} + +export function InvitationShowcase() { + const [active, setActive] = useState("qr"); + const current = methods.find((method) => method.id === active) ?? methods[0]; + + return ( +
+
+ {methods.map((method) => ( + + ))} +
+
+
+ {current.eyebrow} +

{current.title}

+

{current.text}

+
+ + + The invitation is a private access link. + Only share it with people you intend to receive the transfer. + +
+
+ +
+
+ ); +} diff --git a/docs/components/network-illustration.tsx b/docs/components/network-illustration.tsx new file mode 100644 index 0000000..5251913 --- /dev/null +++ b/docs/components/network-illustration.tsx @@ -0,0 +1,51 @@ +import { Icon } from "@/components/icons"; + +export function NetworkIllustration() { + return ( +