From 5d28c574d0654caf53ee2e99e33caa5c37274389 Mon Sep 17 00:00:00 2001 From: Abdulrhman Alkhodiry Date: Tue, 11 Nov 2025 19:58:05 +0300 Subject: [PATCH 1/3] Add initial project structure and components for Spectre password manager - Introduced .cursorrules to define mandatory usage of Context7 and project-specific rules. - Created Cargo.toml for the spectre-app with dependencies and features. - Added .gitignore to exclude build artifacts and temporary files. - Implemented core components including Header, Footer, and form fields for user input. - Developed main application logic in src/main.rs, integrating Dioxus for UI rendering. - Established worker for background key generation in src/worker.rs. - Included Tailwind CSS for styling and layout. - Added README.md for project documentation and setup instructions. --- .cursorrules | 73 ++ Cargo.toml | 13 +- spectre-app/.gitignore | 7 + spectre-app/AGENTS.md | 265 +++++ spectre-app/Cargo.toml | 25 + spectre-app/Dioxus.toml | 22 + spectre-app/README.md | 50 + spectre-app/assets/favicon.ico | Bin 0 -> 132770 bytes spectre-app/assets/header.svg | 20 + spectre-app/assets/main.css | 14 + spectre-app/assets/tailwind.css | 967 ++++++++++++++++++ spectre-app/assets/worker.js | 64 ++ spectre-app/src/components/footer.rs | 12 + spectre-app/src/components/form_fields.rs | 165 +++ spectre-app/src/components/header.rs | 17 + spectre-app/src/components/mod.rs | 12 + .../src/components/password_type_selector.rs | 89 ++ spectre-app/src/components/site_password.rs | 64 ++ spectre-app/src/main.rs | 286 ++++++ spectre-app/src/worker.rs | 133 +++ spectre-app/tailwind.css | 1 + src/bin/main.rs | 1 + src/lib.rs | 8 +- src/marshal.rs | 38 +- src/util.rs | 63 +- 25 files changed, 2370 insertions(+), 39 deletions(-) create mode 100644 .cursorrules create mode 100644 spectre-app/.gitignore create mode 100644 spectre-app/AGENTS.md create mode 100644 spectre-app/Cargo.toml create mode 100644 spectre-app/Dioxus.toml create mode 100644 spectre-app/README.md create mode 100644 spectre-app/assets/favicon.ico create mode 100644 spectre-app/assets/header.svg create mode 100644 spectre-app/assets/main.css create mode 100644 spectre-app/assets/tailwind.css create mode 100644 spectre-app/assets/worker.js create mode 100644 spectre-app/src/components/footer.rs create mode 100644 spectre-app/src/components/form_fields.rs create mode 100644 spectre-app/src/components/header.rs create mode 100644 spectre-app/src/components/mod.rs create mode 100644 spectre-app/src/components/password_type_selector.rs create mode 100644 spectre-app/src/components/site_password.rs create mode 100644 spectre-app/src/main.rs create mode 100644 spectre-app/src/worker.rs create mode 100644 spectre-app/tailwind.css diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..2bbcf92 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,73 @@ +# Cursor Rules - Context7 Required + +## MANDATORY: Always Use Context7 + +**CRITICAL RULE**: Before answering any question, implementing any feature, or solving any problem, you MUST use Context7 to look up relevant documentation and best practices. + +### When to Use Context7 + +1. **Before implementing any feature**: Always search Context7 for library documentation, examples, and best practices +2. **When encountering errors**: Use Context7 to find solutions and troubleshooting guides +3. **When using external libraries**: Always fetch library documentation via Context7 before using APIs +4. **When optimizing code**: Search Context7 for performance best practices and patterns +5. **When designing architecture**: Consult Context7 for design patterns and architectural guidance + +### How to Use Context7 + +1. **Resolve library ID first**: Use `mcp_context7_resolve-library-id` to find the correct library identifier +2. **Fetch documentation**: Use `mcp_context7_get-library-docs` with the resolved library ID +3. **Apply best practices**: Use the documentation to inform your implementation +4. **Cite sources**: Reference the Context7 documentation when explaining your approach + +### Example Workflow + +``` +User asks: "How do I implement X?" +1. Use Context7 to resolve library ID for relevant library +2. Fetch documentation with specific topic +3. Review examples and best practices +4. Implement solution based on Context7 documentation +5. Explain approach referencing Context7 sources +``` + +### Prohibited Behavior + +- ❌ DO NOT implement features without consulting Context7 first +- ❌ DO NOT guess API usage - always look it up via Context7 +- ❌ DO NOT skip Context7 even for "simple" questions +- ❌ DO NOT use outdated patterns - always check Context7 for current best practices + +### Exception + +The ONLY exception is when Context7 is explicitly unavailable or the user explicitly requests to skip it. Otherwise, Context7 usage is MANDATORY. + +--- + +## Project-Specific Rules + +### Technology Stack +- **Framework**: Dioxus 0.7.1 +- **Language**: Rust +- **Styling**: Tailwind CSS +- **Platform**: Web (WASM) + +### Code Style +- Use Rust naming conventions (snake_case for functions, PascalCase for types) +- Prefer explicit types over type inference when it improves readability +- Use `use_signal`, `use_memo`, `use_effect` hooks appropriately +- Keep components focused and single-purpose +- Organize code into separate component files + +### Component Organization +- Components should be in `src/components/` directory +- Each component should be in its own file +- Use `mod.rs` to re-export components +- Pass signals and event handlers as props + +### Best Practices +- Always handle async operations with `spawn` and proper error handling +- Use debouncing for expensive operations +- Cache expensive computations (like scrypt key derivation) +- Provide visual feedback for loading states +- Ensure accessibility with proper ARIA labels and keyboard navigation + diff --git a/Cargo.toml b/Cargo.toml index ab0c3ad..fd04532 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,10 +23,11 @@ sha2 = "0.10" aes = "0.8" cbc = "0.1" rand = "0.8" +getrandom = { version = "0.2", features = ["js"] } # CLI -clap = { version = "4.5", features = ["derive", "env"] } -rpassword = "7.3" +clap = { version = "4.5", features = ["derive", "env"], optional = true } +rpassword = { version = "7.3", optional = true } # Serialization serde = { version = "1.0", features = ["derive"] } @@ -34,5 +35,9 @@ serde_json = "1.0" # Utilities thiserror = "1.0" -chrono = { version = "0.4", features = ["serde"] } -dirs = "5.0" +chrono = { version = "0.4", features = ["serde", "clock", "wasmbind"], default-features = false } +dirs = { version = "5.0", optional = true } + +[features] +default = ["cli"] +cli = ["clap", "rpassword", "dirs"] diff --git a/spectre-app/.gitignore b/spectre-app/.gitignore new file mode 100644 index 0000000..80aab8e --- /dev/null +++ b/spectre-app/.gitignore @@ -0,0 +1,7 @@ +# Generated by Cargo +# will have compiled files and executables +/target +.DS_Store + +# These are backup files generated by rustfmt +**/*.rs.bk diff --git a/spectre-app/AGENTS.md b/spectre-app/AGENTS.md new file mode 100644 index 0000000..0f3190b --- /dev/null +++ b/spectre-app/AGENTS.md @@ -0,0 +1,265 @@ +You are an expert [0.7 Dioxus](https://dioxuslabs.com/learn/0.7) assistant. Dioxus 0.7 changes every api in dioxus. Only use this up to date documentation. `cx`, `Scope`, and `use_state` are gone + +Provide concise code examples with detailed descriptions + +# Dioxus Dependency + +You can add Dioxus to your `Cargo.toml` like this: + +```toml +[dependencies] +dioxus = { version = "0.7.1" } + +[features] +default = ["web", "webview", "server"] +web = ["dioxus/web"] +webview = ["dioxus/desktop"] +server = ["dioxus/server"] +``` + +# Launching your application + +You need to create a main function that sets up the Dioxus runtime and mounts your root component. + +```rust +use dioxus::prelude::*; + +fn main() { + dioxus::launch(App); +} + +#[component] +fn App() -> Element { + rsx! { "Hello, Dioxus!" } +} +``` + +Then serve with `dx serve`: + +```sh +curl -sSL http://dioxus.dev/install.sh | sh +dx serve +``` + +# UI with RSX + +```rust +rsx! { + div { + class: "container", // Attribute + color: "red", // Inline styles + width: if condition { "100%" }, // Conditional attributes + "Hello, Dioxus!" + } + // Prefer loops over iterators + for i in 0..5 { + div { "{i}" } // use elements or components directly in loops + } + if condition { + div { "Condition is true!" } // use elements or components directly in conditionals + } + + {children} // Expressions are wrapped in brace + {(0..5).map(|i| rsx! { span { "Item {i}" } })} // Iterators must be wrapped in braces +} +``` + +# Assets + +The asset macro can be used to link to local files to use in your project. All links start with `/` and are relative to the root of your project. + +```rust +rsx! { + img { + src: asset!("/assets/image.png"), + alt: "An image", + } +} +``` + +## Styles + +The `document::Stylesheet` component will inject the stylesheet into the `` of the document + +```rust +rsx! { + document::Stylesheet { + href: asset!("/assets/styles.css"), + } +} +``` + +# Components + +Components are the building blocks of apps + +* Component are functions annotated with the `#[component]` macro. +* The function name must start with a capital letter or contain an underscore. +* A component re-renders only under two conditions: + 1. Its props change (as determined by `PartialEq`). + 2. An internal reactive state it depends on is updated. + +```rust +#[component] +fn Input(mut value: Signal) -> Element { + rsx! { + input { + value, + oninput: move |e| { + *value.write() = e.value(); + }, + onkeydown: move |e| { + if e.key() == Key::Enter { + value.write().clear(); + } + }, + } + } +} +``` + +Each component accepts function arguments (props) + +* Props must be owned values, not references. Use `String` and `Vec` instead of `&str` or `&[T]`. +* Props must implement `PartialEq` and `Clone`. +* To make props reactive and copy, you can wrap the type in `ReadOnlySignal`. Any reactive state like memos and resources that read `ReadOnlySignal` props will automatically re-run when the prop changes. + +# State + +A signal is a wrapper around a value that automatically tracks where it's read and written. Changing a signal's value causes code that relies on the signal to rerun. + +## Local State + +The `use_signal` hook creates state that is local to a single component. You can call the signal like a function (e.g. `my_signal()`) to clone the value, or use `.read()` to get a reference. `.write()` gets a mutable reference to the value. + +Use `use_memo` to create a memoized value that recalculates when its dependencies change. Memos are useful for expensive calculations that you don't want to repeat unnecessarily. + +```rust +#[component] +fn Counter() -> Element { + let mut count = use_signal(|| 0); + let mut doubled = use_memo(move || count() * 2); // doubled will re-run when count changes because it reads the signal + + rsx! { + h1 { "Count: {count}" } // Counter will re-render when count changes because it reads the signal + h2 { "Doubled: {doubled}" } + button { + onclick: move |_| *count.write() += 1, // Writing to the signal rerenders Counter + "Increment" + } + button { + onclick: move |_| count.with_mut(|count| *count += 1), // use with_mut to mutate the signal + "Increment with with_mut" + } + } +} +``` + +## Context API + +The Context API allows you to share state down the component tree. A parent provides the state using `use_context_provider`, and any child can access it with `use_context` + +```rust +#[component] +fn App() -> Element { + let mut theme = use_signal(|| "light".to_string()); + use_context_provider(|| theme); // Provide a type to children + rsx! { Child {} } +} + +#[component] +fn Child() -> Element { + let theme = use_context::>(); // Consume the same type + rsx! { + div { + "Current theme: {theme}" + } + } +} +``` + +# Async + +For state that depends on an asynchronous operation (like a network request), Dioxus provides a hook called `use_resource`. This hook manages the lifecycle of the async task and provides the result to your component. + +* The `use_resource` hook takes an `async` closure. It re-runs this closure whenever any signals it depends on (reads) are updated +* The `Resource` object returned can be in several states when read: +1. `None` if the resource is still loading +2. `Some(value)` if the resource has successfully loaded + +```rust +let mut dog = use_resource(move || async move { + // api request +}); + +match dog() { + Some(dog_info) => rsx! { Dog { dog_info } }, + None => rsx! { "Loading..." }, +} +``` + +# Routing + +All possible routes are defined in a single Rust `enum` that derives `Routable`. Each variant represents a route and is annotated with `#[route("/path")]`. Dynamic Segments can capture parts of the URL path as parameters by using `:name` in the route string. These become fields in the enum variant. + +The `Router {}` component is the entry point that manages rendering the correct component for the current URL. + +You can use the `#[layout(NavBar)]` to create a layout shared between pages and place an `Outlet {}` inside your layout component. The child routes will be rendered in the outlet. + +```rust +#[derive(Routable, Clone, PartialEq)] +enum Route { + #[layout(NavBar)] // This will use NavBar as the layout for all routes + #[route("/")] + Home {}, + #[route("/blog/:id")] // Dynamic segment + BlogPost { id: i32 }, +} + +#[component] +fn NavBar() -> Element { + rsx! { + a { href: "/", "Home" } + Outlet {} // Renders Home or BlogPost + } +} + +#[component] +fn App() -> Element { + rsx! { Router:: {} } +} +``` + +```toml +dioxus = { version = "0.7.1", features = ["router"] } +``` + +# Fullstack + +Fullstack enables server rendering and ipc calls. It uses Cargo features (`server` and a client feature like `web`) to split the code into a server and client binaries. + +```toml +dioxus = { version = "0.7.1", features = ["fullstack"] } +``` + +## Server Functions + +Use the `#[post]` / `#[get]` macros to define an `async` function that will only run on the server. On the server, this macro generates an API endpoint. On the client, it generates a function that makes an HTTP request to that endpoint. + +```rust +#[post("/api/double/:path/&query")] +async fn double_server(number: i32, path: String, query: i32) -> Result { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + Ok(number * 2) +} +``` + +## Hydration + +Hydration is the process of making a server-rendered HTML page interactive on the client. The server sends the initial HTML, and then the client-side runs, attaches event listeners, and takes control of future rendering. + +### Errors +The initial UI rendered by the component on the client must be identical to the UI rendered on the server. + +* Use the `use_server_future` hook instead of `use_resource`. It runs the future on the server, serializes the result, and sends it to the client, ensuring the client has the data immediately for its first render. +* Any code that relies on browser-specific APIs (like accessing `localStorage`) must be run *after* hydration. Place this code inside a `use_effect` hook. diff --git a/spectre-app/Cargo.toml b/spectre-app/Cargo.toml new file mode 100644 index 0000000..e48c733 --- /dev/null +++ b/spectre-app/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "spectre-app" +version = "0.1.0" +authors = ["Abdulrhman Alkhodiry "] +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +dioxus = { version = "0.7.1", features = ["router"] } +spectre-rs = { path = "..", default-features = false } +gloo-timers = { version = "0.3", features = ["futures"] } +web-sys = { version = "0.3", features = ["Window", "Navigator", "Clipboard", "Worker", "WorkerGlobalScope", "MessageEvent", "MessagePort", "DedicatedWorkerGlobalScope", "Blob", "Url"] } +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +js-sys = "0.3" +futures = "0.3" + +[features] +default = ["web"] +web = ["dioxus/web"] +desktop = ["dioxus/desktop"] +mobile = ["dioxus/mobile"] diff --git a/spectre-app/Dioxus.toml b/spectre-app/Dioxus.toml new file mode 100644 index 0000000..4a752e2 --- /dev/null +++ b/spectre-app/Dioxus.toml @@ -0,0 +1,22 @@ +[application] +name = "spectre-app" +out_dir = "dist" + +[web.app] +title = "Spectre Password Generator" + +[web.watcher] +reload_html = true +watch_path = ["src", "assets"] + +[web.resource] +style = [] +script = [] + +[web.resource.dev] +script = [] + +[web] +# The port the fullstack server will listen on. +# Defaults to 8080 if not specified. +port = 3000 # Replace 3000 with your desired port number \ No newline at end of file diff --git a/spectre-app/README.md b/spectre-app/README.md new file mode 100644 index 0000000..12681a5 --- /dev/null +++ b/spectre-app/README.md @@ -0,0 +1,50 @@ +# Development + +Your new bare-bones project includes minimal organization with a single `main.rs` file and a few assets. + +``` +project/ +├─ assets/ # Any assets that are used by the app should be placed here +├─ src/ +│ ├─ main.rs # main.rs is the entry point to your application and currently contains all components for the app +├─ Cargo.toml # The Cargo.toml file defines the dependencies and feature flags for your project +``` + +### Automatic Tailwind (Dioxus 0.7+) + +As of Dioxus 0.7, there no longer is a need to manually install tailwind. Simply `dx serve` and you're good to go! + +Automatic tailwind is supported by checking for a file called `tailwind.css` in your app's manifest directory (next to Cargo.toml). To customize the file, use the dioxus.toml: + +```toml +[application] +tailwind_input = "my.css" +tailwind_output = "assets/out.css" # also customize the location of the out file! +``` + +### Tailwind Manual Install + +To use tailwind plugins or manually customize tailwind, you can can install the Tailwind CLI and use it directly. + +### Tailwind +1. Install npm: https://docs.npmjs.com/downloading-and-installing-node-js-and-npm +2. Install the Tailwind CSS CLI: https://tailwindcss.com/docs/installation/tailwind-cli +3. Run the following command in the root of the project to start the Tailwind CSS compiler: + +```bash +npx @tailwindcss/cli -i ./input.css -o ./assets/tailwind.css --watch +``` + +### Serving Your App + +Run the following command in the root of your project to start developing with the default platform: + +```bash +dx serve +``` + +To run for a different platform, use the `--platform platform` flag. E.g. +```bash +dx serve --platform desktop +``` + diff --git a/spectre-app/assets/favicon.ico b/spectre-app/assets/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..eed0c09735ab94e724c486a053c367cf7ee3d694 GIT binary patch literal 132770 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 literal 0 HcmV?d00001 diff --git a/spectre-app/assets/header.svg b/spectre-app/assets/header.svg new file mode 100644 index 0000000..59c96f2 --- /dev/null +++ b/spectre-app/assets/header.svg @@ -0,0 +1,20 @@ + \ No newline at end of file diff --git a/spectre-app/assets/main.css b/spectre-app/assets/main.css new file mode 100644 index 0000000..ec1a1ba --- /dev/null +++ b/spectre-app/assets/main.css @@ -0,0 +1,14 @@ +/* App-wide base styling */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/spectre-app/assets/tailwind.css b/spectre-app/assets/tailwind.css new file mode 100644 index 0000000..3ac7290 --- /dev/null +++ b/spectre-app/assets/tailwind.css @@ -0,0 +1,967 @@ +/*! tailwindcss v4.1.5 | MIT License | https://tailwindcss.com */ +@layer properties; +@layer theme, base, components, utilities; +@layer theme { + :root, :host { + --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', + 'Noto Color Emoji'; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', + monospace; + --color-red-300: oklch(80.8% 0.114 19.571); + --color-red-400: oklch(70.4% 0.191 22.216); + --color-red-500: oklch(63.7% 0.237 25.331); + --color-yellow-200: oklch(94.5% 0.129 101.54); + --color-yellow-300: oklch(90.5% 0.182 98.111); + --color-yellow-400: oklch(85.2% 0.199 91.936); + --color-green-400: oklch(79.2% 0.209 151.711); + --color-emerald-300: oklch(84.5% 0.143 164.978); + --color-emerald-400: oklch(76.5% 0.177 163.223); + --color-teal-100: oklch(95.3% 0.051 180.801); + --color-teal-200: oklch(91% 0.096 180.426); + --color-teal-300: oklch(85.5% 0.138 181.071); + --color-teal-400: oklch(77.7% 0.152 181.912); + --color-teal-500: oklch(70.4% 0.14 182.503); + --color-teal-600: oklch(60% 0.118 184.704); + --color-teal-700: oklch(51.1% 0.096 186.391); + --color-teal-800: oklch(43.7% 0.078 188.216); + --color-teal-900: oklch(38.6% 0.063 188.416); + --color-teal-950: oklch(27.7% 0.046 192.524); + --color-cyan-200: oklch(91.7% 0.08 205.041); + --color-cyan-300: oklch(86.5% 0.127 207.078); + --color-cyan-400: oklch(78.9% 0.154 211.53); + --color-cyan-500: oklch(71.5% 0.143 215.221); + --color-blue-300: oklch(80.9% 0.105 251.813); + --color-slate-300: oklch(86.9% 0.022 252.894); + --color-slate-400: oklch(70.4% 0.04 256.788); + --color-slate-500: oklch(55.4% 0.046 257.417); + --color-slate-600: oklch(44.6% 0.043 257.281); + --color-slate-700: oklch(37.2% 0.044 257.287); + --color-slate-800: oklch(27.9% 0.041 260.031); + --color-slate-900: oklch(20.8% 0.042 265.755); + --color-white: #fff; + --spacing: 0.25rem; + --container-xl: 36rem; + --container-2xl: 42rem; + --container-3xl: 48rem; + --container-4xl: 56rem; + --container-7xl: 80rem; + --text-xs: 0.75rem; + --text-xs--line-height: calc(1 / 0.75); + --text-sm: 0.875rem; + --text-sm--line-height: calc(1.25 / 0.875); + --text-base: 1rem; + --text-base--line-height: calc(1.5 / 1); + --text-lg: 1.125rem; + --text-lg--line-height: calc(1.75 / 1.125); + --text-xl: 1.25rem; + --text-xl--line-height: calc(1.75 / 1.25); + --text-2xl: 1.5rem; + --text-2xl--line-height: calc(2 / 1.5); + --text-3xl: 1.875rem; + --text-3xl--line-height: calc(2.25 / 1.875); + --text-4xl: 2.25rem; + --text-4xl--line-height: calc(2.5 / 2.25); + --text-5xl: 3rem; + --text-5xl--line-height: 1; + --font-weight-light: 300; + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --tracking-wide: 0.025em; + --tracking-widest: 0.1em; + --radius-lg: 0.5rem; + --radius-xl: 0.75rem; + --radius-2xl: 1rem; + --radius-3xl: 1.5rem; + --animate-spin: spin 1s linear infinite; + --blur-sm: 8px; + --blur-xl: 24px; + --blur-2xl: 40px; + --blur-3xl: 64px; + --default-transition-duration: 150ms; + --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + --default-font-family: var(--font-sans); + --default-mono-font-family: var(--font-mono); + } +} +@layer base { + *, ::after, ::before, ::backdrop, ::file-selector-button { + box-sizing: border-box; + margin: 0; + padding: 0; + border: 0 solid; + } + html, :host { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + tab-size: 4; + font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'); + font-feature-settings: var(--default-font-feature-settings, normal); + font-variation-settings: var(--default-font-variation-settings, normal); + -webkit-tap-highlight-color: transparent; + } + hr { + height: 0; + color: inherit; + border-top-width: 1px; + } + abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; + } + h1, h2, h3, h4, h5, h6 { + font-size: inherit; + font-weight: inherit; + } + a { + color: inherit; + -webkit-text-decoration: inherit; + text-decoration: inherit; + } + b, strong { + font-weight: bolder; + } + code, kbd, samp, pre { + font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace); + font-feature-settings: var(--default-mono-font-feature-settings, normal); + font-variation-settings: var(--default-mono-font-variation-settings, normal); + font-size: 1em; + } + small { + font-size: 80%; + } + sub, sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; + } + sub { + bottom: -0.25em; + } + sup { + top: -0.5em; + } + table { + text-indent: 0; + border-color: inherit; + border-collapse: collapse; + } + :-moz-focusring { + outline: auto; + } + progress { + vertical-align: baseline; + } + summary { + display: list-item; + } + ol, ul, menu { + list-style: none; + } + img, svg, video, canvas, audio, iframe, embed, object { + display: block; + vertical-align: middle; + } + img, video { + max-width: 100%; + height: auto; + } + button, input, select, optgroup, textarea, ::file-selector-button { + font: inherit; + font-feature-settings: inherit; + font-variation-settings: inherit; + letter-spacing: inherit; + color: inherit; + border-radius: 0; + background-color: transparent; + opacity: 1; + } + :where(select:is([multiple], [size])) optgroup { + font-weight: bolder; + } + :where(select:is([multiple], [size])) optgroup option { + padding-inline-start: 20px; + } + ::file-selector-button { + margin-inline-end: 4px; + } + ::placeholder { + opacity: 1; + } + @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { + ::placeholder { + color: currentcolor; + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, currentcolor 50%, transparent); + } + } + } + textarea { + resize: vertical; + } + ::-webkit-search-decoration { + -webkit-appearance: none; + } + ::-webkit-date-and-time-value { + min-height: 1lh; + text-align: inherit; + } + ::-webkit-datetime-edit { + display: inline-flex; + } + ::-webkit-datetime-edit-fields-wrapper { + padding: 0; + } + ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { + padding-block: 0; + } + :-moz-ui-invalid { + box-shadow: none; + } + button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button { + appearance: button; + } + ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { + height: auto; + } + [hidden]:where(:not([hidden='until-found'])) { + display: none !important; + } +} +@layer utilities { + .visible { + visibility: visible; + } + .absolute { + position: absolute; + } + .relative { + position: relative; + } + .static { + position: static; + } + .top-6 { + top: calc(var(--spacing) * 6); + } + .left-0 { + left: calc(var(--spacing) * 0); + } + .z-50 { + z-index: 50; + } + .container { + width: 100%; + @media (width >= 40rem) { + max-width: 40rem; + } + @media (width >= 48rem) { + max-width: 48rem; + } + @media (width >= 64rem) { + max-width: 64rem; + } + @media (width >= 80rem) { + max-width: 80rem; + } + @media (width >= 96rem) { + max-width: 96rem; + } + } + .mt-2 { + margin-top: calc(var(--spacing) * 2); + } + .mt-6 { + margin-top: calc(var(--spacing) * 6); + } + .mb-2 { + margin-bottom: calc(var(--spacing) * 2); + } + .mb-4 { + margin-bottom: calc(var(--spacing) * 4); + } + .mb-6 { + margin-bottom: calc(var(--spacing) * 6); + } + .mb-8 { + margin-bottom: calc(var(--spacing) * 8); + } + .block { + display: block; + } + .flex { + display: flex; + } + .grid { + display: grid; + } + .hidden { + display: none; + } + .inline { + display: inline; + } + .inline-flex { + display: inline-flex; + } + .table { + display: table; + } + .min-h-screen { + min-height: 100vh; + } + .w-64 { + width: calc(var(--spacing) * 64); + } + .w-72 { + width: calc(var(--spacing) * 72); + } + .w-full { + width: 100%; + } + .max-w-4xl { + max-width: var(--container-4xl); + } + .border-collapse { + border-collapse: collapse; + } + .transform { + transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); + } + .animate-spin { + animation: var(--animate-spin); + } + .cursor-help { + cursor: help; + } + .cursor-not-allowed { + cursor: not-allowed; + } + .cursor-pointer { + cursor: pointer; + } + .resize { + resize: both; + } + .grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .items-center { + align-items: center; + } + .justify-between { + justify-content: space-between; + } + .justify-center { + justify-content: center; + } + .gap-2 { + gap: calc(var(--spacing) * 2); + } + .gap-3 { + gap: calc(var(--spacing) * 3); + } + .space-y-6 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse))); + } + } + .rounded-3xl { + border-radius: var(--radius-3xl); + } + .rounded-full { + border-radius: calc(infinity * 1px); + } + .rounded-xl { + border-radius: var(--radius-xl); + } + .border { + border-style: var(--tw-border-style); + border-width: 1px; + } + .border-slate-600 { + border-color: var(--color-slate-600); + } + .border-slate-600\/50 { + border-color: color-mix(in srgb, oklch(44.6% 0.043 257.281) 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--color-slate-600) 50%, transparent); + } + } + .border-slate-700 { + border-color: var(--color-slate-700); + } + .border-slate-700\/50 { + border-color: color-mix(in srgb, oklch(37.2% 0.044 257.287) 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--color-slate-700) 50%, transparent); + } + } + .border-yellow-400 { + border-color: var(--color-yellow-400); + } + .border-yellow-400\/50 { + border-color: color-mix(in srgb, oklch(85.2% 0.199 91.936) 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--color-yellow-400) 50%, transparent); + } + } + .bg-cyan-400 { + background-color: var(--color-cyan-400); + } + .bg-slate-700 { + background-color: var(--color-slate-700); + } + .bg-slate-700\/30 { + background-color: color-mix(in srgb, oklch(37.2% 0.044 257.287) 30%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-slate-700) 30%, transparent); + } + } + .bg-slate-700\/40 { + background-color: color-mix(in srgb, oklch(37.2% 0.044 257.287) 40%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-slate-700) 40%, transparent); + } + } + .bg-slate-700\/50 { + background-color: color-mix(in srgb, oklch(37.2% 0.044 257.287) 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-slate-700) 50%, transparent); + } + } + .bg-slate-800 { + background-color: var(--color-slate-800); + } + .bg-slate-800\/40 { + background-color: color-mix(in srgb, oklch(27.9% 0.041 260.031) 40%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-slate-800) 40%, transparent); + } + } + .bg-slate-800\/95 { + background-color: color-mix(in srgb, oklch(27.9% 0.041 260.031) 95%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-slate-800) 95%, transparent); + } + } + .bg-yellow-400 { + background-color: var(--color-yellow-400); + } + .bg-yellow-400\/10 { + background-color: color-mix(in srgb, oklch(85.2% 0.199 91.936) 10%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-yellow-400) 10%, transparent); + } + } + .bg-gradient-to-r { + --tw-gradient-position: to right in oklab; + background-image: linear-gradient(var(--tw-gradient-stops)); + } + .from-cyan-400 { + --tw-gradient-from: var(--color-cyan-400); + --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); + } + .to-emerald-400 { + --tw-gradient-to: var(--color-emerald-400); + --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); + } + .p-3 { + padding: calc(var(--spacing) * 3); + } + .p-4 { + padding: calc(var(--spacing) * 4); + } + .p-8 { + padding: calc(var(--spacing) * 8); + } + .px-3 { + padding-inline: calc(var(--spacing) * 3); + } + .px-6 { + padding-inline: calc(var(--spacing) * 6); + } + .py-1 { + padding-block: calc(var(--spacing) * 1); + } + .py-3 { + padding-block: calc(var(--spacing) * 3); + } + .py-4 { + padding-block: calc(var(--spacing) * 4); + } + .pl-1 { + padding-left: calc(var(--spacing) * 1); + } + .text-center { + text-align: center; + } + .text-2xl { + font-size: var(--text-2xl); + line-height: var(--tw-leading, var(--text-2xl--line-height)); + } + .text-4xl { + font-size: var(--text-4xl); + line-height: var(--tw-leading, var(--text-4xl--line-height)); + } + .text-lg { + font-size: var(--text-lg); + line-height: var(--tw-leading, var(--text-lg--line-height)); + } + .text-sm { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + .text-xl { + font-size: var(--text-xl); + line-height: var(--tw-leading, var(--text-xl--line-height)); + } + .text-xs { + font-size: var(--text-xs); + line-height: var(--tw-leading, var(--text-xs--line-height)); + } + .text-\[11px\] { + font-size: 11px; + } + .font-light { + --tw-font-weight: var(--font-weight-light); + font-weight: var(--font-weight-light); + } + .font-medium { + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + } + .font-normal { + --tw-font-weight: var(--font-weight-normal); + font-weight: var(--font-weight-normal); + } + .tracking-wide { + --tw-tracking: var(--tracking-wide); + letter-spacing: var(--tracking-wide); + } + .tracking-widest { + --tw-tracking: var(--tracking-widest); + letter-spacing: var(--tracking-widest); + } + .text-cyan-400 { + color: var(--color-cyan-400); + } + .text-slate-300 { + color: var(--color-slate-300); + } + .text-slate-400 { + color: var(--color-slate-400); + } + .text-slate-500 { + color: var(--color-slate-500); + } + .text-slate-600 { + color: var(--color-slate-600); + } + .text-slate-900 { + color: var(--color-slate-900); + } + .text-white { + color: var(--color-white); + } + .text-yellow-200 { + color: var(--color-yellow-200); + } + .text-yellow-200\/90 { + color: color-mix(in srgb, oklch(94.5% 0.129 101.54) 90%, transparent); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--color-yellow-200) 90%, transparent); + } + } + .text-yellow-400 { + color: var(--color-yellow-400); + } + .italic { + font-style: italic; + } + .underline { + text-decoration-line: underline; + } + .placeholder-slate-500 { + &::placeholder { + color: var(--color-slate-500); + } + } + .placeholder-slate-500\/50 { + &::placeholder { + color: color-mix(in srgb, oklch(55.4% 0.046 257.417) 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--color-slate-500) 50%, transparent); + } + } + } + .shadow-lg { + --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-xl { + --tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-cyan-500 { + --tw-shadow-color: oklch(71.5% 0.143 215.221); + @supports (color: color-mix(in lab, red, red)) { + --tw-shadow-color: color-mix(in oklab, var(--color-cyan-500) var(--tw-shadow-alpha), transparent); + } + } + .shadow-cyan-500\/20 { + --tw-shadow-color: color-mix(in srgb, oklch(71.5% 0.143 215.221) 20%, transparent); + @supports (color: color-mix(in lab, red, red)) { + --tw-shadow-color: color-mix(in oklab, color-mix(in oklab, var(--color-cyan-500) 20%, transparent) var(--tw-shadow-alpha), transparent); + } + } + .outline { + outline-style: var(--tw-outline-style); + outline-width: 1px; + } + .backdrop-blur-sm { + --tw-backdrop-blur: blur(var(--blur-sm)); + -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + } + .backdrop-filter { + -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + } + .transition { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, visibility, content-visibility, overlay, pointer-events; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .group-hover\:block { + &:is(:where(.group):hover *) { + @media (hover: hover) { + display: block; + } + } + } + .hover\:bg-cyan-300 { + &:hover { + @media (hover: hover) { + background-color: var(--color-cyan-300); + } + } + } + .hover\:bg-slate-600\/50 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, oklch(44.6% 0.043 257.281) 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-slate-600) 50%, transparent); + } + } + } + } + .hover\:from-cyan-300 { + &:hover { + @media (hover: hover) { + --tw-gradient-from: var(--color-cyan-300); + --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); + } + } + } + .hover\:to-emerald-300 { + &:hover { + @media (hover: hover) { + --tw-gradient-to: var(--color-emerald-300); + --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); + } + } + } + .focus\:border-cyan-400\/50 { + &:focus { + border-color: color-mix(in srgb, oklch(78.9% 0.154 211.53) 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--color-cyan-400) 50%, transparent); + } + } + } + .focus\:border-yellow-400\/50 { + &:focus { + border-color: color-mix(in srgb, oklch(85.2% 0.199 91.936) 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--color-yellow-400) 50%, transparent); + } + } + } + .focus\:bg-slate-700\/40 { + &:focus { + background-color: color-mix(in srgb, oklch(37.2% 0.044 257.287) 40%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-slate-700) 40%, transparent); + } + } + } + .focus\:bg-slate-700\/60 { + &:focus { + background-color: color-mix(in srgb, oklch(37.2% 0.044 257.287) 60%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-slate-700) 60%, transparent); + } + } + } + .focus\:outline-none { + &:focus { + --tw-outline-style: none; + outline-style: none; + } + } + .md\:p-12 { + @media (width >= 48rem) { + padding: calc(var(--spacing) * 12); + } + } + .md\:text-5xl { + @media (width >= 48rem) { + font-size: var(--text-5xl); + line-height: var(--tw-leading, var(--text-5xl--line-height)); + } + } +} +@property --tw-rotate-x { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-y { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-z { + syntax: "*"; + inherits: false; +} +@property --tw-skew-x { + syntax: "*"; + inherits: false; +} +@property --tw-skew-y { + syntax: "*"; + inherits: false; +} +@property --tw-space-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-border-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-gradient-position { + syntax: "*"; + inherits: false; +} +@property --tw-gradient-from { + syntax: ""; + inherits: false; + initial-value: #0000; +} +@property --tw-gradient-via { + syntax: ""; + inherits: false; + initial-value: #0000; +} +@property --tw-gradient-to { + syntax: ""; + inherits: false; + initial-value: #0000; +} +@property --tw-gradient-stops { + syntax: "*"; + inherits: false; +} +@property --tw-gradient-via-stops { + syntax: "*"; + inherits: false; +} +@property --tw-gradient-from-position { + syntax: ""; + inherits: false; + initial-value: 0%; +} +@property --tw-gradient-via-position { + syntax: ""; + inherits: false; + initial-value: 50%; +} +@property --tw-gradient-to-position { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-font-weight { + syntax: "*"; + inherits: false; +} +@property --tw-tracking { + syntax: "*"; + inherits: false; +} +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-inset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-inset { + syntax: "*"; + inherits: false; +} +@property --tw-ring-offset-width { + syntax: ""; + inherits: false; + initial-value: 0px; +} +@property --tw-ring-offset-color { + syntax: "*"; + inherits: false; + initial-value: #fff; +} +@property --tw-ring-offset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-backdrop-blur { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-brightness { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-contrast { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-grayscale { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-hue-rotate { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-invert { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-opacity { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-saturate { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-sepia { + syntax: "*"; + inherits: false; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +@layer properties { + @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { + *, ::before, ::after, ::backdrop { + --tw-rotate-x: initial; + --tw-rotate-y: initial; + --tw-rotate-z: initial; + --tw-skew-x: initial; + --tw-skew-y: initial; + --tw-space-y-reverse: 0; + --tw-border-style: solid; + --tw-gradient-position: initial; + --tw-gradient-from: #0000; + --tw-gradient-via: #0000; + --tw-gradient-to: #0000; + --tw-gradient-stops: initial; + --tw-gradient-via-stops: initial; + --tw-gradient-from-position: 0%; + --tw-gradient-via-position: 50%; + --tw-gradient-to-position: 100%; + --tw-font-weight: initial; + --tw-tracking: initial; + --tw-shadow: 0 0 #0000; + --tw-shadow-color: initial; + --tw-shadow-alpha: 100%; + --tw-inset-shadow: 0 0 #0000; + --tw-inset-shadow-color: initial; + --tw-inset-shadow-alpha: 100%; + --tw-ring-color: initial; + --tw-ring-shadow: 0 0 #0000; + --tw-inset-ring-color: initial; + --tw-inset-ring-shadow: 0 0 #0000; + --tw-ring-inset: initial; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-offset-shadow: 0 0 #0000; + --tw-outline-style: solid; + --tw-backdrop-blur: initial; + --tw-backdrop-brightness: initial; + --tw-backdrop-contrast: initial; + --tw-backdrop-grayscale: initial; + --tw-backdrop-hue-rotate: initial; + --tw-backdrop-invert: initial; + --tw-backdrop-opacity: initial; + --tw-backdrop-saturate: initial; + --tw-backdrop-sepia: initial; + } + } +} diff --git a/spectre-app/assets/worker.js b/spectre-app/assets/worker.js new file mode 100644 index 0000000..1224b55 --- /dev/null +++ b/spectre-app/assets/worker.js @@ -0,0 +1,64 @@ +// Worker script for key generation +// This worker loads WASM and runs the key generation computation +// Note: The WASM module path needs to match your Dioxus build output + +let wasmModule = null; +let wasmReady = false; + +// Initialize WASM module +async function initWasm() { + if (wasmReady) return; + + try { + // Try to import the WASM module + // For Dioxus, the WASM is typically available at a path like: + // - /pkg/spectre_app.js (if using wasm-pack) + // - Or the Dioxus-generated WASM bundle + // You may need to adjust this path based on your build setup + + // For now, we'll use a fallback: call back to main thread + // This is a temporary solution until WASM can be loaded in the worker + wasmReady = true; + } catch (err) { + console.error('Failed to load WASM in worker:', err); + wasmReady = false; + } +} + +// Handle messages from main thread +self.onmessage = async function(e) { + const data = JSON.parse(e.data); + + if (data.type === 'generate_key') { + // For now, since WASM loading in workers is complex, + // we'll send a message back requesting the main thread to compute + // This is a temporary workaround + self.postMessage(JSON.stringify({ + type: 'key_error', + error: 'WASM not yet loaded in worker. Please use main thread computation for now.' + })); + + // TODO: Once WASM is loaded in worker, uncomment this: + /* + await initWasm(); + if (wasmModule && wasmReady) { + try { + const key = wasmModule.spectre_user_key(data.name, data.secret); + const keyResult = { + type: 'key_result', + key_id: Array.from(key.key_id), + key_data: Array.from(key.key_data), + algorithm: key.algorithm + }; + self.postMessage(JSON.stringify(keyResult)); + } catch (err) { + self.postMessage(JSON.stringify({ + type: 'key_error', + error: err.toString() + })); + } + } + */ + } +}; + diff --git a/spectre-app/src/components/footer.rs b/spectre-app/src/components/footer.rs new file mode 100644 index 0000000..bc8ba2c --- /dev/null +++ b/spectre-app/src/components/footer.rs @@ -0,0 +1,12 @@ +use dioxus::prelude::*; + +#[component] +pub fn Footer() -> Element { + rsx! { + p { + class: "text-center text-slate-500 italic text-sm mt-6", + "This information never leaves this page." + } + } +} + diff --git a/spectre-app/src/components/form_fields.rs b/spectre-app/src/components/form_fields.rs new file mode 100644 index 0000000..ae706a1 --- /dev/null +++ b/spectre-app/src/components/form_fields.rs @@ -0,0 +1,165 @@ +use dioxus::prelude::*; + +#[component] +pub fn FullNameInput(full_name: Signal) -> Element { + rsx! { + div { + label { + class: "flex items-center gap-2 text-slate-400 text-xs tracking-widest mb-2", + "YOUR FULL NAME" + div { + class: "group relative", + span { + class: "text-slate-500 cursor-help", + "?" + } + div { + class: "absolute left-0 top-6 hidden w-64 rounded-xl border border-slate-600 bg-slate-800/95 p-3 text-xs font-normal text-slate-300 shadow-xl group-hover:block z-50", + p { + class: "text-slate-300", + strong { "Your full name" } + " is used to generate your master key. Use the same name consistently across all your devices." + } + p { + class: "text-slate-400 mt-2", + "Example: Robert Lee Mitchell" + } + } + } + } + input { + class: "w-full bg-slate-700/40 text-slate-300 placeholder-slate-500 px-6 py-4 rounded-full border border-slate-600/50 focus:outline-none focus:border-cyan-400/50 focus:bg-slate-700/60 transition", + r#type: "text", + placeholder: "eg. Robert Lee Mitchell", + value: "{full_name}", + oninput: move |e| full_name.set(e.value()) + } + } + } +} + +#[component] +pub fn SpectreSecretInput( + secret: Signal, + on_blur: EventHandler<()>, +) -> Element { + rsx! { + div { + div { + class: "flex items-center justify-between mb-2", + label { + class: "flex items-center gap-2 text-slate-400 text-xs tracking-widest", + "YOUR SPECTRE SECRET" + div { + class: "group relative", + span { + class: "text-slate-500 cursor-help", + "?" + } + div { + class: "absolute left-0 top-6 hidden w-64 rounded-xl border border-slate-600 bg-slate-800/95 p-3 text-xs font-normal text-slate-300 shadow-xl group-hover:block z-50", + p { + class: "text-slate-300", + strong { "Your master password" } + " is the only password you need to remember. It's combined with your name to generate unique passwords for each site." + } + p { + class: "text-slate-400 mt-2", + "💡 Tip: Use a memorable phrase like \"banana colored duckling\"" + } + p { + class: "text-yellow-400 mt-2", + "⚠️ Never share this with anyone!" + } + } + } + } + span { class: "text-2xl", "🔒" } + } + input { + class: "w-full bg-slate-700/40 text-slate-300 placeholder-slate-500 px-6 py-4 rounded-full border border-slate-600/50 focus:outline-none focus:border-cyan-400/50 focus:bg-slate-700/60 transition", + r#type: "password", + placeholder: "eg. banana colored duckling", + value: "{secret}", + oninput: move |e| secret.set(e.value()), + onblur: move |_| on_blur.call(()) + } + } + } +} + +#[component] +pub fn SiteDomainInput( + site_domain: Signal, + is_computing_key: Signal, + on_focus: EventHandler<()>, +) -> Element { + rsx! { + div { + div { + class: "flex items-center justify-center mb-2", + span { class: "text-cyan-400 text-2xl", "↓" } + } + label { + class: "flex items-center justify-between gap-2 text-slate-400 text-xs tracking-widest mb-2", + div { + class: "flex items-center gap-2", + "SITE DOMAIN" + div { + class: "group relative", + span { + class: "text-slate-500 cursor-help", + "?" + } + div { + class: "absolute left-0 top-6 hidden w-72 rounded-xl border border-slate-600 bg-slate-800/95 p-3 text-xs font-normal text-slate-300 shadow-xl group-hover:block z-50", + p { + class: "text-slate-300", + strong { "The website domain" } + " you want to generate a password for. Each site gets a unique password." + } + p { + class: "text-slate-400 mt-2", + "Examples: github.com, google.com, facebook.com" + } + p { + class: "text-cyan-400 mt-2", + "💡 Use consistent domain names (e.g., always \"google.com\", not \"www.google.com\")" + } + } + } + } + if *is_computing_key.read() { + span { + class: "inline-flex items-center gap-2 rounded-full bg-yellow-400/10 px-3 py-1 text-[11px] font-medium text-yellow-200", + span { class: "animate-spin", "⏳" } + "Computing key" + } + } + } + input { + class: if *is_computing_key.read() { + "w-full bg-slate-700/40 text-slate-300 placeholder-slate-500 px-6 py-4 rounded-full border border-yellow-400/50 focus:outline-none focus:border-yellow-400/50 focus:bg-slate-700/60 transition" + } else { + "w-full bg-slate-700/40 text-slate-300 placeholder-slate-500 px-6 py-4 rounded-full border border-slate-600/50 focus:outline-none focus:border-cyan-400/50 focus:bg-slate-700/60 transition" + }, + r#type: "text", + placeholder: if *is_computing_key.read() { + "eg. wikipedia.org (computing key in background...)" + } else { + "eg. wikipedia.org" + }, + value: "{site_domain}", + oninput: move |e| site_domain.set(e.value()), + onfocus: move |_| on_focus.call(()), + } + if *is_computing_key.read() { + p { + class: "text-xs text-yellow-200/90 mt-2 pl-1", + "⚡ Generating secure encryption key…" + } + } + } + } +} + diff --git a/spectre-app/src/components/header.rs b/spectre-app/src/components/header.rs new file mode 100644 index 0000000..ee07688 --- /dev/null +++ b/spectre-app/src/components/header.rs @@ -0,0 +1,17 @@ +use dioxus::prelude::*; + +#[component] +pub fn Header() -> Element { + rsx! { + div { + class: "text-center mb-8", + p { class: "text-slate-300 text-sm tracking-widest mb-2", "TRY OUT THE" } + h1 { + class: "text-4xl md:text-5xl font-light", + span { class: "text-white", "Spectre" } + span { class: "text-cyan-400", " algorithm" } + } + } + } +} + diff --git a/spectre-app/src/components/mod.rs b/spectre-app/src/components/mod.rs new file mode 100644 index 0000000..07f3a2e --- /dev/null +++ b/spectre-app/src/components/mod.rs @@ -0,0 +1,12 @@ +mod header; +mod password_type_selector; +mod form_fields; +mod site_password; +mod footer; + +pub use header::*; +pub use password_type_selector::*; +pub use form_fields::*; +pub use site_password::*; +pub use footer::*; + diff --git a/spectre-app/src/components/password_type_selector.rs b/spectre-app/src/components/password_type_selector.rs new file mode 100644 index 0000000..197f1ee --- /dev/null +++ b/spectre-app/src/components/password_type_selector.rs @@ -0,0 +1,89 @@ +use dioxus::prelude::*; +use spectre::SpectreResultType; + +#[component] +pub fn PasswordTypeSelector(password_type: Signal) -> Element { + rsx! { + div { + class: "mb-8", + div { + class: "flex items-center gap-2 mb-4", + span { class: "text-2xl", "🎯" } + h2 { class: "text-white text-xl font-light", "Password Type" } + } + div { + class: "grid grid-cols-2 gap-3 mb-6", + + PasswordTypeButton { + password_type: password_type, + result_type: SpectreResultType::LongPassword, + label: "LONG PASSWORD" + } + + PasswordTypeButton { + password_type: password_type, + result_type: SpectreResultType::MediumPassword, + label: "MEDIUM" + } + + PasswordTypeButton { + password_type: password_type, + result_type: SpectreResultType::BasicPassword, + label: "BASIC" + } + + PasswordTypeButton { + password_type: password_type, + result_type: SpectreResultType::ShortPassword, + label: "SHORT" + } + + PasswordTypeButton { + password_type: password_type, + result_type: SpectreResultType::PIN, + label: "PIN" + } + + PasswordTypeButton { + password_type: password_type, + result_type: SpectreResultType::Name, + label: "NAME" + } + + PasswordTypeButton { + password_type: password_type, + result_type: SpectreResultType::Phrase, + label: "PHRASE" + } + + PasswordTypeButton { + password_type: password_type, + result_type: SpectreResultType::MaximumSecurityPassword, + label: "MAXIMUM" + } + } + } + } +} + +#[component] +fn PasswordTypeButton( + password_type: Signal, + result_type: SpectreResultType, + label: &'static str, +) -> Element { + let is_selected = *password_type.read() == result_type; + + rsx! { + button { + class: if is_selected { + "bg-cyan-400 text-slate-900 py-3 px-6 rounded-full font-medium tracking-wide hover:bg-cyan-300 transition" + } else { + "bg-slate-700/50 text-slate-300 py-3 px-6 rounded-full font-medium tracking-wide hover:bg-slate-600/50 transition border border-slate-600" + }, + onclick: move |_| password_type.set(result_type), + "{label}" + } + } +} + diff --git a/spectre-app/src/components/site_password.rs b/spectre-app/src/components/site_password.rs new file mode 100644 index 0000000..a117d65 --- /dev/null +++ b/spectre-app/src/components/site_password.rs @@ -0,0 +1,64 @@ +use dioxus::prelude::*; +use wasm_bindgen_futures::JsFuture; +use web_sys::window; +use gloo_timers::future::sleep; +use std::time::Duration; + +#[component] +pub fn SitePassword( + generated_password: Signal, + is_generating: Signal, +) -> Element { + let mut copied = use_signal(|| false); + + rsx! { + div { + div { + class: "flex items-center justify-center mb-4", + span { class: "text-slate-600 text-2xl", "═" } + } + div { + class: "flex items-center gap-2 mb-4", + span { class: "text-2xl", "🔑" } + h2 { class: "text-white text-xl font-light", "Site Password" } + } + button { + class: "w-full bg-gradient-to-r from-cyan-400 to-emerald-400 text-slate-900 py-4 px-6 rounded-full text-lg font-medium hover:from-cyan-300 hover:to-emerald-300 transition shadow-lg shadow-cyan-500/20 cursor-pointer", + onclick: move |_| { + let password = generated_password.read().clone(); + if !password.is_empty() { + spawn(async move { + if let Some(window) = window() { + let clipboard = window.navigator().clipboard(); + let promise = clipboard.write_text(&password); + if let Ok(_) = JsFuture::from(promise).await { + copied.set(true); + // Reset copied state after 2 seconds + sleep(Duration::from_secs(2)).await; + copied.set(false); + } + } + }); + } + }, + if *is_generating.read() { + span { + class: "flex items-center justify-center gap-2", + span { class: "animate-spin", "⏳" } + "Generating..." + } + } else if *copied.read() { + span { + class: "flex items-center justify-center gap-2", + "✓ Copied to clipboard!" + } + } else if generated_password.read().is_empty() { + "Generate your password" + } else { + "{generated_password}" + } + } + } + } +} + diff --git a/spectre-app/src/main.rs b/spectre-app/src/main.rs new file mode 100644 index 0000000..cbb415f --- /dev/null +++ b/spectre-app/src/main.rs @@ -0,0 +1,286 @@ +use dioxus::prelude::*; +use spectre::{spectre_user_key, spectre_site_result, spectre_identicon_render, spectre_identicon}; +use spectre::{SpectreResultType, SpectreKeyPurpose, SPECTRE_ALGORITHM_CURRENT, SpectreUserKey}; +use gloo_timers::future::sleep; +use std::time::Duration; + +mod components; +mod worker; +use crate::components::*; +use crate::worker::KeyWorker; + +#[derive(Debug, Clone, Routable, PartialEq)] +#[rustfmt::skip] +enum Route { + #[route("/")] + Home {}, +} + +const FAVICON: Asset = asset!("/assets/favicon.ico"); +const MAIN_CSS: Asset = asset!("/assets/main.css"); +const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css"); + +fn main() { + dioxus::launch(App); +} + +#[component] +fn App() -> Element { + rsx! { + document::Link { rel: "icon", href: FAVICON } + document::Link { rel: "stylesheet", href: MAIN_CSS } + document::Link { rel: "stylesheet", href: TAILWIND_CSS } + Router:: {} + } +} + +/// Home page - Spectre Landing Page +#[component] +fn Home() -> Element { + let mut full_name = use_signal(|| String::new()); + let mut secret = use_signal(|| String::new()); + let mut site_domain = use_signal(|| String::new()); + let mut password_type = use_signal(|| SpectreResultType::LongPassword); + let mut generated_password = use_signal(|| String::new()); + let mut identicon = use_signal(|| String::new()); + let mut is_generating = use_signal(|| false); + let mut is_computing_key = use_signal(|| false); + + // Cache the user key to avoid recomputing scrypt for every site + // The expensive operation (scrypt) only runs when name or secret changes + let mut cached_user_key = use_signal(|| Option::<(String, String, SpectreUserKey)>::None); + + // Initialize Web Worker for background key generation + // Falls back to main thread if worker initialization fails + // Use Arc to share the worker across async contexts + use std::sync::Arc; + let mut key_worker = use_signal(|| { + KeyWorker::new().ok().map(Arc::new) + }); + + // Compute validation states (memoized) + let name_valid = use_memo(move || { + let name = full_name.read(); + !name.is_empty() && name.len() >= 3 + }); + let secret_valid = use_memo(move || { + let sec = secret.read(); + !sec.is_empty() && sec.len() >= 4 + }); + let domain_valid = use_memo(move || { + let site = site_domain.read(); + !site.is_empty() && site.len() >= 3 + }); + + // Track when to trigger key computation + let mut trigger_key_computation = use_signal(|| 0u32); + + // Eager user key generation - starts when user focuses on site field or leaves secret field + // This precomputes the expensive scrypt operation before user tries to type + use_effect(move || { + let trigger = trigger_key_computation(); + if trigger == 0 { + return; // Don't compute on initial render + } + + let name = full_name.read().clone(); + let sec = secret.read().clone(); + let worker_ref = (*key_worker.read()).clone(); // Clone the Arc reference + + spawn(async move { + // Wait a bit to let the UI update + sleep(Duration::from_millis(100)).await; + + // Validate name and secret + let name_is_valid = !name.is_empty() && name.len() >= 3; + let secret_is_valid = !sec.is_empty() && sec.len() >= 4; + + // If valid, precompute the user key (expensive scrypt operation) + if name_is_valid && secret_is_valid { + // Check if we already have this cached + let needs_computation = { + let cache = cached_user_key.read(); + if let Some((ref cached_name, ref cached_secret, _)) = *cache { + cached_name != &name || cached_secret != &sec + } else { + true + } + }; + + if needs_computation { + // Show that we're computing the key + is_computing_key.set(true); + + // Small delay to let the UI update + sleep(Duration::from_millis(50)).await; + + // Try to use Web Worker for background computation, fallback to main thread + let key_result = if let Some(ref worker) = worker_ref { + // Use Web Worker (runs in background thread) + // If worker fails, fallback to main thread + worker.generate_key(name.clone(), sec.clone()).await + .or_else(|_| { + // Worker failed, fallback to main thread + spectre_user_key(&name, &sec, SPECTRE_ALGORITHM_CURRENT) + .map_err(|e| format!("Key generation failed: {:?}", e)) + }) + } else { + // Fallback to main thread if worker not available + spectre_user_key(&name, &sec, SPECTRE_ALGORITHM_CURRENT) + .map_err(|e| format!("Key generation failed: {:?}", e)) + }; + + if let Ok(key) = key_result { + cached_user_key.set(Some((name.clone(), sec.clone(), key))); + } + + // Always reset the computing state, even on error + is_computing_key.set(false); + } + } + }); + }); + + // Password generation - uses the precomputed user key + // Note: Spectre uses scrypt with N=32768 which is computationally expensive (by design for security) + // We precompute this in the effect above, so password generation is fast + use_effect(move || { + // Clone the values we need + let name = full_name(); + let sec = secret(); + let site = site_domain(); + let result_type = password_type(); + let worker_ref = (*key_worker.read()).clone(); // Clone the Arc reference + + // Spawn an async task with a delay + spawn(async move { + // Wait for 500ms before generating the password (shorter now since key is precomputed) + sleep(Duration::from_millis(500)).await; + + // Validate fields + let name_is_valid = !name.is_empty() && name.len() >= 3; + let secret_is_valid = !sec.is_empty() && sec.len() >= 4; + let domain_is_valid = !site.is_empty() && site.len() >= 3; + + // Generate password if all fields are valid + if name_is_valid && secret_is_valid && domain_is_valid { + // Check if we have a cached user key for this name+secret combination + let user_key: Option = { + let cache = cached_user_key.read(); + if let Some((ref cached_name, ref cached_secret, ref key)) = *cache { + if cached_name == &name && cached_secret == &sec { + // Cache hit! Use the precomputed key (no scrypt computation needed) + Some(key.clone()) + } else { + None + } + } else { + None + } + }; + + // If no cache hit, compute the user key (expensive scrypt operation) + // This should rarely happen since we precompute the key above + let user_key = if let Some(key) = user_key { + Ok(key) + } else { + is_generating.set(true); + // Small delay to show loading state + sleep(Duration::from_millis(50)).await; + + // Try to use Web Worker for background computation, fallback to main thread + let result = if let Some(ref worker) = worker_ref { + // Use Web Worker (runs in background thread) + worker.generate_key(name.clone(), sec.clone()).await + } else { + // Fallback to main thread if worker not available + spectre_user_key(&name, &sec, SPECTRE_ALGORITHM_CURRENT) + .map_err(|e| format!("Key generation failed: {:?}", e)) + }; + + if let Ok(ref key) = result { + // Cache the result for future use + cached_user_key.set(Some((name.clone(), sec.clone(), key.clone()))); + } + result + }; + + if let Ok(user_key) = user_key { + if let Ok(password) = spectre_site_result( + &user_key, + &site, + result_type, + None, + 1, + SpectreKeyPurpose::Authentication, + None, + ) { + generated_password.set(password); + + // Generate identicon (reuse the cached user key data) + let mut identicon_bytes = [0u8; 4]; + identicon_bytes.copy_from_slice(&user_key.key_data[0..4]); + identicon.set(spectre_identicon_render(identicon_bytes)); + } else { + generated_password.set(String::new()); + identicon.set(String::new()); + } + } else { + generated_password.set(String::new()); + identicon.set(String::new()); + } + + is_generating.set(false); + } else { + generated_password.set(String::new()); + identicon.set(String::new()); + is_generating.set(false); + } + }); + }); + + rsx! { + div { + class: "min-h-screen flex items-center justify-center p-4", + style: "background: linear-gradient(135deg, #0a2540 0%, #1a3a52 100%);", + + div { + class: "w-full max-w-4xl bg-slate-800/40 backdrop-blur-sm rounded-3xl p-8 md:p-12 border border-slate-700/50", + + Header {} + + PasswordTypeSelector { password_type } + + div { + class: "space-y-6", + + FullNameInput { full_name } + + SpectreSecretInput { + secret, + on_blur: move |_| { + // Trigger key computation when user leaves secret field + trigger_key_computation.set(trigger_key_computation() + 1); + } + } + + SiteDomainInput { + site_domain, + is_computing_key, + on_focus: move |_| { + // Trigger key computation when user focuses site field + trigger_key_computation.set(trigger_key_computation() + 1); + } + } + + SitePassword { + generated_password, + is_generating + } + } + + Footer {} + } + } + } +} diff --git a/spectre-app/src/worker.rs b/spectre-app/src/worker.rs new file mode 100644 index 0000000..571dcf7 --- /dev/null +++ b/spectre-app/src/worker.rs @@ -0,0 +1,133 @@ +use wasm_bindgen::prelude::*; +use web_sys::{MessageEvent, Worker}; +use serde::{Deserialize, Serialize}; +use spectre::SpectreUserKey; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum WorkerMessage { + #[serde(rename = "generate_key")] + GenerateKey { name: String, secret: String }, + #[serde(rename = "key_result")] + KeyResult { + key_id: Vec, + key_data: Vec, + algorithm: u32, + }, + #[serde(rename = "key_error")] + KeyError { error: String }, +} + +impl WorkerMessage { + pub fn to_spectre_key(&self) -> Option { + match self { + WorkerMessage::KeyResult { key_id, key_data, algorithm } => { + if key_id.len() == 32 { + let mut key_id_array = [0u8; 32]; + key_id_array.copy_from_slice(key_id); + Some(SpectreUserKey { + key_id: key_id_array, + key_data: key_data.clone(), + algorithm: *algorithm, + }) + } else { + None + } + } + _ => None, + } + } + + pub fn from_spectre_key(key: &SpectreUserKey) -> Self { + WorkerMessage::KeyResult { + key_id: key.key_id.to_vec(), + key_data: key.key_data.clone(), + algorithm: key.algorithm, + } + } +} + +pub struct KeyWorker { + worker: Option, +} + +impl KeyWorker { + pub fn new() -> Result { + // Create an inline worker using a Blob URL + let worker_js = include_str!("../assets/worker.js"); + + let blob = web_sys::Blob::new_with_str_sequence( + &js_sys::Array::of1(&JsValue::from_str(worker_js)) + )?; + let url = web_sys::Url::create_object_url_with_blob(&blob)?; + + let worker = Worker::new(&url)?; + + Ok(Self { + worker: Some(worker), + }) + } + + pub async fn generate_key( + &self, + name: String, + secret: String, + ) -> Result { + let worker = self.worker.as_ref().ok_or("Worker not initialized")?; + + // Create a channel for the result + let (tx, rx) = futures::channel::oneshot::channel(); + let tx = std::sync::Arc::new(std::sync::Mutex::new(Some(tx))); + + // Set up message handler (one-time use) + let tx_clone = tx.clone(); + let closure = Closure::wrap(Box::new(move |event: MessageEvent| { + if let Some(data) = event.data().as_string() { + if let Ok(msg) = serde_json::from_str::(&data) { + match msg { + WorkerMessage::KeyResult { .. } => { + if let Some(key) = msg.to_spectre_key() { + if let Some(tx) = tx_clone.lock().unwrap().take() { + let _ = tx.send(Ok(key)); + } + } else { + if let Some(tx) = tx_clone.lock().unwrap().take() { + let _ = tx.send(Err("Invalid key format".to_string())); + } + } + } + WorkerMessage::KeyError { error } => { + if let Some(tx) = tx_clone.lock().unwrap().take() { + let _ = tx.send(Err(error)); + } + } + _ => {} + } + } + } + }) as Box); + + worker.set_onmessage(Some(closure.as_ref().unchecked_ref())); + closure.forget(); + + // Send message to worker + let message = WorkerMessage::GenerateKey { name, secret }; + let message_json = serde_json::to_string(&message) + .map_err(|e| format!("Serialization error: {}", e))?; + + worker.post_message(&JsValue::from_str(&message_json)) + .map_err(|_| "Failed to post message to worker".to_string())?; + + // Wait for response + rx.await.map_err(|_| "Worker communication failed".to_string())? + } +} + +impl Drop for KeyWorker { + fn drop(&mut self) { + if let Some(worker) = self.worker.take() { + worker.terminate(); + } + } +} + diff --git a/spectre-app/tailwind.css b/spectre-app/tailwind.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/spectre-app/tailwind.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/src/bin/main.rs b/src/bin/main.rs index 8d5d4d5..114ed15 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -1,5 +1,6 @@ use clap::Parser; use spectre::*; +use spectre::util::cli::*; use std::process; use std::str::FromStr; diff --git a/src/lib.rs b/src/lib.rs index 5fd100f..aa138c8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,4 +42,10 @@ pub use error::{SpectreError, Result}; pub use models::*; pub use marshal::{spectre_marshal_read, spectre_marshal_write, spectre_marshal_auth, spectre_user_path}; pub use types::*; -pub use util::*; + +// Re-export utility functions (parse_bool and zero_string are always available) +pub use util::{parse_bool, zero_string}; + +// CLI utilities are conditionally exported +#[cfg(feature = "cli")] +pub use util::cli; diff --git a/src/marshal.rs b/src/marshal.rs index 315f76a..94edce5 100644 --- a/src/marshal.rs +++ b/src/marshal.rs @@ -1,11 +1,15 @@ -use std::fs::{self, File}; -use std::io::{Read, Write}; use std::path::PathBuf; use crate::error::{Result, SpectreError}; use crate::models::*; use crate::algorithm::{spectre_user_key, spectre_identicon}; -/// Read a marshalled user file +#[cfg(feature = "cli")] +use std::fs::{self, File}; +#[cfg(feature = "cli")] +use std::io::{Read, Write}; + +/// Read a marshalled user file (CLI only) +#[cfg(feature = "cli")] pub fn spectre_marshal_read(file_path: &PathBuf) -> Result<(SpectreMarshalledFile, Option)> { if !file_path.exists() { return Ok(( @@ -31,7 +35,14 @@ pub fn spectre_marshal_read(file_path: &PathBuf) -> Result<(SpectreMarshalledFil } } -/// Write a marshalled user file +/// Read a marshalled user file (stub for non-CLI builds) +#[cfg(not(feature = "cli"))] +pub fn spectre_marshal_read(_file_path: &PathBuf) -> Result<(SpectreMarshalledFile, Option)> { + Err(SpectreError::InvalidFileFormat("File I/O not available in this build".to_string())) +} + +/// Write a marshalled user file (CLI only) +#[cfg(feature = "cli")] pub fn spectre_marshal_write( file_path: &PathBuf, format: SpectreFormat, @@ -60,6 +71,16 @@ pub fn spectre_marshal_write( Ok(()) } +/// Write a marshalled user file (stub for non-CLI builds) +#[cfg(not(feature = "cli"))] +pub fn spectre_marshal_write( + _file_path: &PathBuf, + _format: SpectreFormat, + _user: &SpectreMarshalledUser, +) -> Result<()> { + Err(SpectreError::InvalidFileFormat("File I/O not available in this build".to_string())) +} + /// Parse flat format (simplified version) fn parse_flat_format(_contents: &str) -> Result<(SpectreMarshalledFile, Option)> { // This is a simplified parser - the full implementation would be more complex @@ -92,7 +113,8 @@ pub fn spectre_marshal_auth( Ok(()) } -/// Get the default user file path +/// Get the default user file path (CLI only) +#[cfg(feature = "cli")] pub fn spectre_user_path(user_name: &str, format: SpectreFormat) -> Option { let home_dir = dirs::home_dir()?; let spectre_dir = home_dir.join(".spectre.d"); @@ -106,6 +128,12 @@ pub fn spectre_user_path(user_name: &str, format: SpectreFormat) -> Option Option { + None +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/util.rs b/src/util.rs index 2a24a76..e90bb6f 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,21 +1,3 @@ -use std::io::{self, Write}; - -/// Prompt user for input -pub fn prompt_line(prompt: &str) -> io::Result { - print!("{} ", prompt); - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - - Ok(input.trim().to_string()) -} - -/// Prompt user for password (hidden input) -pub fn prompt_password(prompt: &str) -> io::Result { - rpassword::prompt_password(prompt) -} - /// Parse boolean from string pub fn parse_bool(s: &str) -> bool { matches!(s, "1" | "true" | "yes" | "y" | "on") @@ -32,16 +14,39 @@ pub fn zero_string(s: &mut String) { s.clear(); } -/// Read from file descriptor -pub fn read_fd(fd: i32) -> io::Result { - use std::os::unix::io::FromRawFd; - use std::fs::File; - use std::io::Read; - - let mut file = unsafe { File::from_raw_fd(fd) }; - let mut contents = String::new(); - file.read_to_string(&mut contents)?; - - Ok(contents) +// CLI-only functions (not available in WASM) +#[cfg(feature = "cli")] +pub mod cli { + use std::io::{self, Write}; + + /// Prompt user for input + pub fn prompt_line(prompt: &str) -> io::Result { + print!("{} ", prompt); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + Ok(input.trim().to_string()) + } + + /// Prompt user for password (hidden input) + pub fn prompt_password(prompt: &str) -> io::Result { + rpassword::prompt_password(prompt) + } + + /// Read from file descriptor (Unix only) + #[cfg(unix)] + pub fn read_fd(fd: i32) -> io::Result { + use std::os::unix::io::FromRawFd; + use std::fs::File; + use std::io::Read; + + let mut file = unsafe { File::from_raw_fd(fd) }; + let mut contents = String::new(); + file.read_to_string(&mut contents)?; + + Ok(contents) + } } From ddd565894f991f78db0f541615ec009678fc6e9f Mon Sep 17 00:00:00 2001 From: Abdulrhman Alkhodiry Date: Tue, 11 Nov 2025 20:07:49 +0300 Subject: [PATCH 2/3] Refactor Tailwind CSS and enhance FullNameInput component - Removed unused color variables and CSS classes from tailwind.css to streamline styles. - Updated FullNameInput component to accept an on_blur event handler, triggering key computation when the user leaves the full name field. - Improved performance by capturing values once when the trigger changes in main.rs, avoiding unnecessary signal subscriptions. --- spectre-app/assets/tailwind.css | 106 ---------------------- spectre-app/src/components/form_fields.rs | 8 +- spectre-app/src/main.rs | 17 +++- 3 files changed, 18 insertions(+), 113 deletions(-) diff --git a/spectre-app/assets/tailwind.css b/spectre-app/assets/tailwind.css index 3ac7290..eb888de 100644 --- a/spectre-app/assets/tailwind.css +++ b/spectre-app/assets/tailwind.css @@ -7,30 +7,13 @@ 'Noto Color Emoji'; --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; - --color-red-300: oklch(80.8% 0.114 19.571); - --color-red-400: oklch(70.4% 0.191 22.216); - --color-red-500: oklch(63.7% 0.237 25.331); --color-yellow-200: oklch(94.5% 0.129 101.54); - --color-yellow-300: oklch(90.5% 0.182 98.111); --color-yellow-400: oklch(85.2% 0.199 91.936); - --color-green-400: oklch(79.2% 0.209 151.711); --color-emerald-300: oklch(84.5% 0.143 164.978); --color-emerald-400: oklch(76.5% 0.177 163.223); - --color-teal-100: oklch(95.3% 0.051 180.801); - --color-teal-200: oklch(91% 0.096 180.426); - --color-teal-300: oklch(85.5% 0.138 181.071); - --color-teal-400: oklch(77.7% 0.152 181.912); - --color-teal-500: oklch(70.4% 0.14 182.503); - --color-teal-600: oklch(60% 0.118 184.704); - --color-teal-700: oklch(51.1% 0.096 186.391); - --color-teal-800: oklch(43.7% 0.078 188.216); - --color-teal-900: oklch(38.6% 0.063 188.416); - --color-teal-950: oklch(27.7% 0.046 192.524); - --color-cyan-200: oklch(91.7% 0.08 205.041); --color-cyan-300: oklch(86.5% 0.127 207.078); --color-cyan-400: oklch(78.9% 0.154 211.53); --color-cyan-500: oklch(71.5% 0.143 215.221); - --color-blue-300: oklch(80.9% 0.105 251.813); --color-slate-300: oklch(86.9% 0.022 252.894); --color-slate-400: oklch(70.4% 0.04 256.788); --color-slate-500: oklch(55.4% 0.046 257.417); @@ -40,25 +23,17 @@ --color-slate-900: oklch(20.8% 0.042 265.755); --color-white: #fff; --spacing: 0.25rem; - --container-xl: 36rem; - --container-2xl: 42rem; - --container-3xl: 48rem; --container-4xl: 56rem; - --container-7xl: 80rem; --text-xs: 0.75rem; --text-xs--line-height: calc(1 / 0.75); --text-sm: 0.875rem; --text-sm--line-height: calc(1.25 / 0.875); - --text-base: 1rem; - --text-base--line-height: calc(1.5 / 1); --text-lg: 1.125rem; --text-lg--line-height: calc(1.75 / 1.125); --text-xl: 1.25rem; --text-xl--line-height: calc(1.75 / 1.25); --text-2xl: 1.5rem; --text-2xl--line-height: calc(2 / 1.5); - --text-3xl: 1.875rem; - --text-3xl--line-height: calc(2.25 / 1.875); --text-4xl: 2.25rem; --text-4xl--line-height: calc(2.5 / 2.25); --text-5xl: 3rem; @@ -66,18 +41,12 @@ --font-weight-light: 300; --font-weight-normal: 400; --font-weight-medium: 500; - --font-weight-semibold: 600; --tracking-wide: 0.025em; --tracking-widest: 0.1em; - --radius-lg: 0.5rem; --radius-xl: 0.75rem; - --radius-2xl: 1rem; --radius-3xl: 1.5rem; --animate-spin: spin 1s linear infinite; --blur-sm: 8px; - --blur-xl: 24px; - --blur-2xl: 40px; - --blur-3xl: 64px; --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: var(--font-sans); @@ -287,9 +256,6 @@ .mb-8 { margin-bottom: calc(var(--spacing) * 8); } - .block { - display: block; - } .flex { display: flex; } @@ -305,9 +271,6 @@ .inline-flex { display: inline-flex; } - .table { - display: table; - } .min-h-screen { min-height: 100vh; } @@ -323,9 +286,6 @@ .max-w-4xl { max-width: var(--container-4xl); } - .border-collapse { - border-collapse: collapse; - } .transform { transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); } @@ -335,15 +295,9 @@ .cursor-help { cursor: help; } - .cursor-not-allowed { - cursor: not-allowed; - } .cursor-pointer { cursor: pointer; } - .resize { - resize: both; - } .grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -391,18 +345,12 @@ border-color: color-mix(in oklab, var(--color-slate-600) 50%, transparent); } } - .border-slate-700 { - border-color: var(--color-slate-700); - } .border-slate-700\/50 { border-color: color-mix(in srgb, oklch(37.2% 0.044 257.287) 50%, transparent); @supports (color: color-mix(in lab, red, red)) { border-color: color-mix(in oklab, var(--color-slate-700) 50%, transparent); } } - .border-yellow-400 { - border-color: var(--color-yellow-400); - } .border-yellow-400\/50 { border-color: color-mix(in srgb, oklch(85.2% 0.199 91.936) 50%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -412,15 +360,6 @@ .bg-cyan-400 { background-color: var(--color-cyan-400); } - .bg-slate-700 { - background-color: var(--color-slate-700); - } - .bg-slate-700\/30 { - background-color: color-mix(in srgb, oklch(37.2% 0.044 257.287) 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-slate-700) 30%, transparent); - } - } .bg-slate-700\/40 { background-color: color-mix(in srgb, oklch(37.2% 0.044 257.287) 40%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -433,9 +372,6 @@ background-color: color-mix(in oklab, var(--color-slate-700) 50%, transparent); } } - .bg-slate-800 { - background-color: var(--color-slate-800); - } .bg-slate-800\/40 { background-color: color-mix(in srgb, oklch(27.9% 0.041 260.031) 40%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -448,9 +384,6 @@ background-color: color-mix(in oklab, var(--color-slate-800) 95%, transparent); } } - .bg-yellow-400 { - background-color: var(--color-yellow-400); - } .bg-yellow-400\/10 { background-color: color-mix(in srgb, oklch(85.2% 0.199 91.936) 10%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -582,22 +515,11 @@ .italic { font-style: italic; } - .underline { - text-decoration-line: underline; - } .placeholder-slate-500 { &::placeholder { color: var(--color-slate-500); } } - .placeholder-slate-500\/50 { - &::placeholder { - color: color-mix(in srgb, oklch(55.4% 0.046 257.417) 50%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-slate-500) 50%, transparent); - } - } - } .shadow-lg { --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); @@ -606,31 +528,17 @@ --tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } - .shadow-cyan-500 { - --tw-shadow-color: oklch(71.5% 0.143 215.221); - @supports (color: color-mix(in lab, red, red)) { - --tw-shadow-color: color-mix(in oklab, var(--color-cyan-500) var(--tw-shadow-alpha), transparent); - } - } .shadow-cyan-500\/20 { --tw-shadow-color: color-mix(in srgb, oklch(71.5% 0.143 215.221) 20%, transparent); @supports (color: color-mix(in lab, red, red)) { --tw-shadow-color: color-mix(in oklab, color-mix(in oklab, var(--color-cyan-500) 20%, transparent) var(--tw-shadow-alpha), transparent); } } - .outline { - outline-style: var(--tw-outline-style); - outline-width: 1px; - } .backdrop-blur-sm { --tw-backdrop-blur: blur(var(--blur-sm)); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } - .backdrop-filter { - -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); - backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); - } .transition { transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, visibility, content-visibility, overlay, pointer-events; transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); @@ -692,14 +600,6 @@ } } } - .focus\:bg-slate-700\/40 { - &:focus { - background-color: color-mix(in srgb, oklch(37.2% 0.044 257.287) 40%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-slate-700) 40%, transparent); - } - } - } .focus\:bg-slate-700\/60 { &:focus { background-color: color-mix(in srgb, oklch(37.2% 0.044 257.287) 60%, transparent); @@ -871,11 +771,6 @@ inherits: false; initial-value: 0 0 #0000; } -@property --tw-outline-style { - syntax: "*"; - inherits: false; - initial-value: solid; -} @property --tw-backdrop-blur { syntax: "*"; inherits: false; @@ -952,7 +847,6 @@ --tw-ring-offset-width: 0px; --tw-ring-offset-color: #fff; --tw-ring-offset-shadow: 0 0 #0000; - --tw-outline-style: solid; --tw-backdrop-blur: initial; --tw-backdrop-brightness: initial; --tw-backdrop-contrast: initial; diff --git a/spectre-app/src/components/form_fields.rs b/spectre-app/src/components/form_fields.rs index ae706a1..6d22f59 100644 --- a/spectre-app/src/components/form_fields.rs +++ b/spectre-app/src/components/form_fields.rs @@ -1,7 +1,10 @@ use dioxus::prelude::*; #[component] -pub fn FullNameInput(full_name: Signal) -> Element { +pub fn FullNameInput( + full_name: Signal, + on_blur: EventHandler<()>, +) -> Element { rsx! { div { label { @@ -32,7 +35,8 @@ pub fn FullNameInput(full_name: Signal) -> Element { r#type: "text", placeholder: "eg. Robert Lee Mitchell", value: "{full_name}", - oninput: move |e| full_name.set(e.value()) + oninput: move |e| full_name.set(e.value()), + onblur: move |_| on_blur.call(()) } } } diff --git a/spectre-app/src/main.rs b/spectre-app/src/main.rs index cbb415f..db6fd41 100644 --- a/spectre-app/src/main.rs +++ b/spectre-app/src/main.rs @@ -75,7 +75,7 @@ fn Home() -> Element { // Track when to trigger key computation let mut trigger_key_computation = use_signal(|| 0u32); - // Eager user key generation - starts when user focuses on site field or leaves secret field + // Eager user key generation - starts when user focuses on site field or leaves secret/name field // This precomputes the expensive scrypt operation before user tries to type use_effect(move || { let trigger = trigger_key_computation(); @@ -83,9 +83,10 @@ fn Home() -> Element { return; // Don't compute on initial render } - let name = full_name.read().clone(); - let sec = secret.read().clone(); - let worker_ref = (*key_worker.read()).clone(); // Clone the Arc reference + // Capture values once when trigger changes - don't subscribe to signal changes + let name = full_name.peek().clone(); + let sec = secret.peek().clone(); + let worker_ref = (*key_worker.peek()).clone(); // Clone the Arc reference spawn(async move { // Wait a bit to let the UI update @@ -254,7 +255,13 @@ fn Home() -> Element { div { class: "space-y-6", - FullNameInput { full_name } + FullNameInput { + full_name, + on_blur: move |_| { + // Trigger key computation when user leaves full name field + trigger_key_computation.set(trigger_key_computation() + 1); + } + } SpectreSecretInput { secret, From 26095c07aea83b4556f601b0ce63a088f0685b7c Mon Sep 17 00:00:00 2001 From: Abdulrhman Alkhodiry Date: Thu, 13 Nov 2025 14:04:06 +0300 Subject: [PATCH 3/3] Add GitHub Actions workflow for deploying Spectre to GitHub Pages - Created a new GitHub Actions workflow in deploy-pages.yaml to automate deployment of the Spectre application to GitHub Pages upon pushes to the master branch. - Configured steps for checking out the repository, setting up the Rust toolchain, installing Dioxus CLI, building the application, and uploading the build artifacts. - Updated .gitignore to exclude the public distribution directory from version control. - Added documentation files for algorithm validation, examples, GPL compliance, implementation notes, and testing summary to enhance project documentation. --- .github/workflows/deploy-pages.yaml | 48 +++++++++++++++++++ .gitignore | 2 + .../ALGORITHM_VALIDATION.md | 0 EXAMPLES.md => docs/EXAMPLES.md | 0 GPL_COMPLIANCE.md => docs/GPL_COMPLIANCE.md | 0 .../IMPLEMENTATION_NOTES.md | 0 TESTING.md => docs/TESTING.md | 0 TEST_SUMMARY.md => docs/TEST_SUMMARY.md | 0 .../VALIDATION_COMPLETE.md | 0 spectre-app/Dioxus.toml | 2 +- spectre-app/src/main.rs | 10 ++-- 11 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/deploy-pages.yaml rename ALGORITHM_VALIDATION.md => docs/ALGORITHM_VALIDATION.md (100%) rename EXAMPLES.md => docs/EXAMPLES.md (100%) rename GPL_COMPLIANCE.md => docs/GPL_COMPLIANCE.md (100%) rename IMPLEMENTATION_NOTES.md => docs/IMPLEMENTATION_NOTES.md (100%) rename TESTING.md => docs/TESTING.md (100%) rename TEST_SUMMARY.md => docs/TEST_SUMMARY.md (100%) rename VALIDATION_COMPLETE.md => docs/VALIDATION_COMPLETE.md (100%) diff --git a/.github/workflows/deploy-pages.yaml b/.github/workflows/deploy-pages.yaml new file mode 100644 index 0000000..c2f7ce2 --- /dev/null +++ b/.github/workflows/deploy-pages.yaml @@ -0,0 +1,48 @@ +name: Deploy GitHub Pages + +on: + push: + branches: + - master + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + target: wasm32-unknown-unknown + override: true + profile: minimal + + - name: Install Dioxus CLI + run: cargo install dioxus-cli + + - name: Build with Dioxus + run: | + cd spectre-app + dx bundle -r + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ./spectre-app/dist/public + + deploy: + needs: build + permissions: + pages: write # Allow the GITHUB_TOKEN to create a deployment + id-token: write # Allow the GITHUB_TOKEN to authenticate with OIDC + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 71ab1d8..fe623e1 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ Thumbs.db *.mpsites *.json !Cargo.json + +*/dist/public \ No newline at end of file diff --git a/ALGORITHM_VALIDATION.md b/docs/ALGORITHM_VALIDATION.md similarity index 100% rename from ALGORITHM_VALIDATION.md rename to docs/ALGORITHM_VALIDATION.md diff --git a/EXAMPLES.md b/docs/EXAMPLES.md similarity index 100% rename from EXAMPLES.md rename to docs/EXAMPLES.md diff --git a/GPL_COMPLIANCE.md b/docs/GPL_COMPLIANCE.md similarity index 100% rename from GPL_COMPLIANCE.md rename to docs/GPL_COMPLIANCE.md diff --git a/IMPLEMENTATION_NOTES.md b/docs/IMPLEMENTATION_NOTES.md similarity index 100% rename from IMPLEMENTATION_NOTES.md rename to docs/IMPLEMENTATION_NOTES.md diff --git a/TESTING.md b/docs/TESTING.md similarity index 100% rename from TESTING.md rename to docs/TESTING.md diff --git a/TEST_SUMMARY.md b/docs/TEST_SUMMARY.md similarity index 100% rename from TEST_SUMMARY.md rename to docs/TEST_SUMMARY.md diff --git a/VALIDATION_COMPLETE.md b/docs/VALIDATION_COMPLETE.md similarity index 100% rename from VALIDATION_COMPLETE.md rename to docs/VALIDATION_COMPLETE.md diff --git a/spectre-app/Dioxus.toml b/spectre-app/Dioxus.toml index 4a752e2..a966172 100644 --- a/spectre-app/Dioxus.toml +++ b/spectre-app/Dioxus.toml @@ -3,7 +3,7 @@ name = "spectre-app" out_dir = "dist" [web.app] -title = "Spectre Password Generator" +title = "Spectre-rs Password Generator" [web.watcher] reload_html = true diff --git a/spectre-app/src/main.rs b/spectre-app/src/main.rs index db6fd41..45bede5 100644 --- a/spectre-app/src/main.rs +++ b/spectre-app/src/main.rs @@ -37,10 +37,10 @@ fn App() -> Element { /// Home page - Spectre Landing Page #[component] fn Home() -> Element { - let mut full_name = use_signal(|| String::new()); - let mut secret = use_signal(|| String::new()); - let mut site_domain = use_signal(|| String::new()); - let mut password_type = use_signal(|| SpectreResultType::LongPassword); + let full_name = use_signal(|| String::new()); + let secret = use_signal(|| String::new()); + let site_domain = use_signal(|| String::new()); + let password_type = use_signal(|| SpectreResultType::LongPassword); let mut generated_password = use_signal(|| String::new()); let mut identicon = use_signal(|| String::new()); let mut is_generating = use_signal(|| false); @@ -54,7 +54,7 @@ fn Home() -> Element { // Falls back to main thread if worker initialization fails // Use Arc to share the worker across async contexts use std::sync::Arc; - let mut key_worker = use_signal(|| { + let key_worker = use_signal(|| { KeyWorker::new().ok().map(Arc::new) });