diff --git a/AGENTS.md b/AGENTS.md index 09fbde0..e5b7a10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ cargo test --manifest-path src-tauri/Cargo.toml or an agent in Boite). It never reads Boite's files beyond `core.json`. - Tests and manual runs use a throwaway core on a temporary data folder, never the data of a Boite someone uses. -- Native commands check which webview calls them (`only_bots`, - `only_companion`), and every command is listed in +- Native commands check which webview calls them (`only_companion`: the + companion's window is Bots's only page), and every command is listed in `src-tauri/permissions/bots.toml` and covered by `src-tauri/src/acl.rs`. - Quit exits with code 0; any other exit tells Boite to start Bots again. diff --git a/README.md b/README.md index 1c85f08..8957dbd 100644 --- a/README.md +++ b/README.md @@ -132,10 +132,10 @@ A pomodoro or a timer sits beside it. ### Settings -Everything else is in **Settings**, from the tray icon: where the robots sit and on which screen, the shortcut, what the pill shows, the mascot and the agents on screen, quotas, pomodoro, missions, sounds and reminders. In English and French. +Everything else is in **Settings**, a panel that grows out of the open island under its bar, from the bar's gear or the tray icon: where the robots sit and on which screen, the shortcut, what the pill shows, the mascot and the agents on screen, quotas, pomodoro, missions, sounds and reminders. In English and French.

- The B.O.T.S settings: where the robots sit, open on hover, pill at rest, screen, shortcut and the mascot + The B.O.T.S settings under the open island's bar: a rail of sections, and Place with where the robots sit, the screen, the shortcut, open on hover, the pill at rest and the bar's edit mode

