diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a495f3d..0afcf59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,8 +22,9 @@ jobs: - run: npm ci - uses: dtolnay/rust-toolchain@stable + - uses: taiki-e/install-action@v2 with: - components: rustfmt + tool: dioxus-cli - name: Check formatting run: npm run format:check diff --git a/.vscode/settings.json b/.vscode/settings.json index 50c790e..ccdf644 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,7 +8,7 @@ }, "editor.formatOnSave": true, "[rust]": { - "editor.defaultFormatter": "rust-lang.rust-analyzer" + "editor.defaultFormatter": "DioxusLabs.dioxus" }, "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" diff --git a/CLAUDE.md b/CLAUDE.md index 2ed1947..7e603c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,6 +65,48 @@ Reusable inputs in `src/components/inputs.rs`: - `SwitchInput` - toggle with label - `NumberInput` - numeric with +/- buttons +## Widget Layout System + +Layout classes in `src/pages/widget.css`: + +- `.widget` - Flex column container; child `TextAreaForm` elements auto-expand to fill space +- `.widget-grid` - Grid container for widgets without expanding textareas +- `.widget-params` - Horizontal flex-wrap for form controls (inputs grow, buttons/switches don't) +- `.widget-buttons` / `.widget-switches` - Fixed-width groups inside `.widget-params` + +**Generator pattern** (params + expanding textarea): + +```rust +div { class: "widget", + div { class: "widget-params", + SelectForm:: { /* ... */ } + NumberInput:: { /* ... */ } + div { class: "widget-buttons", button { /* ... */ } } + div { class: "widget-switches", SwitchInput { /* ... */ } } + } + TextAreaForm { /* expands to fill remaining space */ } +} +``` + +**Encoder/Decoder pattern** (multiple textareas share space equally): + +```rust +div { class: "widget", + TextAreaForm { /* input */ } + TextAreaForm { /* output */ } +} +``` + +**Converter pattern** (stacked inputs, no expanding): + +```rust +div { class: "widget-grid", + SwitchInput { /* ... */ } + TextInput { /* ... */ } + TextInput { /* ... */ } +} +``` + ## Development ```bash @@ -99,6 +141,7 @@ Dark mode is handled by `public/js/darkmode.js`. 1. Create file in appropriate category folder (e.g., `src/pages/converter/my_widget.rs`) 2. Define `WIDGET_ENTRY`, `ICON`, and component function -3. Add module declaration in category's `mod.rs` -4. Add route variant to category's `Route` enum with `#[route("/my-widget")]` -5. Implement match arm in `get_widget_entry()` +3. Choose layout class: `widget` (expanding textareas) or `widget-grid` (stacked inputs) +4. Add module declaration in category's `mod.rs` +5. Add route variant to category's `Route` enum with `#[route("/my-widget")]` +6. Implement match arm in `get_widget_entry()` diff --git a/package.json b/package.json index 95cccdf..f5eb914 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,9 @@ "build:web": "npm run tailwind:build && dx build --platform web --release", "build:desktop": "npm run tailwind:build && dx build --platform desktop --release", "format:prettier": "prettier --write \"**/*.{js,css,md,json,html}\"", - "format:rust": "cargo fmt", + "format:rust": "dx fmt", "format": "concurrently -n \"prettier,rust\" -c \"yellow,red\" \"npm:format:prettier\" \"npm:format:rust\"", - "format:check": "concurrently -n \"prettier,rust\" -c \"yellow,red\" \"prettier --check '**/*.{js,css,md,json,html}'\" \"cargo fmt --check\"", + "format:check": "concurrently -n \"prettier,rust\" -c \"yellow,red\" \"prettier --check '**/*.{js,css,md,json,html}'\" \"dx fmt --check\"", "prepare": "husky" }, "devDependencies": { @@ -26,6 +26,6 @@ }, "lint-staged": { "*.{js,css,md,json,html,yml,yaml}": "prettier --write", - "*.rs": "cargo fmt --" + "*.rs": "sh -c 'for f in \"$@\"; do dx fmt -f \"$f\"; done' _" } } diff --git a/src/components/accordion.rs b/src/components/accordion.rs index 1d99c01..0ee9731 100644 --- a/src/components/accordion.rs +++ b/src/components/accordion.rs @@ -26,17 +26,14 @@ pub fn Accordion( div { class: "collapse-title p-2 min-h-0 flex items-center", // Category link (icon + title) - no tooltip needed when expanded if let Some(route) = category_route.clone() { - Link { - class: "flex items-center gap-2 grow", - to: route, + Link { class: "flex items-center gap-2 grow", to: route, if let Some(icon) = icon { {icon} } {title} } } else { - div { - class: "flex items-center gap-2 grow", + div { class: "flex items-center gap-2 grow", if let Some(icon) = icon { {icon} } @@ -59,7 +56,7 @@ pub fn Accordion( path { "fill-rule": "evenodd", "d": "M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z", - "clip-rule": "evenodd" + "clip-rule": "evenodd", } } } @@ -68,9 +65,7 @@ pub fn Accordion( // Collapsible content (only if has children) if has_children { div { class: "collapse-content p-0", - ul { class: "menu w-full", - {children} - } + ul { class: "menu w-full", {children} } } } } diff --git a/src/components/inputs.css b/src/components/inputs.css index 7030fc7..535ab49 100644 --- a/src/components/inputs.css +++ b/src/components/inputs.css @@ -36,10 +36,11 @@ /* Text Input */ .text-input { @apply relative; + height: 3.5rem; } .text-input input { - @apply input w-full pt-6 pb-2; + @apply input w-full h-full pt-6 pb-2; } .text-input label { @@ -49,6 +50,7 @@ /* Select Form */ .select-form { @apply relative; + height: 3.5rem; } .select-form select { @@ -62,6 +64,7 @@ /* Number Input */ .number-input { @apply join w-full; + height: 3.5rem; } .number-input .number-input-field { diff --git a/src/main.css b/src/main.css index 45b0987..0f7b125 100644 --- a/src/main.css +++ b/src/main.css @@ -11,17 +11,10 @@ /* Layout */ @import "./pages/layout.css"; +@import "./pages/widget.css"; /* Pages */ @import "./pages/home_page.css"; -@import "./pages/encoder_decoder/base64_encoder.css"; -@import "./pages/encoder_decoder/cidr_decoder.css"; @import "./pages/converter/date_converter.css"; -@import "./pages/converter/json_yaml_converter.css"; -@import "./pages/converter/number_base_converter.css"; -@import "./pages/generator/hash_generator.css"; @import "./pages/generator/qr_code_generator.css"; -@import "./pages/generator/uuid_generator.css"; -@import "./pages/generator/lorem_ipsum.css"; -@import "./pages/generator/password_generator.css"; @import "./pages/media/color_picker.css"; diff --git a/src/pages/converter/date_converter.css b/src/pages/converter/date_converter.css index 2c00deb..bb51b42 100644 --- a/src/pages/converter/date_converter.css +++ b/src/pages/converter/date_converter.css @@ -1,28 +1,30 @@ -/* Date Converter */ +/* Date Converter - uses .widget-grid from widget.css */ @layer components { - .date-converter { - @apply grid gap-y-3; - } - + /* Widget-specific: date/time selectors layout */ .date-converter .selectors-wrapper { - @apply flex flex-row gap-y-3 gap-x-2 flex-nowrap; + @apply flex flex-row gap-y-3 gap-x-3 flex-nowrap; } .date-converter .selectors { - @apply flex-col; + @apply flex flex-col flex-1; } .date-converter .selectors-inner { - @apply flex flex-row flex-nowrap gap-x-2; + @apply flex flex-row flex-nowrap gap-x-3; } .date-converter .selectors-inner .number-input { @apply flex-1; + min-width: 100px; } @media screen and (max-width: 835px) { .date-converter .selectors-wrapper { @apply flex-wrap; } + + .date-converter .selectors { + @apply w-full; + } } } diff --git a/src/pages/converter/date_converter.rs b/src/pages/converter/date_converter.rs index a2e317c..eb6bfda 100644 --- a/src/pages/converter/date_converter.rs +++ b/src/pages/converter/date_converter.rs @@ -31,7 +31,7 @@ pub fn DateConverter() -> Element { let unix_time = date_signal.with(|date_state| date_state.time_utc.unix_timestamp()); rsx! { - div { class: "date-converter", + div { class: "widget-grid date-converter", SelectForm:: { label: "Time Zone", oninput: move |tz: DcTimeZone| { diff --git a/src/pages/converter/json_yaml_converter.css b/src/pages/converter/json_yaml_converter.css deleted file mode 100644 index db40a67..0000000 --- a/src/pages/converter/json_yaml_converter.css +++ /dev/null @@ -1,10 +0,0 @@ -/* JSON <> YAML Converter */ -@layer components { - .json-yaml-converter { - @apply flex flex-col gap-y-3 h-full; - } - - .json-yaml-converter .textarea-form { - flex: 1 1 auto; - } -} diff --git a/src/pages/converter/json_yaml_converter.rs b/src/pages/converter/json_yaml_converter.rs index ab5df00..87bb125 100644 --- a/src/pages/converter/json_yaml_converter.rs +++ b/src/pages/converter/json_yaml_converter.rs @@ -23,7 +23,7 @@ pub fn JsonYamlConverter() -> Element { }) }); rsx! { - div { class: "json-yaml-converter", + div { class: "widget", converter_input { direction: Direction::Json } converter_input { direction: Direction::Yaml } } @@ -51,17 +51,19 @@ fn converter_input(direction: Direction) -> Element { match direction { Direction::Json => { let yaml_result = convert_json_to_yaml(&input_value); - value_context.set(ConverterValue { - json_value: input_value, - yaml_value: yaml_result, - }); + value_context + .set(ConverterValue { + json_value: input_value, + yaml_value: yaml_result, + }); } Direction::Yaml => { let json_result = convert_yaml_to_json(&input_value); - value_context.set(ConverterValue { - json_value: json_result, - yaml_value: input_value, - }); + value_context + .set(ConverterValue { + json_value: json_result, + yaml_value: input_value, + }); } }; }, diff --git a/src/pages/converter/number_base_converter.css b/src/pages/converter/number_base_converter.css deleted file mode 100644 index d8e7bb6..0000000 --- a/src/pages/converter/number_base_converter.css +++ /dev/null @@ -1,6 +0,0 @@ -/* Number Base Converter */ -@layer components { - .number-base-converter { - @apply grid gap-y-3; - } -} diff --git a/src/pages/converter/number_base_converter.rs b/src/pages/converter/number_base_converter.rs index 737e392..05523cd 100644 --- a/src/pages/converter/number_base_converter.rs +++ b/src/pages/converter/number_base_converter.rs @@ -21,7 +21,7 @@ pub fn NumberBaseConverter() -> Element { let mut format_number_state = use_context_provider(|| Signal::new(FormatNumberState(false))); rsx! { - div { class: "number-base-converter", + div { class: "widget-grid", SwitchInput { label: "Format Numbers", checked: format_number_state.read().0, diff --git a/src/pages/encoder_decoder/base64_encoder.css b/src/pages/encoder_decoder/base64_encoder.css deleted file mode 100644 index 1ff4fd6..0000000 --- a/src/pages/encoder_decoder/base64_encoder.css +++ /dev/null @@ -1,10 +0,0 @@ -/* Base64 Encoder */ -@layer components { - .base64-encoder { - @apply flex flex-col gap-y-3 h-full; - } - - .base64-encoder .textarea-form { - flex: 1 1 auto; - } -} diff --git a/src/pages/encoder_decoder/base64_encoder.rs b/src/pages/encoder_decoder/base64_encoder.rs index c9c3bdf..205cded 100644 --- a/src/pages/encoder_decoder/base64_encoder.rs +++ b/src/pages/encoder_decoder/base64_encoder.rs @@ -24,7 +24,7 @@ pub fn Base64Encoder() -> Element { }) }); rsx! { - div { class: "base64-encoder", + div { class: "widget", encoder_input { direction: Direction::Encode } encoder_input { direction: Direction::Decode } } diff --git a/src/pages/encoder_decoder/cidr_decoder.css b/src/pages/encoder_decoder/cidr_decoder.css deleted file mode 100644 index 5a8d0c4..0000000 --- a/src/pages/encoder_decoder/cidr_decoder.css +++ /dev/null @@ -1,10 +0,0 @@ -/* CIDR Decoder */ -@layer components { - .cidr-decoder { - @apply flex flex-col gap-y-3 h-full; - } - - .cidr-decoder .textarea-form { - flex: 1 1 auto; - } -} diff --git a/src/pages/encoder_decoder/cidr_decoder.rs b/src/pages/encoder_decoder/cidr_decoder.rs index 2965dc4..ff1fce4 100644 --- a/src/pages/encoder_decoder/cidr_decoder.rs +++ b/src/pages/encoder_decoder/cidr_decoder.rs @@ -68,7 +68,7 @@ pub fn CidrDecoder() -> Element { let mut show_error_state = use_signal(|| false); rsx! { - div { class: "cidr-decoder", + div { class: "widget", TextInput { label: "CIDR", value: "{cidr_input_ref.with(|cidr_str| cidr_str.to_string())}", diff --git a/src/pages/generator/hash_generator.css b/src/pages/generator/hash_generator.css deleted file mode 100644 index 36d4f65..0000000 --- a/src/pages/generator/hash_generator.css +++ /dev/null @@ -1,10 +0,0 @@ -/* Hash Generator */ -@layer components { - .hash-generator { - @apply grid gap-y-3; - } - - .hash-generator .textarea-form { - height: 20em; - } -} diff --git a/src/pages/generator/hash_generator.rs b/src/pages/generator/hash_generator.rs index b3d47aa..35639ea 100644 --- a/src/pages/generator/hash_generator.rs +++ b/src/pages/generator/hash_generator.rs @@ -27,7 +27,7 @@ pub fn HashGenerator() -> Element { }); rsx! { - div { class: "number-base-converter", + div { class: "widget", SwitchInput { label: "Uppercase", checked: hash_generator_state.read().uppercase, diff --git a/src/pages/generator/lorem_ipsum.css b/src/pages/generator/lorem_ipsum.css deleted file mode 100644 index 743ebef..0000000 --- a/src/pages/generator/lorem_ipsum.css +++ /dev/null @@ -1,31 +0,0 @@ -/* Lorem Ipsum Generator */ -@layer components { - .lorem-ipsum-generator { - @apply flex flex-col gap-y-3 h-full; - } - - .lorem-ipsum-generator .textarea-form { - flex: 1 1 auto; - } - - .lorem-ipsum-generator .params { - @apply flex flex-row gap-x-3 gap-y-3 flex-wrap; - } - - .lorem-ipsum-generator .params > div:not(.switches):not(.buttons) { - @apply flex grow; - min-width: 225px; - } - - .lorem-ipsum-generator .params .buttons { - @apply flex flex-row gap-x-3 items-center; - } - - .lorem-ipsum-generator .params .switches { - @apply flex flex-col gap-y-3 justify-center; - } - - .lorem-ipsum-generator .params .switches .switch { - @apply gap-x-1; - } -} diff --git a/src/pages/generator/lorem_ipsum.rs b/src/pages/generator/lorem_ipsum.rs index d14ec4d..23195e7 100644 --- a/src/pages/generator/lorem_ipsum.rs +++ b/src/pages/generator/lorem_ipsum.rs @@ -89,8 +89,8 @@ pub fn LoremIpsum() -> Element { }; rsx! { - div { class: "lorem-ipsum-generator", - div { class: "params", + div { class: "widget", + div { class: "widget-params", SelectForm:: { label: "Mode", value: *mode.read(), @@ -101,19 +101,15 @@ pub fn LoremIpsum() -> Element { value: *count.read(), onchange: move |value: usize| count.set(value.clamp(1, 50)), } - div { class: "buttons", - button { - class: "btn btn-info", - onclick: generate, - "Generate" - } + div { class: "widget-buttons", + button { class: "btn btn-info", onclick: generate, "Generate" } button { class: "btn btn-error", onclick: move |_| generated_text.set(String::new()), "Clear" } } - div { class: "switches", + div { class: "widget-switches", SwitchInput { label: "Start with \"Lorem ipsum...\"", checked: *start_with_lorem.read(), diff --git a/src/pages/generator/password_generator.css b/src/pages/generator/password_generator.css deleted file mode 100644 index f75d5fb..0000000 --- a/src/pages/generator/password_generator.css +++ /dev/null @@ -1,31 +0,0 @@ -/* Password Generator */ -@layer components { - .password-generator { - @apply flex flex-col gap-y-3 h-full; - } - - .password-generator .textarea-form { - flex: 1 1 auto; - } - - .password-generator .params { - @apply flex flex-row gap-x-3 gap-y-3 flex-wrap; - } - - .password-generator .params > div:not(.switches):not(.buttons) { - @apply flex grow; - min-width: 225px; - } - - .password-generator .params .buttons { - @apply flex flex-row gap-x-3 items-center; - } - - .password-generator .params .switches { - @apply flex flex-row gap-x-3 gap-y-3 flex-wrap items-center; - } - - .password-generator .params .switches .switch { - @apply gap-x-1; - } -} diff --git a/src/pages/generator/password_generator.rs b/src/pages/generator/password_generator.rs index 58c97e6..0fbfced 100644 --- a/src/pages/generator/password_generator.rs +++ b/src/pages/generator/password_generator.rs @@ -4,7 +4,7 @@ use dioxus_free_icons::icons::fa_solid_icons::FaKey; use rand::Rng; use crate::{ - components::inputs::{NumberInput, SwitchInput, TextAreaForm}, + components::inputs::{NumberInput, SwitchInput, TextAreaForm, TextInput}, pages::{WidgetEntry, WidgetIcon}, }; @@ -115,8 +115,8 @@ pub fn PasswordGenerator() -> Element { let passwords_str = passwords.with(|p| p.join("\n")); rsx! { - div { class: "password-generator", - div { class: "params", + div { class: "widget", + div { class: "widget-params", NumberInput:: { label: "Password Length", value: *length.read(), @@ -131,7 +131,7 @@ pub fn PasswordGenerator() -> Element { quantity.set(value.clamp(1, 100)); }, } - div { class: "buttons", + div { class: "widget-buttons", button { class: "btn btn-info", onclick: generate_passwords, "Generate" } button { class: "btn btn-error", @@ -139,7 +139,7 @@ pub fn PasswordGenerator() -> Element { "Clear" } } - div { class: "switches", + div { class: "widget-switches", SwitchInput { label: "Uppercase (A-Z)", checked: *use_uppercase.read(), @@ -168,7 +168,11 @@ pub fn PasswordGenerator() -> Element { } } - div { class: "entropy-display", "Entropy: {entropy:.0} bits ({entropy_label})" } + TextInput { + label: "Entropy", + value: "{entropy:.0} bits ({entropy_label})", + readonly: true, + } TextAreaForm { label: "Generated Passwords", diff --git a/src/pages/generator/qr_code_generator.css b/src/pages/generator/qr_code_generator.css index 8a97b09..84dbde1 100644 --- a/src/pages/generator/qr_code_generator.css +++ b/src/pages/generator/qr_code_generator.css @@ -1,13 +1,6 @@ -/* QR Code Generator */ +/* QR Code Generator - uses .widget from widget.css */ @layer components { - .qr-code-generator { - @apply grid gap-y-3; - } - - .qr-code-generator .textarea-form { - height: 14em; - } - + /* Widget-specific: QR code display size */ .qr-code-generator .qr-code { width: 30em; height: 30em; diff --git a/src/pages/generator/qr_code_generator.rs b/src/pages/generator/qr_code_generator.rs index a189918..e6a77ce 100644 --- a/src/pages/generator/qr_code_generator.rs +++ b/src/pages/generator/qr_code_generator.rs @@ -36,7 +36,7 @@ pub fn QrCodeGenerator() -> Element { }; rsx! { - div { class: "qr-code-generator", + div { class: "widget qr-code-generator", SelectForm:: { label: "Error Correction Level", oninput: move |ecc: Ecc| { diff --git a/src/pages/generator/uuid_generator.css b/src/pages/generator/uuid_generator.css deleted file mode 100644 index 14b508e..0000000 --- a/src/pages/generator/uuid_generator.css +++ /dev/null @@ -1,27 +0,0 @@ -/* UUID Generator */ -@layer components { - .uuid-generator { - @apply flex flex-col gap-y-3 h-full; - } - - .uuid-generator .textarea-form { - flex: 1 1 auto; - } - - .uuid-generator .params { - @apply flex flex-row gap-x-3 gap-y-3 flex-wrap; - } - - .uuid-generator .params > div:not(.switches) { - @apply flex grow; - min-width: 225px; - } - - .uuid-generator .params .switches { - @apply flex flex-col gap-y-3; - } - - .uuid-generator .params .switches .switch { - @apply gap-x-1; - } -} diff --git a/src/pages/generator/uuid_generator.rs b/src/pages/generator/uuid_generator.rs index 415a38b..8f8e650 100644 --- a/src/pages/generator/uuid_generator.rs +++ b/src/pages/generator/uuid_generator.rs @@ -26,24 +26,8 @@ pub fn UuidGenerator() -> Element { let uuids_str = uuids_state.with(|uuids_vec| uuids_vec.join("\n")); rsx! { - div { class: "uuid-generator", - div { class: "params", - div { class: "switches", - SwitchInput { - label: "Hyphens", - checked: true, - oninput: move |value| { - hyphens_state.set(value); - }, - } - SwitchInput { - label: "Uppercase", - checked: true, - oninput: move |value| { - uppercase_state.set(value); - }, - } - } + div { class: "widget", + div { class: "widget-params", SelectForm:: { label: "UUID Version", value: *uuid_version_state.read(), @@ -58,35 +42,50 @@ pub fn UuidGenerator() -> Element { num_uuids_state.set(value); }, } - } - - div { class: "buttons", - button { - class: "btn btn-info me-3", - onclick: move |_| { - let mut uuids = vec![]; - for _ in 0..*num_uuids_state.read() { - let uuid = uuid::Uuid::new_v4(); - let mut uuid = if *hyphens_state.read() { - uuid.hyphenated().to_string() - } else { - uuid.simple().to_string() - }; - if *uppercase_state.read() { - uuid = uuid.to_uppercase(); + div { class: "widget-buttons", + button { + class: "btn btn-info", + onclick: move |_| { + let mut uuids = vec![]; + for _ in 0..*num_uuids_state.read() { + let uuid = uuid::Uuid::new_v4(); + let mut uuid = if *hyphens_state.read() { + uuid.hyphenated().to_string() + } else { + uuid.simple().to_string() + }; + if *uppercase_state.read() { + uuid = uuid.to_uppercase(); + } + uuids.push(uuid); } - uuids.push(uuid); - } - uuids_state.write().append(&mut uuids); - }, - "Generate" + uuids_state.write().append(&mut uuids); + }, + "Generate" + } + button { + class: "btn btn-error", + onclick: move |_| { + uuids_state.write().clear(); + }, + "Clear" + } } - button { - class: "btn btn-error", - onclick: move |_| { - uuids_state.write().clear(); - }, - "Clear" + div { class: "widget-switches", + SwitchInput { + label: "Hyphens", + checked: true, + oninput: move |value| { + hyphens_state.set(value); + }, + } + SwitchInput { + label: "Uppercase", + checked: true, + oninput: move |value| { + uppercase_state.set(value); + }, + } } } TextAreaForm { label: "UUIDs", value: "{uuids_str}", readonly: true } diff --git a/src/pages/media/color_picker.css b/src/pages/media/color_picker.css index e24ab07..606d6a2 100644 --- a/src/pages/media/color_picker.css +++ b/src/pages/media/color_picker.css @@ -67,7 +67,7 @@ } .color-picker .color-view { - @apply mt-4 flex flex-row; + @apply mt-3 flex flex-row gap-x-3; height: 4rem; } @@ -80,6 +80,6 @@ .color-picker .color-view .text-input, .color-picker .color-view .select-form { - @apply m-auto grow ml-4; + @apply m-auto grow; } } diff --git a/src/pages/widget.css b/src/pages/widget.css new file mode 100644 index 0000000..0fb6c77 --- /dev/null +++ b/src/pages/widget.css @@ -0,0 +1,42 @@ +/* Widget Layout System */ +@layer components { + /* Base container: flex column, full height, consistent gap */ + .widget { + @apply flex flex-col gap-y-3 h-full; + } + + /* Textareas flex-fill available space */ + .widget .textarea-form { + flex: 1 1 auto; + } + + /* Params section: horizontal flex-wrap */ + .widget-params { + @apply flex flex-row gap-x-3 gap-y-3 flex-wrap; + } + + /* Params children grow (except buttons/switches) */ + .widget-params > div:not(.widget-switches):not(.widget-buttons) { + @apply flex grow; + min-width: 225px; + } + + /* Button group */ + .widget-buttons { + @apply flex flex-row gap-x-3 items-center; + } + + /* Switch group */ + .widget-switches { + @apply flex flex-row gap-x-3 items-center; + } + + .widget-switches .switch-input { + @apply gap-x-1; + } + + /* Grid variant for widgets without flex-fill textareas */ + .widget-grid { + @apply grid gap-y-3; + } +}