## Install @@ -173,7 +173,7 @@ cd src-tauri && cargo test && cargo clippy --all-targets -- -D warnings The demo runs the robots on Boite's in-memory fake core: no Boite needed. -- In a browser: `bun run dev`, then open `http://localhost:5174/?fake=1` (add `&hud=1` for busy agents and quotas, or open `?view=settings&fake=1` for the settings). +- In a browser: `bun run dev`, then open `http://localhost:5174/?fake=1` (add `&hud=1` for busy agents and quotas, or `&pose=settings§ion=crew` for the settings panel on a section). - In the desktop window: `BOTS_DEMO=1 bun run tauri dev` (`BOTS_DEMO=hud` for the busy version). Development builds only. In the browser, a few more parameters set a scene for captures: `pose=collapsed|peek|open|deck|desktop|away`, `tab=threads|today|chat|stats|music|apps|clipboard|wallpaper|system`, `calm=1` to answer the starting requests, `music=playing&lyrics=1`, `rest=live|quota|music|timer|robot`, `timer=1`, `todo=1`, `mission=ask|list`, `source=engine|recent|folder` for the wallpapers, `bar=custom` and `edit=1` for the bar, and more. [`src/lib/island/pose.ts`](src/lib/island/pose.ts) lists them all. The pictures of this README were taken that way, with the English interface. diff --git a/docs/media/settings.png b/docs/media/settings.png index 156d269..2223e6d 100644 Binary files a/docs/media/settings.png and b/docs/media/settings.png differ diff --git a/src-tauri/capabilities/settings.json b/src-tauri/capabilities/settings.json deleted file mode 100644 index 65c7984..0000000 --- a/src-tauri/capabilities/settings.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "settings", - "description": "The settings window may connect to the core and list the screens. Remote origins receive nothing.", - "webviews": ["settings"], - "permissions": ["allow-settings-ui", "core:event:default"] -} diff --git a/src-tauri/permissions/bots.toml b/src-tauri/permissions/bots.toml index 0293698..9de4658 100644 --- a/src-tauri/permissions/bots.toml +++ b/src-tauri/permissions/bots.toml @@ -1,6 +1,6 @@ [[permission]] identifier = "allow-companion-ui" -description = "Allows the companion to connect to the core, place its window on the desktop or as the island, take clicks where it draws, read the music playing, register its shortcut, see the screen when asked, take the keyboard when files are dropped on it, open its settings and name the tray; and, for the deck's computer tabs, read the system's numbers, close a process, lock, sleep, restart or shut down the computer, list and open the Start menu's apps, keep a clipboard history in memory and copy an entry back, list and set wallpapers, and drive Wallpaper Engine; and, for the island's bar, open a web link the user added there." +description = "Allows the companion to connect to the core, place its window on the desktop or as the island, take clicks where it draws, read the music playing, register its shortcut, see the screen when asked, take the keyboard when files are dropped on it, open its settings panel and name the tray; and, for the deck's computer tabs, read the system's numbers, close a process, lock, sleep, restart or shut down the computer, list and open the Start menu's apps, keep a clipboard history in memory and copy an entry back, list and set wallpapers, and drive Wallpaper Engine; and, for the island's bar, open a web link the user added there." commands.allow = [ "core_endpoint", "companion_monitors", @@ -42,7 +42,3 @@ commands.allow = [ "computer_engine_control", ] -[[permission]] -identifier = "allow-settings-ui" -description = "Allows the settings window to connect to the core and list the screens the companion can sit on." -commands.allow = ["core_endpoint", "companion_monitors"] diff --git a/src-tauri/src/acl.rs b/src-tauri/src/acl.rs index ed7df57..4e7bb6b 100644 --- a/src-tauri/src/acl.rs +++ b/src-tauri/src/acl.rs @@ -34,14 +34,11 @@ mod tests { } #[test] - fn the_settings_window_connects_and_lists_screens_but_does_not_drive_the_companion() { + fn the_settings_live_in_the_companion_and_no_settings_webview_is_granted_anything() { let mut context: tauri::Context = crate::bots_context(); let authority = context.runtime_authority_mut(); - for command in ["core_endpoint", "companion_monitors", "plugin:event|listen", "plugin:event|unlisten"] { - assert!(authority.resolve_access(command, "settings", "settings", &Origin::Local).is_some(), "settings cannot invoke {command}"); - } - for command in ["companion_place", "island_place", "companion_capture", "companion_keep", "companion_configure", "bots_tray_labels", "bots_settings"] { - assert!(authority.resolve_access(command, "settings", "settings", &Origin::Local).is_none(), "settings can invoke {command}"); + for command in COMPANION { + assert!(authority.resolve_access(command, "settings", "settings", &Origin::Local).is_none(), "a settings webview can invoke {command}"); } } } diff --git a/src-tauri/src/companion_window.rs b/src-tauri/src/companion_window.rs index 7be6ab4..86f8d40 100644 --- a/src-tauri/src/companion_window.rs +++ b/src-tauri/src/companion_window.rs @@ -254,7 +254,7 @@ fn place(window: &WebviewWindow, monitor: Option<&str>, anchor: A /// The page's query: `BOTS_DEMO=1` runs a development build on the fake core, /// `BOTS_DEMO=hud` with the HUD demo seeded too. A release build ignores it. -pub fn demo_query() -> &'static str { +fn demo_query() -> &'static str { if !cfg!(debug_assertions) { return ""; } match std::env::var("BOTS_DEMO").as_deref() { Ok("hud") => "fake=1&hud=1", @@ -428,12 +428,18 @@ fn watch(app: AppHandle, shared: Shared, mine: u64) { /// The shortcut, or Bots launched again: the companion comes forward, over a /// full-screen app too, and the page opens its question bar. pub fn summon(app: &AppHandle) { - let Some(window) = app.get_webview_window(LABEL) else { return }; + if let Some(window) = bring_forward(app) { let _ = window.emit("companion://summon", ()); } +} + +/// The companion's window over the others, with the keyboard, back from +/// hiding for a full-screen app: for the shortcut and for Settings. +pub fn bring_forward(app: &AppHandle) -> Option> { + let window = app.get_webview_window(LABEL)?; app.state::().shared.hidden.store(false, Ordering::Release); let _ = window.show(); crate::platform::keep_on_top(&window); let _ = window.set_focus(); - let _ = window.emit("companion://summon", ()); + Some(window) } /// Opens the companion, at start. It stays until Bots quits: closing it is @@ -465,7 +471,7 @@ fn window_of(app: &AppHandle) -> Result { /// The screens connected now, for the setting that picks one. #[tauri::command] pub async fn companion_monitors(app: AppHandle, webview: Webview) -> Result, String> { - crate::only_bots(&webview)?; + only_companion(&webview)?; let primary = app.primary_monitor().map_err(|error| error.to_string())?.map(|monitor| monitor_name(&monitor)); Ok(app.available_monitors().map_err(|error| error.to_string())?.into_iter().map(|monitor| { let name = monitor_name(&monitor); diff --git a/src-tauri/src/endpoint.rs b/src-tauri/src/endpoint.rs index 5f5caaa..22f543d 100644 --- a/src-tauri/src/endpoint.rs +++ b/src-tauri/src/endpoint.rs @@ -108,11 +108,11 @@ fn find() -> Option { listening(&endpoint.url).then_some(endpoint) } -/// The core to connect to, or none yet. Only Bots's own pages may ask: the -/// answer carries the owner's token. +/// The core to connect to, or none yet. Only the companion's page may ask: +/// the answer carries the owner's token. #[tauri::command] pub async fn core_endpoint(webview: Webview) -> Result, String> { - crate::only_bots(&webview)?; + crate::companion_window::only_companion(&webview)?; tauri::async_runtime::spawn_blocking(find).await.map_err(|error| error.to_string()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0c4977b..da6eeee 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -18,17 +18,6 @@ mod tray; #[link(name = "resource", kind = "static", modifiers = "-bundle")] unsafe extern "C" {} -use tauri::Webview; - -/// Bots's own pages, the companion and its settings: the only ones that may -/// ask for the core's token or list the screens. -pub(crate) fn only_bots(webview: &Webview) -> Result<(), String> { - match webview.label() { - companion_window::LABEL | tray::SETTINGS_LABEL => Ok(()), - other => Err(format!("the webview {other:?} may not invoke this command")), - } -} - // One expansion: on macOS generate_context also embeds the application plist. fn bots_context() -> tauri::Context { tauri::generate_context!() @@ -102,8 +91,7 @@ pub fn run() { } }; app.run(|_app, event| { - // The settings window closing leaves the companion: Bots stops on Quit - // alone, or when Boite stops it. + // Bots stops on Quit alone, or when Boite stops it. if let tauri::RunEvent::ExitRequested { code: None, api, .. } = event { api.prevent_exit(); } }); } diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index e00b3a3..c06c827 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -1,22 +1,24 @@ -//! The tray icon, its menu, the settings window and quitting. +//! The tray icon, its menu, opening the settings and quitting. +//! +//! The settings have no window of their own: they are a panel of the island, +//! in the companion's window (`components/island/settings/`). Settings brings +//! that window forward and asks its page for the panel (`companion://settings`). -use std::sync::{Mutex, PoisonError}; use tauri::{ menu::{Menu, MenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - AppHandle, Manager, Runtime, Webview, WebviewUrl, + AppHandle, Emitter, Manager, Runtime, Webview, }; -pub const SETTINGS_LABEL: &str = "settings"; pub const TRAY_ID: &str = "bots"; +/// The longest section name the page may ask the panel to open on. +const SECTION_MAX: usize = 24; -/// The tray menu's items and the settings window's title, kept so the page -/// can name them in its language. +/// The tray menu's items, kept so the page can name them in its language. struct Labels { tray: tauri::tray::TrayIcon, settings: MenuItem, quit: MenuItem, - title: Mutex, } /// Quit: Bots stops with code 0, which Boite reads as stopped on purpose, @@ -25,47 +27,43 @@ pub fn quit(app: &AppHandle) { app.exit(0); } -/// Opens the settings window, or brings it forward when it is open. -pub fn open_settings(app: &AppHandle) -> tauri::Result<()> { - if let Some(window) = app.get_webview_window(SETTINGS_LABEL) { - let _ = window.unminimize(); - window.show()?; - return window.set_focus(); - } - let title = app.try_state::>() - .map(|labels| labels.title.lock().unwrap_or_else(PoisonError::into_inner).clone()) - .unwrap_or_else(|| "B.O.T.S settings".into()); - let query = crate::companion_window::demo_query(); - let page = if query.is_empty() { "index.html?view=settings".to_string() } else { format!("index.html?view=settings&{query}") }; - let window = tauri::WebviewWindowBuilder::new(app, SETTINGS_LABEL, WebviewUrl::App(page.into())) - .title(title).inner_size(720.0, 760.0).min_inner_size(520.0, 480.0) - .on_navigation(|url| matches!(url.scheme(), "tauri" | "http" | "https") && matches!(url.host_str(), Some("tauri.localhost") | Some("localhost"))) - .build()?; - window.set_focus() +/// A section the page may name: a short lowercase word. The page knows its +/// sections (`lib/island/settings-panel.svelte.ts`) and opens the last one +/// shown on any name it does not. +fn is_section(name: &str) -> bool { + !name.is_empty() && name.len() <= SECTION_MAX && name.bytes().all(|byte| byte.is_ascii_lowercase()) +} + +/// The companion's window comes forward with the keyboard and opens its +/// settings panel, on `section` when one is named. +pub fn open_settings(app: &AppHandle, section: Option<&str>) -> tauri::Result<()> { + let Some(window) = crate::companion_window::bring_forward(app) else { return Ok(()) }; + window.emit("companion://settings", section) } -/// `companion://settings` from the companion: its settings, in their own window. +/// The companion asks for its settings panel, on a section or the last one shown. #[tauri::command] -pub async fn bots_settings(app: AppHandle, webview: Webview) -> Result<(), String> { +pub async fn bots_settings(app: AppHandle, webview: Webview, section: Option) -> Result<(), String> { crate::companion_window::only_companion(&webview)?; - open_settings(&app).map_err(|error| error.to_string()) + if let Some(name) = section.as_deref().filter(|name| !is_section(name)) { + return Err(format!("{name:?} is not a settings section")); + } + open_settings(&app, section.as_deref()).map_err(|error| error.to_string()) } /// The tray is native and the sentences live in the page's catalogue: the /// companion sends them at start and on every language change. English until /// then. #[tauri::command] -pub fn bots_tray_labels(app: AppHandle, webview: Webview, tooltip: String, settings: String, quit: String, title: String) -> Result<(), String> { +pub fn bots_tray_labels(app: AppHandle, webview: Webview, tooltip: String, settings: String, quit: String) -> Result<(), String> { crate::companion_window::only_companion(&webview)?; - if [&tooltip, &settings, &quit, &title].iter().any(|label| label.trim().is_empty()) { - return Err(format!("tray labels must not be empty: tooltip {tooltip:?}, settings {settings:?}, quit {quit:?}, title {title:?}")); + if [&tooltip, &settings, &quit].iter().any(|label| label.trim().is_empty()) { + return Err(format!("tray labels must not be empty: tooltip {tooltip:?}, settings {settings:?}, quit {quit:?}")); } let Some(labels) = app.try_state::>() else { return Ok(()) }; labels.tray.set_tooltip(Some(tooltip)).map_err(|error| format!("the tray kept its tooltip: {error}"))?; labels.settings.set_text(settings).map_err(|error| format!("the tray's Settings item kept its label: {error}"))?; labels.quit.set_text(quit).map_err(|error| format!("the tray's Quit item kept its label: {error}"))?; - *labels.title.lock().unwrap_or_else(PoisonError::into_inner) = title.clone(); - if let Some(window) = app.get_webview_window(SETTINGS_LABEL) { let _ = window.set_title(&title); } Ok(()) } @@ -85,7 +83,7 @@ pub fn build_tray(app: &AppHandle) -> tauri::Result<()> { }) .on_menu_event(|app, event| match event.id().as_ref() { "settings" => { - if let Err(error) = open_settings(app) { eprintln!("[bots] the settings window did not open: {error}"); } + if let Err(error) = open_settings(app, None) { eprintln!("[bots] the settings did not open: {error}"); } } "quit" => quit(app), _ => {} @@ -94,6 +92,21 @@ pub fn build_tray(app: &AppHandle) -> tauri::Result<()> { builder = builder.icon(icon); } let tray = builder.build(app)?; - app.manage(Labels { tray, settings, quit: leave, title: Mutex::new("B.O.T.S settings".into()) }); + app.manage(Labels { tray, settings, quit: leave }); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_section_is_a_short_lowercase_word() { + for name in ["place", "crew", "permissions"] { + assert!(is_section(name), "{name}"); + } + for name in ["", "Crew", "crew settings", "../crew", "brain-model", "a".repeat(SECTION_MAX + 1).as_str()] { + assert!(!is_section(name), "{name:?}"); + } + } +} diff --git a/src/App.svelte b/src/App.svelte index a996a01..bcbf75e 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -9,6 +9,11 @@ (`components/desktop/`). Dragging a robot out of the island sets it loose; dragging it back against the top edge, or "Back to the island", returns it. The shell places the window for either (`island_place`, `companion_place`). + + The settings are a panel of the island (`island/settings/SettingsPanel.svelte`): + asked for from the tray, a gear or "Make an agent", the island opens for + them, drawn for them even while the robots are loose, and held open while + they show. --> -
+
- {#if island} + {#if shown} {:else} diff --git a/src/SettingsApp.svelte b/src/SettingsApp.svelte deleted file mode 100644 index 3d2a9e1..0000000 --- a/src/SettingsApp.svelte +++ /dev/null @@ -1,81 +0,0 @@ - - - -
- {#if link.waiting} -
{}} />
- {/if} - -
(notice = null)} />
-
- - diff --git a/src/app.test.ts b/src/app.test.ts index 09d5876..07f71a0 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -1,7 +1,7 @@ import { afterEach, expect, test, vi } from 'vitest'; import { flushSync, mount, unmount } from 'svelte'; import App from './App.svelte'; -import SettingsApp from './SettingsApp.svelte'; +import { requestSettings, settingsPanel } from './lib/island/settings-panel.svelte'; import { strings } from './lib/strings'; /** The demo loads its fake core on demand: in a cold run its first import alone can take seconds. */ @@ -9,7 +9,7 @@ const DEMO_WAIT = { timeout: 15_000 }; let shown: { app: Record; target: HTMLElement } | null = null; -function show(page: typeof App | typeof SettingsApp, search: string) { +function show(page: typeof App, search: string) { history.replaceState(null, '', `/${search}`); const target = document.body.appendChild(document.createElement('div')); shown = { app: mount(page, { target }), target }; @@ -23,6 +23,7 @@ afterEach(() => { shown.target.remove(); shown = null; } + settingsPanel.close(); window.localStorage.clear(); history.replaceState(null, '', '/'); }); @@ -39,15 +40,38 @@ test('the demo mode runs the companion on the fake core', async () => { expect(target.querySelector('[data-testid="companion"]')).not.toBeNull(); }); -test('the settings window lists the crew, without a link to Boite\'s Advanced page', async () => { - const target = show(SettingsApp, '?view=settings&fake=1'); +test('a request opens the island and its settings on a section; the deck\'s gear puts them away', async () => { + const target = show(App, '?fake=1'); + await vi.waitFor(() => expect(target.querySelector('[data-testid="bots-notice"]')?.textContent).toContain(strings.bots.demo), DEMO_WAIT); + requestSettings('crew'); + flushSync(); + expect(target.querySelector('[data-testid="island-settings-panel"]')).not.toBeNull(); + const gear = target.querySelector('[data-testid="island-settings"]')!; + expect(gear.getAttribute('aria-pressed')).toBe('true'); + expect(target.querySelector('[data-testid="settings-section-crew"]')?.getAttribute('aria-selected')).toBe('true'); await vi.waitFor(() => expect(target.querySelector('[data-testid="companion-crew"] ul, [data-testid="companion-crew"] form')).not.toBeNull(), DEMO_WAIT); - expect(target.querySelector('[data-testid="companion-settings"]')).not.toBeNull(); expect(target.querySelector('[data-testid="companion-open-advanced"]')).toBeNull(); - expect(document.title).toBe(strings.bots.settingsTitle); -}); + target.querySelector('[data-testid="settings-section-place"]')!.click(); + flushSync(); + expect(target.querySelector('[data-testid="companion-robots"]')).not.toBeNull(); + gear.click(); + flushSync(); + expect(settingsPanel.open).toBe(false); + expect(gear.getAttribute('aria-pressed')).toBe('false'); +}, 40_000); -test('the settings window waits for Boite too', async () => { - const target = show(SettingsApp, '?view=settings'); - await vi.waitFor(() => expect(target.querySelector('[data-testid="bots-notice"]')?.textContent).toContain(strings.bots.waiting)); -}); +test('Escape closes the settings panel first, before a request waiting under it or the island', async () => { + const target = show(App, '?fake=1'); + await vi.waitFor(() => expect(target.querySelector('[data-testid="bots-notice"]')?.textContent).toContain(strings.bots.demo), DEMO_WAIT); + requestSettings('focus'); + flushSync(); + // The fake core starts with requests waiting: their card sits under the panel. + await vi.waitFor(() => expect(target.querySelector('[data-testid="island-attention"]')).not.toBeNull(), DEMO_WAIT); + const asked = target.querySelector('[data-testid="attention-of"]')?.textContent; + expect(target.querySelector('[data-testid="island"]')?.getAttribute('data-size')).toBe('open'); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + flushSync(); + expect(settingsPanel.open).toBe(false); + expect(target.querySelector('[data-testid="island"]')?.getAttribute('data-size')).toBe('open'); + expect(target.querySelector('[data-testid="attention-of"]')?.textContent).toBe(asked); +}, 30_000); diff --git a/src/bots.css b/src/bots.css index 09a4eed..2d7d399 100644 --- a/src/bots.css +++ b/src/bots.css @@ -3,7 +3,7 @@ /* The soft shadow under a robot, the same on light and dark desktops. */ --bots-shadow: drop-shadow(0 1.5px 2px rgb(0 0 0 / 0.35)); - /* The island and the settings window: pure black, dark cards, hairlines. */ + /* The island and its settings panel: pure black, dark cards, hairlines. */ --island-bg: #000; --island-card: #0e0e12; --island-card-hover: #16161c; diff --git a/src/components/CompanionSettings.svelte b/src/components/CompanionSettings.svelte deleted file mode 100644 index df35fec..0000000 --- a/src/components/CompanionSettings.svelte +++ /dev/null @@ -1,412 +0,0 @@ - - - -{#snippet picker(label: string, hint: string | null, items: MenuItem[], current: string, onpick: (id: string) => void, testid: string, onreach?: () => void)} - -
- - {label}{#if hint}{/if} - - - {current} - -
-{/snippet} - -
-
-

{strings.companion.heading}

-
- - {#if !inShell()} -

{copy.desktopOnly}

- {/if} - -
-

{copy.place}

-
- {copy.robots} -
- {#each ROBOTS as robots (robots)} - - {/each} -
-
- {#if prefs.robots === 'island'} - - {@render picker(copy.pillRest, copy.pillRestHint, pillRestItems, copy.pillRests[prefs.pillRest], (id) => save({ pillRest: id as PillRest }), 'companion-pill-rest')} -
- {copy.bar} - -
- {/if} - {@render picker(copy.screen, copy.screenHint, screenItems, prefs.monitor ?? copy.primary, (id) => save({ monitor: id === PRIMARY ? null : id }), 'companion-screen')} - {#if prefs.robots === 'desktop'} - {@render picker(copy.position, copy.positionHint, anchorItems, copy.anchors[prefs.anchor], (id) => save({ anchor: id as CompanionAnchor }), 'companion-position')} - {/if} - {@render picker(copy.hotkey, copy.hotkeyHint, hotkeyItems, prefs.hotkey ?? copy.hotkeyOff, (id) => save({ hotkey: id === OFF ? null : id }), 'companion-hotkey')} - {#if hotkeyTaken}{/if} -
- -
-

{copy.mascot}

- save({ mascot: robot as Mascot })} /> - {#if prefs.mascot} - - {/if} -
- - - -
-

{copy.brain}

- {@render picker(copy.model, copy.modelHint, modelItems, currentModel, pickModel, 'companion-model', readAllModels)} - {#if accounts.length > 1} - {@render picker(copy.account, null, accountItems, account?.label ?? '', (id) => save({ accountId: id }), 'companion-account')} - {/if} - {#if levels.length > 0} - {@render picker(copy.effort, null, effortItems, currentEffort, (id) => save({ effort: id === AUTO ? null : id }), 'companion-effort')} - {/if} - {@render picker(copy.control, copy.controlHint, controlItems, copy.controls[prefs.control], (id) => save({ control: id as CompanionControl }), 'companion-control')} -

{copy.controlsHint[prefs.control]}

- {#if warmMinutes !== null} -

{warmMinutes > 0 ? fill(copy.warmOn, { minutes: String(warmMinutes) }) : copy.warmOff}

- - {/if} -
- -
- - - - {#if prefs.quotas && gauges.length > 0} -
- {#each gauges as gauge (gauge.id)} - - {/each} -
- {/if} - - - - - {@render picker(copy.radar, copy.radarHint, radarItems, radarLabel(prefs.radarMinutes), (id) => save({ radarMinutes: Number(id) }), 'companion-radar-minutes')} -
- -
- -
- -
-

{copy.pomodoro}

- {@render picker(copy.workMinutes, null, workItems, minutesLabel(prefs.workMinutes), (id) => save({ workMinutes: Number(id) }), 'companion-work-minutes')} - {@render picker(copy.breakMinutes, null, breakItems, minutesLabel(prefs.breakMinutes), (id) => save({ breakMinutes: Number(id) }), 'companion-break-minutes')} - -
- - - - -
- - diff --git a/src/components/companion/CompanionAllowRules.svelte b/src/components/companion/CompanionAllowRules.svelte index 570a70f..51e7d88 100644 --- a/src/components/companion/CompanionAllowRules.svelte +++ b/src/components/companion/CompanionAllowRules.svelte @@ -22,14 +22,13 @@ } +
-
-

{copy.title}

- {#if rules.length > 0} + {#if rules.length > 0} +
- {/if} -
-

{copy.lead}

+
+ {/if} {#if rules.length === 0}

{copy.empty}

{:else} @@ -50,9 +49,11 @@ diff --git a/src/components/island/settings/FocusSection.svelte b/src/components/island/settings/FocusSection.svelte new file mode 100644 index 0000000..7898a39 --- /dev/null +++ b/src/components/island/settings/FocusSection.svelte @@ -0,0 +1,28 @@ + + + +
+ save({ workMinutes: Number(id) })} testid="companion-work-minutes" /> + save({ breakMinutes: Number(id) })} testid="companion-break-minutes" /> + save({ focusOnWork })} testid="companion-focus-on-work" /> +
diff --git a/src/components/island/settings/IslandSection.svelte b/src/components/island/settings/IslandSection.svelte new file mode 100644 index 0000000..1beb002 --- /dev/null +++ b/src/components/island/settings/IslandSection.svelte @@ -0,0 +1,101 @@ + + + +
+ save({ music })} testid="companion-music" /> + save({ lyrics })} testid="companion-lyrics" /> + save({ quotas })} testid="companion-quotas" /> + {#if prefs.quotas && gauges.length > 0} +
+ {#each gauges as gauge (gauge.id)} + + {/each} +
+ {/if} +
+ +
+ save({ closeOutside })} testid="companion-close-outside" /> + save({ hideFullscreen })} testid="companion-hide-fullscreen" /> + save({ sounds })} testid="companion-sounds" /> +
+ +
+ save({ awayRecap })} testid="companion-away-recap" /> + save({ radarMinutes: Number(id) })} testid="companion-radar-minutes" /> +
+ + diff --git a/src/components/island/settings/MascotSection.svelte b/src/components/island/settings/MascotSection.svelte new file mode 100644 index 0000000..57103e8 --- /dev/null +++ b/src/components/island/settings/MascotSection.svelte @@ -0,0 +1,31 @@ + + + +
+ save({ mascot: robot as Mascot })} /> + {#if prefs.mascot} + + {/if} +
+ + diff --git a/src/components/island/settings/PlaceSection.svelte b/src/components/island/settings/PlaceSection.svelte new file mode 100644 index 0000000..0501243 --- /dev/null +++ b/src/components/island/settings/PlaceSection.svelte @@ -0,0 +1,116 @@ + + + +
+
+ {copy.robots} +
+ {#each ROBOTS as robots (robots)} + + {/each} +
+
+ save({ monitor: id === PRIMARY ? null : id })} testid="companion-screen" /> + {#if prefs.robots === 'desktop'} + save({ anchor: id as CompanionAnchor })} testid="companion-position" /> + {/if} + save({ hotkey: id === OFF ? null : id })} testid="companion-hotkey" /> + {#if hotkeyTaken}{/if} +
+ +{#if prefs.robots === 'island'} +
+ save({ openOnHover })} testid="companion-open-on-hover" /> + save({ pillRest: id as PillRest })} testid="companion-pill-rest" /> +
+ {copy.bar} + + +
+
+{/if} + + diff --git a/src/components/island/settings/SettingPick.svelte b/src/components/island/settings/SettingPick.svelte new file mode 100644 index 0000000..9af14be --- /dev/null +++ b/src/components/island/settings/SettingPick.svelte @@ -0,0 +1,41 @@ + + + +
+ + {label}{#if hint}{/if} + + + {current} + +
+ + diff --git a/src/components/island/settings/SettingSwitch.svelte b/src/components/island/settings/SettingSwitch.svelte new file mode 100644 index 0000000..c1dccfa --- /dev/null +++ b/src/components/island/settings/SettingSwitch.svelte @@ -0,0 +1,22 @@ + + + + diff --git a/src/components/island/settings/SettingsPanel.svelte b/src/components/island/settings/SettingsPanel.svelte new file mode 100644 index 0000000..27bb469 --- /dev/null +++ b/src/components/island/settings/SettingsPanel.svelte @@ -0,0 +1,379 @@ + + + + + + + + diff --git a/src/components/island/views/AttentionCard.svelte b/src/components/island/views/AttentionCard.svelte index 0c57276..df90602 100644 --- a/src/components/island/views/AttentionCard.svelte +++ b/src/components/island/views/AttentionCard.svelte @@ -9,6 +9,7 @@ -->