asdf98 commited on
Commit
93446ed
·
verified ·
1 Parent(s): 5a31df1

Fix Shield popover airspace and add Phase 3 library board core

Browse files
README.md CHANGED
@@ -1,7 +1,3 @@
1
- ---
2
- tags:
3
- - ml-intern
4
- ---
5
  # MUSE Alpha
6
 
7
  Creative browser prototype: Tauri v2 + Rust + SolidJS.
@@ -36,23 +32,3 @@ Frontend-only build:
36
  ```bash
37
  pnpm build
38
  ```
39
-
40
- <!-- ml-intern-provenance -->
41
- ## Generated by ML Intern
42
-
43
- This model repository was generated by [ML Intern](https://github.com/huggingface/ml-intern), an agent for machine learning research and development on the Hugging Face Hub.
44
-
45
- - Try ML Intern: https://smolagents-ml-intern.hf.space
46
- - Source code: https://github.com/huggingface/ml-intern
47
-
48
- ## Usage
49
-
50
- ```python
51
- from transformers import AutoModelForCausalLM, AutoTokenizer
52
-
53
- model_id = 'asdf98/musealpha'
54
- tokenizer = AutoTokenizer.from_pretrained(model_id)
55
- model = AutoModelForCausalLM.from_pretrained(model_id)
56
- ```
57
-
58
- For non-causal architectures, replace `AutoModelForCausalLM` with the appropriate `AutoModel` class.
 
 
 
 
 
1
  # MUSE Alpha
2
 
3
  Creative browser prototype: Tauri v2 + Rust + SolidJS.
 
32
  ```bash
33
  pnpm build
34
  ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src-tauri/Cargo.toml CHANGED
@@ -36,6 +36,10 @@ tokio = { version = "1", features = ["time", "sync"] }
36
 
37
  # Utilities
38
  once_cell = "1"
 
 
 
 
39
  chrono = { version = "0.4", features = ["serde"] }
40
 
41
  [target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
 
36
 
37
  # Utilities
38
  once_cell = "1"
39
+ image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp", "bmp"] }
40
+ blake3 = "1"
41
+ base64 = "0.22"
42
+ uuid = { version = "1", features = ["v4", "serde"] }
43
  chrono = { version = "0.4", features = ["serde"] }
44
 
45
  [target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
src-tauri/resources/scripts/hover_overlay.js ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (function() {
2
+ 'use strict';
3
+ let overlay = null;
4
+ let currentImg = null;
5
+ let timer = null;
6
+ const MIN = 90;
7
+
8
+ function invoke(cmd, args) {
9
+ try {
10
+ if (window.__TAURI_INTERNALS__?.invoke) return window.__TAURI_INTERNALS__.invoke(cmd, args);
11
+ if (window.__TAURI__?.core?.invoke) return window.__TAURI__.core.invoke(cmd, args);
12
+ } catch (_) {}
13
+ console.warn('[Muse] Tauri IPC unavailable in this page');
14
+ return Promise.reject(new Error('Muse IPC unavailable'));
15
+ }
16
+
17
+ function button(label) {
18
+ const b = document.createElement('button');
19
+ b.textContent = label;
20
+ b.style.cssText = 'height:26px;border:1px solid rgba(255,255,255,.25);border-radius:8px;background:rgba(20,18,14,.86);color:white;padding:0 10px;font:12px system-ui;cursor:pointer';
21
+ return b;
22
+ }
23
+ function ensureOverlay() {
24
+ if (overlay) return overlay;
25
+ overlay = document.createElement('div');
26
+ overlay.id = '__muse_image_overlay';
27
+ overlay.style.cssText = 'position:fixed;z-index:2147483647;display:none;align-items:center;gap:8px;padding:6px 8px;border-radius:12px;background:rgba(10,8,6,.78);backdrop-filter:blur(12px);box-shadow:0 8px 30px rgba(0,0,0,.35);pointer-events:auto';
28
+ const meta = document.createElement('span'); meta.id='__muse_meta'; meta.style.cssText='color:white;font:12px ui-monospace,monospace;opacity:.9';
29
+ const save = button('+ Library');
30
+ save.onclick = async () => {
31
+ if (!currentImg) return;
32
+ save.textContent = 'Saving…';
33
+ try { await invoke('library_add_item', { url: currentImg.src, sourceUrl: location.href, title: document.title || currentImg.alt || 'Reference' }); save.textContent = '✓ Saved'; }
34
+ catch { save.textContent = 'Save failed'; }
35
+ setTimeout(()=>save.textContent='+ Library', 1500);
36
+ };
37
+ const palette = button('🎨');
38
+ palette.onclick = async () => { if (currentImg) save.click(); };
39
+ overlay.append(meta, save, palette);
40
+ document.documentElement.appendChild(overlay);
41
+ return overlay;
42
+ }
43
+ function show(img) {
44
+ currentImg = img;
45
+ const o = ensureOverlay();
46
+ const r = img.getBoundingClientRect();
47
+ o.querySelector('#__muse_meta').textContent = `${img.naturalWidth || Math.round(r.width)}×${img.naturalHeight || Math.round(r.height)}`;
48
+ o.style.left = `${Math.min(r.left + 10, window.innerWidth - 260)}px`;
49
+ o.style.top = `${Math.max(10, r.top + 10)}px`;
50
+ o.style.display = 'flex';
51
+ }
52
+ function hide() { if (overlay) overlay.style.display = 'none'; currentImg = null; }
53
+ function eligible(el) { return el && el.tagName === 'IMG' && el.src && el.getBoundingClientRect().width >= MIN && el.getBoundingClientRect().height >= MIN; }
54
+ document.addEventListener('mousemove', e => {
55
+ clearTimeout(timer);
56
+ const target = e.target;
57
+ if (!eligible(target)) { if (!overlay?.contains(target)) hide(); return; }
58
+ timer = setTimeout(() => show(target), 350);
59
+ }, true);
60
+ document.addEventListener('scroll', hide, true);
61
+ })();
src-tauri/src/adblock/scripts.rs CHANGED
@@ -5,6 +5,7 @@ pub fn build_init_script(blocked_domains_json: &str) -> String {
5
  let cookie_consent = include_str!("../../resources/scripts/cookie_consent.js");
6
  let webrtc_protect = include_str!("../../resources/scripts/webrtc_protect.js");
7
  let canvas_noise = include_str!("../../resources/scripts/canvas_noise.js");
 
8
 
9
  format!(
10
  r#"(function() {{
@@ -14,7 +15,8 @@ window.__MUSE_BLOCKED_DOMAINS__ = {blocked_domains_json};
14
  {adblock_layer1}
15
  {cookie_consent}
16
  {webrtc_protect}
17
- {canvas_noise}"#
 
18
  )
19
  }
20
 
 
5
  let cookie_consent = include_str!("../../resources/scripts/cookie_consent.js");
6
  let webrtc_protect = include_str!("../../resources/scripts/webrtc_protect.js");
7
  let canvas_noise = include_str!("../../resources/scripts/canvas_noise.js");
8
+ let hover_overlay = include_str!("../../resources/scripts/hover_overlay.js");
9
 
10
  format!(
11
  r#"(function() {{
 
15
  {adblock_layer1}
16
  {cookie_consent}
17
  {webrtc_protect}
18
+ {canvas_noise}
19
+ {hover_overlay}"#
20
  )
21
  }
22
 
src-tauri/src/adblock/updater.rs CHANGED
@@ -41,8 +41,9 @@ pub async fn update_now(app: &AppHandle) -> Result<(), String> {
41
  Ok(resp) => {
42
  if let Ok(text) = resp.text().await {
43
  let _meta = filter_set.add_filter_list(&text, ParseOptions::default());
44
- total_rules += 1;
45
- println!("[muse-shield] Updated {name}");
 
46
  }
47
  }
48
  Err(e) => {
 
41
  Ok(resp) => {
42
  if let Ok(text) = resp.text().await {
43
  let _meta = filter_set.add_filter_list(&text, ParseOptions::default());
44
+ let rule_count = text.lines().filter(|l| !l.trim().is_empty() && !l.starts_with('!')).count();
45
+ total_rules += rule_count;
46
+ println!("[muse-shield] Updated {name}: {rule_count} candidate rules");
47
  }
48
  }
49
  Err(e) => {
src-tauri/src/board.rs ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use serde::{Deserialize, Serialize};
2
+ use std::sync::Mutex;
3
+ use tauri::{AppHandle, Manager};
4
+ use uuid::Uuid;
5
+
6
+ #[derive(Debug, Clone, Serialize, Deserialize)]
7
+ pub struct BoardItem {
8
+ pub id: String,
9
+ pub kind: String,
10
+ pub library_id: Option<String>,
11
+ pub data_url: Option<String>,
12
+ pub text: Option<String>,
13
+ pub colors: Vec<String>,
14
+ pub x: f64,
15
+ pub y: f64,
16
+ pub w: f64,
17
+ pub h: f64,
18
+ pub z: i64,
19
+ }
20
+
21
+ #[derive(Default)]
22
+ pub struct BoardState {
23
+ pub items: Mutex<Vec<BoardItem>>,
24
+ }
25
+
26
+ #[tauri::command]
27
+ pub fn board_items(app: AppHandle) -> Result<Vec<BoardItem>, String> {
28
+ Ok(app.state::<BoardState>().items.lock().map_err(|_| "board lock poisoned".to_string())?.clone())
29
+ }
30
+
31
+ #[tauri::command]
32
+ pub fn board_add_image(app: AppHandle, library_id: String, data_url: String, x: f64, y: f64, w: f64, h: f64) -> Result<BoardItem, String> {
33
+ let item = BoardItem { id: Uuid::new_v4().to_string(), kind: "image".into(), library_id: Some(library_id), data_url: Some(data_url), text: None, colors: vec![], x, y, w, h, z: chrono::Utc::now().timestamp_millis() };
34
+ app.state::<BoardState>().items.lock().map_err(|_| "board lock poisoned".to_string())?.push(item.clone());
35
+ Ok(item)
36
+ }
37
+
38
+ #[tauri::command]
39
+ pub fn board_add_note(app: AppHandle, text: String, x: f64, y: f64) -> Result<BoardItem, String> {
40
+ let item = BoardItem { id: Uuid::new_v4().to_string(), kind: "note".into(), library_id: None, data_url: None, text: Some(text), colors: vec![], x, y, w: 220.0, h: 140.0, z: chrono::Utc::now().timestamp_millis() };
41
+ app.state::<BoardState>().items.lock().map_err(|_| "board lock poisoned".to_string())?.push(item.clone());
42
+ Ok(item)
43
+ }
44
+
45
+ #[tauri::command]
46
+ pub fn board_add_palette(app: AppHandle, colors: Vec<String>, x: f64, y: f64) -> Result<BoardItem, String> {
47
+ let item = BoardItem { id: Uuid::new_v4().to_string(), kind: "palette".into(), library_id: None, data_url: None, text: None, colors, x, y, w: 260.0, h: 90.0, z: chrono::Utc::now().timestamp_millis() };
48
+ app.state::<BoardState>().items.lock().map_err(|_| "board lock poisoned".to_string())?.push(item.clone());
49
+ Ok(item)
50
+ }
51
+
52
+ #[tauri::command]
53
+ pub fn board_update_item(app: AppHandle, item: BoardItem) -> Result<(), String> {
54
+ let state = app.state::<BoardState>();
55
+ let mut items = state.items.lock().map_err(|_| "board lock poisoned".to_string())?;
56
+ if let Some(slot) = items.iter_mut().find(|i| i.id == item.id) { *slot = item; }
57
+ Ok(())
58
+ }
59
+
60
+ #[tauri::command]
61
+ pub fn board_delete_item(app: AppHandle, id: String) -> Result<(), String> {
62
+ app.state::<BoardState>().items.lock().map_err(|_| "board lock poisoned".to_string())?.retain(|i| i.id != id);
63
+ Ok(())
64
+ }
src-tauri/src/lib.rs CHANGED
@@ -1,5 +1,7 @@
1
  mod adblock;
2
  mod browser;
 
 
3
  mod settings;
4
  mod state;
5
 
@@ -19,6 +21,8 @@ pub fn run() {
19
  )
20
  .manage(state::AppState::default())
21
  .manage(adblock::engine::AdBlockState::new())
 
 
22
  .invoke_handler(tauri::generate_handler![
23
  settings::phase0_status,
24
  browser::browser_init,
@@ -39,6 +43,15 @@ pub fn run() {
39
  adblock::commands::shield_toggle_domain,
40
  adblock::commands::shield_is_allowed,
41
  adblock::commands::shield_update_lists,
 
 
 
 
 
 
 
 
 
42
  ])
43
  .setup(|app| {
44
  #[cfg(desktop)]
 
1
  mod adblock;
2
  mod browser;
3
+ mod library;
4
+ mod board;
5
  mod settings;
6
  mod state;
7
 
 
21
  )
22
  .manage(state::AppState::default())
23
  .manage(adblock::engine::AdBlockState::new())
24
+ .manage(library::LibraryState::default())
25
+ .manage(board::BoardState::default())
26
  .invoke_handler(tauri::generate_handler![
27
  settings::phase0_status,
28
  browser::browser_init,
 
43
  adblock::commands::shield_toggle_domain,
44
  adblock::commands::shield_is_allowed,
45
  adblock::commands::shield_update_lists,
46
+ library::library_add_item,
47
+ library::library_items,
48
+ library::library_search,
49
+ board::board_items,
50
+ board::board_add_image,
51
+ board::board_add_note,
52
+ board::board_add_palette,
53
+ board::board_update_item,
54
+ board::board_delete_item,
55
  ])
56
  .setup(|app| {
57
  #[cfg(desktop)]
src-tauri/src/library.rs ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use base64::{engine::general_purpose, Engine as _};
2
+ use image::{GenericImageView, Pixel};
3
+ use serde::{Deserialize, Serialize};
4
+ use std::sync::Mutex;
5
+ use tauri::{AppHandle, Manager};
6
+ use uuid::Uuid;
7
+
8
+ #[derive(Debug, Clone, Serialize, Deserialize)]
9
+ pub struct LibraryItem {
10
+ pub id: String,
11
+ pub url: String,
12
+ pub source_url: String,
13
+ pub title: String,
14
+ pub data_url: String,
15
+ pub hash: String,
16
+ pub width: u32,
17
+ pub height: u32,
18
+ pub colors: Vec<String>,
19
+ pub tags: Vec<String>,
20
+ pub created_at: i64,
21
+ }
22
+
23
+ #[derive(Default)]
24
+ pub struct LibraryState {
25
+ pub items: Mutex<Vec<LibraryItem>>,
26
+ }
27
+
28
+ #[tauri::command]
29
+ pub async fn library_add_item(app: AppHandle, url: String, source_url: Option<String>, title: Option<String>) -> Result<LibraryItem, String> {
30
+ let bytes = reqwest::get(&url).await.map_err(|e| e.to_string())?.bytes().await.map_err(|e| e.to_string())?;
31
+ let hash = blake3::hash(&bytes).to_hex().to_string();
32
+ let img = image::load_from_memory(&bytes).map_err(|e| format!("unsupported image: {e}"))?;
33
+ let (width, height) = img.dimensions();
34
+ let colors = extract_palette(&img, 6);
35
+ let mime = if url.to_lowercase().ends_with(".png") { "image/png" } else if url.to_lowercase().ends_with(".webp") { "image/webp" } else if url.to_lowercase().ends_with(".gif") { "image/gif" } else { "image/jpeg" };
36
+ let data_url = format!("data:{mime};base64,{}", general_purpose::STANDARD.encode(&bytes));
37
+ let item = LibraryItem {
38
+ id: Uuid::new_v4().to_string(),
39
+ url,
40
+ source_url: source_url.unwrap_or_default(),
41
+ title: title.unwrap_or_else(|| "Untitled reference".to_string()),
42
+ data_url,
43
+ hash,
44
+ width,
45
+ height,
46
+ colors,
47
+ tags: vec![],
48
+ created_at: chrono::Utc::now().timestamp(),
49
+ };
50
+ let state = app.state::<LibraryState>();
51
+ let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
52
+ if let Some(existing) = items.iter().find(|i| i.hash == item.hash) {
53
+ return Ok(existing.clone());
54
+ }
55
+ items.push(item.clone());
56
+ Ok(item)
57
+ }
58
+
59
+ #[tauri::command]
60
+ pub fn library_items(app: AppHandle) -> Result<Vec<LibraryItem>, String> {
61
+ let state = app.state::<LibraryState>();
62
+ Ok(state.items.lock().map_err(|_| "library lock poisoned".to_string())?.clone())
63
+ }
64
+
65
+ #[tauri::command]
66
+ pub fn library_search(app: AppHandle, query: String) -> Result<Vec<LibraryItem>, String> {
67
+ let q = query.to_lowercase();
68
+ let state = app.state::<LibraryState>();
69
+ let items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
70
+ Ok(items.iter().filter(|item| {
71
+ item.title.to_lowercase().contains(&q)
72
+ || item.url.to_lowercase().contains(&q)
73
+ || item.tags.iter().any(|t| t.to_lowercase().contains(&q))
74
+ }).cloned().collect())
75
+ }
76
+
77
+ fn extract_palette(img: &image::DynamicImage, count: usize) -> Vec<String> {
78
+ let thumb = img.thumbnail(96, 96).to_rgb8();
79
+ let mut buckets: Vec<Vec<[u8;3]>> = vec![thumb.pixels().map(|p| [p[0], p[1], p[2]]).collect()];
80
+ while buckets.len() < count {
81
+ let Some((idx, axis)) = buckets.iter().enumerate().filter(|(_, b)| b.len() > 1).map(|(i,b)| {
82
+ let ranges = (0..3).map(|c| {
83
+ let min = b.iter().map(|p| p[c]).min().unwrap_or(0);
84
+ let max = b.iter().map(|p| p[c]).max().unwrap_or(0);
85
+ max - min
86
+ }).collect::<Vec<_>>();
87
+ let axis = ranges.iter().enumerate().max_by_key(|(_, r)| *r).map(|(a,_)| a).unwrap_or(0);
88
+ (i, axis)
89
+ }).next() else { break; };
90
+ let mut b = buckets.remove(idx);
91
+ b.sort_by_key(|p| p[axis]);
92
+ let right = b.split_off(b.len()/2);
93
+ buckets.push(b); buckets.push(right);
94
+ }
95
+ buckets.into_iter().filter(|b| !b.is_empty()).map(|b| {
96
+ let n=b.len() as u32;
97
+ let (r,g,bl)=b.iter().fold((0u32,0u32,0u32), |a,p| (a.0+p[0] as u32,a.1+p[1] as u32,a.2+p[2] as u32));
98
+ format!("#{:02X}{:02X}{:02X}", r/n, g/n, bl/n)
99
+ }).collect()
100
+ }
src/App.tsx CHANGED
@@ -5,6 +5,8 @@ import Titlebar from "./components/Titlebar";
5
  import HomePage from "./components/HomePage";
6
  import Onboarding from "./components/Onboarding";
7
  import PlaceholderPanel from "./components/PlaceholderPanel";
 
 
8
  import ThemeSwitcher from "./components/ThemeSwitcher";
9
  import BrowserChrome from "./components/browser/BrowserChrome";
10
 
@@ -43,10 +45,10 @@ function App() {
43
  <main classList={{ "content-shell": true, visible: settings().activeSection !== "home" }}>
44
  <Switch>
45
  <Match when={settings().activeSection === "library"}>
46
- <PlaceholderPanel title="Library" subtitle="Phase 3 will add image import, grid, tags, search, and color metadata." />
47
  </Match>
48
  <Match when={settings().activeSection === "board"}>
49
- <PlaceholderPanel title="Board" subtitle="Phase 3 will add the PureRef-style infinite canvas." />
50
  </Match>
51
  <Match when={settings().activeSection === "settings"}>
52
  <section class="panel settings-panel">
 
5
  import HomePage from "./components/HomePage";
6
  import Onboarding from "./components/Onboarding";
7
  import PlaceholderPanel from "./components/PlaceholderPanel";
8
+ import LibraryPanel from "./components/LibraryPanel";
9
+ import BoardPanel from "./components/BoardPanel";
10
  import ThemeSwitcher from "./components/ThemeSwitcher";
11
  import BrowserChrome from "./components/browser/BrowserChrome";
12
 
 
45
  <main classList={{ "content-shell": true, visible: settings().activeSection !== "home" }}>
46
  <Switch>
47
  <Match when={settings().activeSection === "library"}>
48
+ <LibraryPanel />
49
  </Match>
50
  <Match when={settings().activeSection === "board"}>
51
+ <BoardPanel />
52
  </Match>
53
  <Match when={settings().activeSection === "settings"}>
54
  <section class="panel settings-panel">
src/components/BoardPanel.tsx ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createSignal, For, onMount } from "solid-js";
2
+ import { invoke } from "@tauri-apps/api/core";
3
+ import Plus from "lucide-solid/icons/plus";
4
+ import StickyNote from "lucide-solid/icons/sticky-note";
5
+ import Palette from "lucide-solid/icons/palette";
6
+ import Trash2 from "lucide-solid/icons/trash-2";
7
+ import type { LibraryItem } from "./LibraryPanel";
8
+
9
+ interface BoardItem {
10
+ id: string;
11
+ kind: string;
12
+ library_id?: string | null;
13
+ data_url?: string | null;
14
+ text?: string | null;
15
+ colors: string[];
16
+ x: number; y: number; w: number; h: number; z: number;
17
+ }
18
+
19
+ export default function BoardPanel() {
20
+ const [items, setItems] = createSignal<BoardItem[]>([]);
21
+ const [viewport, setViewport] = createSignal({ x: 40, y: 40, scale: 1 });
22
+ let boardEl!: HTMLDivElement;
23
+
24
+ const load = async () => setItems(await invoke<BoardItem[]>("board_items"));
25
+ onMount(load);
26
+
27
+ const screenToWorld = (clientX: number, clientY: number) => {
28
+ const r = boardEl.getBoundingClientRect();
29
+ const v = viewport();
30
+ return { x: (clientX - r.left - v.x) / v.scale, y: (clientY - r.top - v.y) / v.scale };
31
+ };
32
+
33
+ const addNote = async () => { await invoke("board_add_note", { text: "Observation note…", x: 120, y: 120 }); await load(); };
34
+ const addPalette = async () => { await invoke("board_add_palette", { colors: ["#C49A3C", "#100E0B", "#F0EDE6", "#4CAF6E"], x: 180, y: 180 }); await load(); };
35
+ const del = async (id: string) => { await invoke("board_delete_item", { id }); await load(); };
36
+
37
+ const update = async (item: BoardItem) => { await invoke("board_update_item", { item }); };
38
+
39
+ const startDrag = (e: PointerEvent, item: BoardItem) => {
40
+ e.stopPropagation();
41
+ const start = screenToWorld(e.clientX, e.clientY);
42
+ const ox = item.x, oy = item.y;
43
+ const move = (ev: PointerEvent) => {
44
+ const p = screenToWorld(ev.clientX, ev.clientY);
45
+ item.x = ox + p.x - start.x; item.y = oy + p.y - start.y;
46
+ setItems([...items()]);
47
+ };
48
+ const up = () => { window.removeEventListener("pointermove", move); void update(item); };
49
+ window.addEventListener("pointermove", move);
50
+ window.addEventListener("pointerup", up, { once: true });
51
+ };
52
+
53
+ const startPan = (e: PointerEvent) => {
54
+ if ((e.target as HTMLElement).closest(".board-card")) return;
55
+ const startClient = { x: e.clientX, y: e.clientY };
56
+ const startView = viewport();
57
+ const move = (ev: PointerEvent) => setViewport({ ...startView, x: startView.x + ev.clientX - startClient.x, y: startView.y + ev.clientY - startClient.y });
58
+ const up = () => window.removeEventListener("pointermove", move);
59
+ window.addEventListener("pointermove", move);
60
+ window.addEventListener("pointerup", up, { once: true });
61
+ };
62
+
63
+ const onWheel = (e: WheelEvent) => {
64
+ e.preventDefault();
65
+ const v = viewport();
66
+ const next = Math.min(3, Math.max(.25, v.scale * Math.exp(-e.deltaY * .001)));
67
+ setViewport({ ...v, scale: next });
68
+ };
69
+
70
+ const drop = async (e: DragEvent) => {
71
+ e.preventDefault();
72
+ const raw = e.dataTransfer?.getData("application/x-muse-library-item");
73
+ if (!raw) return;
74
+ const item = JSON.parse(raw) as LibraryItem;
75
+ const p = screenToWorld(e.clientX, e.clientY);
76
+ await invoke("board_add_image", { libraryId: item.id, dataUrl: item.data_url, x: p.x, y: p.y, w: Math.min(420, item.width / 2), h: Math.min(320, item.height / 2) });
77
+ await load();
78
+ };
79
+
80
+ return (
81
+ <section class="board-module">
82
+ <header class="board-toolbar-panel">
83
+ <div><p class="eyebrow">Board</p><h1>Reference Board</h1></div>
84
+ <button class="secondary" onClick={() => void addNote()}><StickyNote size={16}/> Note</button>
85
+ <button class="secondary" onClick={() => void addPalette()}><Palette size={16}/> Palette</button>
86
+ <button class="secondary" onClick={() => setViewport({ x: 40, y: 40, scale: 1 })}>Reset view</button>
87
+ </header>
88
+ <div class="board-canvas" ref={boardEl} onPointerDown={startPan} onWheel={onWheel} onDragOver={(e) => e.preventDefault()} onDrop={(e) => void drop(e)}>
89
+ <div class="board-grid-bg" style={{ transform: `translate(${viewport().x}px, ${viewport().y}px) scale(${viewport().scale})` }} />
90
+ <div class="board-world" style={{ transform: `translate(${viewport().x}px, ${viewport().y}px) scale(${viewport().scale})`, "transform-origin": "0 0" }}>
91
+ <For each={items()}>{(item) => (
92
+ <article class={`board-card ${item.kind}`} style={{ left: `${item.x}px`, top: `${item.y}px`, width: `${item.w}px`, height: `${item.h}px`, "z-index": item.z }} onPointerDown={(e) => startDrag(e, item)}>
93
+ {item.kind === "image" && <img src={item.data_url || ""} draggable={false}/>}
94
+ {item.kind === "note" && <textarea value={item.text || ""} onInput={(e) => { item.text = e.currentTarget.value; void update(item); }} />}
95
+ {item.kind === "palette" && <div class="board-palette">{item.colors.map(c => <span class="palette-chip" style={{"background-color": c}} title={c} />)}</div>}
96
+ <button class="board-delete" onClick={(e) => { e.stopPropagation(); void del(item.id); }}><Trash2 size={14}/></button>
97
+ </article>
98
+ )}</For>
99
+ </div>
100
+ </div>
101
+ </section>
102
+ );
103
+ }
src/components/LibraryPanel.tsx ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createSignal, For, onMount, Show } from "solid-js";
2
+ import { invoke } from "@tauri-apps/api/core";
3
+ import Search from "lucide-solid/icons/search";
4
+ import Plus from "lucide-solid/icons/plus";
5
+ import ImageIcon from "lucide-solid/icons/image";
6
+ import Palette from "lucide-solid/icons/palette";
7
+
8
+ export interface LibraryItem {
9
+ id: string;
10
+ url: string;
11
+ source_url: string;
12
+ title: string;
13
+ data_url: string;
14
+ hash: string;
15
+ width: number;
16
+ height: number;
17
+ colors: string[];
18
+ tags: string[];
19
+ created_at: number;
20
+ }
21
+
22
+ export default function LibraryPanel() {
23
+ const [items, setItems] = createSignal<LibraryItem[]>([]);
24
+ const [selected, setSelected] = createSignal<LibraryItem | null>(null);
25
+ const [url, setUrl] = createSignal("");
26
+ const [query, setQuery] = createSignal("");
27
+ const [busy, setBusy] = createSignal(false);
28
+
29
+ const load = async () => setItems(await invoke<LibraryItem[]>("library_items"));
30
+ onMount(load);
31
+
32
+ const addUrl = async () => {
33
+ const value = url().trim();
34
+ if (!value) return;
35
+ setBusy(true);
36
+ try {
37
+ const item = await invoke<LibraryItem>("library_add_item", { url: value, sourceUrl: value, title: value.split('/').pop() || "Reference" });
38
+ setItems(await invoke<LibraryItem[]>("library_items"));
39
+ setSelected(item);
40
+ setUrl("");
41
+ } finally { setBusy(false); }
42
+ };
43
+
44
+ const search = async (q: string) => {
45
+ setQuery(q);
46
+ setItems(q.trim() ? await invoke<LibraryItem[]>("library_search", { query: q }) : await invoke<LibraryItem[]>("library_items"));
47
+ };
48
+
49
+ const badgeClass = (item: LibraryItem) => item.width >= 1800 || item.height >= 1800 ? "good" : item.width >= 900 || item.height >= 900 ? "warn" : "bad";
50
+
51
+ return (
52
+ <section class="library-module">
53
+ <header class="module-header">
54
+ <div>
55
+ <p class="eyebrow">Library</p>
56
+ <h1>Reference Library</h1>
57
+ <p class="muted">Local-first visual archive with colors, dimensions, source URL and board drag-out.</p>
58
+ </div>
59
+ <div class="library-addbar">
60
+ <input value={url()} onInput={(e) => setUrl(e.currentTarget.value)} placeholder="Paste image URL to save…" />
61
+ <button class="primary small" disabled={busy()} onClick={() => void addUrl()}><Plus size={16}/> Add</button>
62
+ </div>
63
+ </header>
64
+
65
+ <div class="library-layout">
66
+ <main class="library-main">
67
+ <div class="library-search"><Search size={16}/><input value={query()} onInput={(e) => void search(e.currentTarget.value)} placeholder="Search references, tags, source…" /></div>
68
+ <div class="library-grid">
69
+ <For each={items()}>{(item) => (
70
+ <article class="library-tile" draggable onDragStart={(e) => {
71
+ e.dataTransfer?.setData("application/x-muse-library-item", JSON.stringify(item));
72
+ e.dataTransfer!.effectAllowed = "copy";
73
+ }} onClick={() => setSelected(item)}>
74
+ <img src={item.data_url} draggable={false}/>
75
+ <span class={`resolution-badge ${badgeClass(item)}`}>{item.width}×{item.height}</span>
76
+ <div class="tile-caption"><ImageIcon size={14}/><span>{item.title}</span></div>
77
+ </article>
78
+ )}</For>
79
+ </div>
80
+ </main>
81
+ <aside class="library-detail">
82
+ <Show when={selected()} fallback={<div class="empty-detail"><ImageIcon size={42}/><p>Select a reference for details.</p></div>}>
83
+ {(item) => <>
84
+ <img class="detail-preview" src={item().data_url}/>
85
+ <h2>{item().title}</h2>
86
+ <p class="muted">{item().width} × {item().height}</p>
87
+ <p class="detail-source">{item().url}</p>
88
+ <div class="palette-row"><Palette size={16}/>{item().colors.map(c => <button title={c} class="swatch" style={{"background-color": c}} />)}</div>
89
+ <button class="secondary-wide" onClick={() => void invoke("board_add_palette", { colors: item().colors, x: 80, y: 80 })}>Send palette to Board</button>
90
+ </>}
91
+ </Show>
92
+ </aside>
93
+ </div>
94
+ </section>
95
+ );
96
+ }
src/components/browser/BrowserChrome.tsx CHANGED
@@ -139,10 +139,38 @@ export default function BrowserChrome(props: { active: boolean }) {
139
  </button>
140
  )}</For>
141
  </div>
142
- <div class="sidebar-shield-summary">
143
- <ShieldCheck size={16} />
144
  <span>{totalBlocked()} blocked · {shield().engine_rules} rules</span>
145
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  </aside>
147
 
148
  <div classList={{ "browser-toolbar": true, inactive: !props.active }}>
@@ -165,26 +193,7 @@ export default function BrowserChrome(props: { active: boolean }) {
165
  {totalBlocked()}
166
  </button>
167
  </div>
168
- <Show when={shieldOpen()}>
169
- <div class="shield-popover">
170
- <div class="shield-popover-header">
171
- <strong>Muse Shield</strong>
172
- <button type="button" class="shield-close" onClick={() => setShieldOpen(false)}><X size={14} /></button>
173
- </div>
174
- <div class="shield-stats-grid">
175
- <div class="shield-stat"><span class="shield-stat-value">{shield().blocked_requests}</span><span class="shield-stat-label">Requests blocked</span></div>
176
- <div class="shield-stat"><span class="shield-stat-value">{shield().blocked_cosmetic}</span><span class="shield-stat-label">Elements hidden</span></div>
177
- <div class="shield-stat"><span class="shield-stat-value">{shield().https_upgrades}</span><span class="shield-stat-label">HTTPS upgrades</span></div>
178
- <div class="shield-stat"><span class="shield-stat-value">{shield().engine_rules}</span><span class="shield-stat-label">Filter rules</span></div>
179
- </div>
180
- <Show when={activeTab()}>
181
- <div class="shield-domain-toggle">
182
- <span>Shield for <strong>{hostLabel(activeTab()!.url)}</strong></span>
183
- <button type="button" classList={{ "toggle-btn": true, off: domainAllowed() }} onClick={() => void toggleDomainShield()}>
184
- {domainAllowed() ? "Paused" : "Active"}
185
- </button>
186
- </div>
187
- </Show>
188
  <div class="shield-actions">
189
  <button type="button" class="shield-action-btn" onClick={() => void updateLists()}>
190
  <RefreshCw size={14} /> Update filter lists
 
139
  </button>
140
  )}</For>
141
  </div>
142
+ <button type="button" class="sidebar-shield-summary clickable" onClick={() => setShieldOpen(!shieldOpen())}>
143
+ {domainAllowed() ? <ShieldOff size={16} /> : <ShieldCheck size={16} />}
144
  <span>{totalBlocked()} blocked · {shield().engine_rules} rules</span>
145
+ </button>
146
+ <Show when={shieldOpen()}>
147
+ <div class="shield-sidebar-panel">
148
+ <div class="shield-popover-header">
149
+ <strong>Muse Shield</strong>
150
+ <button type="button" class="shield-close" onClick={() => setShieldOpen(false)}><X size={14} /></button>
151
+ </div>
152
+ <div class="shield-stats-grid">
153
+ <div class="shield-stat"><span class="shield-stat-value">{shield().blocked_requests}</span><span class="shield-stat-label">Requests</span></div>
154
+ <div class="shield-stat"><span class="shield-stat-value">{shield().blocked_cosmetic}</span><span class="shield-stat-label">Hidden</span></div>
155
+ <div class="shield-stat"><span class="shield-stat-value">{shield().https_upgrades}</span><span class="shield-stat-label">HTTPS</span></div>
156
+ <div class="shield-stat"><span class="shield-stat-value">{shield().engine_rules}</span><span class="shield-stat-label">Rules</span></div>
157
+ </div>
158
+ <Show when={activeTab()}>
159
+ <div class="shield-domain-toggle compact">
160
+ <span><strong>{hostLabel(activeTab()!.url)}</strong></span>
161
+ <button type="button" classList={{ "toggle-btn": true, off: domainAllowed() }} onClick={() => void toggleDomainShield()}>
162
+ {domainAllowed() ? "Paused" : "Active"}
163
+ </button>
164
+ </div>
165
+ </Show>
166
+ <button type="button" class="shield-action-btn" onClick={() => void updateLists()}>
167
+ <RefreshCw size={14} /> Update lists
168
+ </button>
169
+ <div class="shield-footer sidebar-safe">
170
+ <span>HTTPS-first</span><span>WebRTC</span><span>Canvas noise</span><span>Cookie reject</span>
171
+ </div>
172
+ </div>
173
+ </Show>
174
  </aside>
175
 
176
  <div classList={{ "browser-toolbar": true, inactive: !props.active }}>
 
193
  {totalBlocked()}
194
  </button>
195
  </div>
196
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  <div class="shield-actions">
198
  <button type="button" class="shield-action-btn" onClick={() => void updateLists()}>
199
  <RefreshCw size={14} /> Update filter lists
src/styles/app.css CHANGED
@@ -113,3 +113,53 @@ h2 { margin: 4px 0 8px; font-size: 18px; }
113
 
114
  @keyframes pulse { 0%,100% { opacity: .45; transform: scale(.9); } 50% { opacity: 1; transform: scale(1.1); } }
115
  @media (max-width: 980px) { .app-shell { grid-template-columns: 248px minmax(0,1fr); } .browser-toolbar { grid-template-columns: auto 1fr; } .traffic-group.right { display:none; } .dashboard-grid.mature { grid-template-columns: 1fr; } }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
114
  @keyframes pulse { 0%,100% { opacity: .45; transform: scale(.9); } 50% { opacity: 1; transform: scale(1.1); } }
115
  @media (max-width: 980px) { .app-shell { grid-template-columns: 248px minmax(0,1fr); } .browser-toolbar { grid-template-columns: auto 1fr; } .traffic-group.right { display:none; } .dashboard-grid.mature { grid-template-columns: 1fr; } }
116
+
117
+ /* Sidebar-safe shield panel: never overlaps native child WebView */
118
+ .sidebar-shield-summary.clickable { width: 100%; border: 0; background: transparent; cursor: pointer; text-align: left; }
119
+ .sidebar-shield-summary.clickable:hover { background: var(--bg-raised); color: var(--text-primary); }
120
+ .shield-sidebar-panel { margin: 0 10px 10px; padding: 14px; background: var(--bg-surface); border: 1px solid var(--glass-border); border-radius: 16px; box-shadow: 0 12px 30px rgba(0,0,0,.35); }
121
+ .shield-domain-toggle.compact { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 10px 0; border-top: 1px solid var(--glass-border); border-bottom: 1px solid var(--glass-border); margin-bottom: 10px; font-size: 12px; }
122
+ .shield-domain-toggle.compact span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
123
+ .shield-footer.sidebar-safe { margin-top: 10px; }
124
+
125
+ /* Phase 3 Library */
126
+ .library-module, .board-module { height: 100%; min-height: 0; display: flex; flex-direction: column; gap: 18px; }
127
+ .module-header { display: flex; align-items: end; justify-content: space-between; gap: 20px; }
128
+ .module-header h1, .board-toolbar-panel h1 { margin: 0; font-size: 36px; letter-spacing: -0.04em; }
129
+ .library-addbar { display: flex; gap: 10px; min-width: 420px; }
130
+ .library-addbar input, .library-search input { flex: 1; height: 38px; border: 1px solid var(--glass-border); border-radius: 12px; background: var(--bg-surface); color: var(--text-primary); padding: 0 12px; outline: none; }
131
+ .primary.small { height: 38px; margin: 0; display: flex; align-items: center; gap: 6px; }
132
+ .library-layout { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0,1fr) 320px; gap: 18px; }
133
+ .library-main, .library-detail { min-height: 0; background: var(--glass-bg); border: 1px solid var(--glass-border); border-radius: 20px; padding: 16px; overflow: auto; }
134
+ .library-search { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
135
+ .library-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 14px; }
136
+ .library-tile { position: relative; border: 1px solid var(--glass-border); border-radius: 16px; overflow: hidden; background: var(--bg-surface); cursor: pointer; min-height: 150px; }
137
+ .library-tile:hover { border-color: var(--accent); transform: translateY(-1px); }
138
+ .library-tile img { width: 100%; height: 130px; object-fit: cover; display: block; }
139
+ .tile-caption { height: 34px; display: flex; align-items: center; gap: 6px; padding: 0 10px; font-size: 12px; color: var(--text-secondary); }
140
+ .tile-caption span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
141
+ .resolution-badge { position: absolute; right: 8px; top: 8px; padding: 3px 7px; border-radius: 999px; background: rgba(0,0,0,.7); color: white; font-size: 11px; }
142
+ .resolution-badge.good { color: #7CFFB2; } .resolution-badge.warn { color: #FFD36A; } .resolution-badge.bad { color: #FF8A8A; }
143
+ .detail-preview { width: 100%; max-height: 260px; object-fit: contain; border-radius: 14px; background: #111; }
144
+ .detail-source { font-size: 12px; color: var(--text-muted); word-break: break-all; }
145
+ .palette-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 14px 0; }
146
+ .swatch { width: 28px; height: 28px; border-radius: 8px; border: 1px solid var(--glass-border); }
147
+ .secondary-wide { width: 100%; height: 36px; border: 1px solid var(--glass-border); border-radius: 10px; background: var(--bg-raised); color: var(--text-primary); cursor: pointer; }
148
+ .empty-detail { height: 100%; display: grid; place-items: center; color: var(--text-muted); text-align: center; }
149
+
150
+ /* Phase 3 Board */
151
+ .board-toolbar-panel { display: flex; align-items: center; gap: 12px; background: var(--glass-bg); border: 1px solid var(--glass-border); border-radius: 18px; padding: 14px 16px; }
152
+ .board-toolbar-panel > div { margin-right: auto; }
153
+ .secondary { height: 34px; display: flex; align-items: center; gap: 7px; border: 1px solid var(--glass-border); border-radius: 10px; background: var(--bg-surface); color: var(--text-secondary); cursor: pointer; padding: 0 12px; }
154
+ .secondary:hover { color: var(--text-primary); background: var(--bg-raised); }
155
+ .board-canvas { flex: 1; min-height: 0; position: relative; overflow: hidden; background: #11100e; border: 1px solid var(--glass-border); border-radius: 20px; touch-action: none; }
156
+ .board-grid-bg { position: absolute; inset: -200%; background-image: linear-gradient(rgba(255,255,255,.06) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.06) 1px, transparent 1px); background-size: 32px 32px; pointer-events: none; }
157
+ .board-world { position: absolute; inset: 0; }
158
+ .board-card { position: absolute; border: 1px solid rgba(255,255,255,.16); border-radius: 14px; background: var(--bg-surface); overflow: hidden; box-shadow: 0 12px 30px rgba(0,0,0,.35); cursor: grab; }
159
+ .board-card:active { cursor: grabbing; }
160
+ .board-card.image img { width: 100%; height: 100%; object-fit: cover; display: block; }
161
+ .board-card.note textarea { width: 100%; height: 100%; resize: none; border: 0; outline: 0; padding: 14px; background: #F7E8B4; color: #2b2118; font: inherit; }
162
+ .board-palette { display: flex; width: 100%; height: 100%; padding: 12px; gap: 8px; align-items: stretch; }
163
+ .palette-chip { flex: 1; border-radius: 10px; border: 1px solid rgba(0,0,0,.25); }
164
+ .board-delete { position: absolute; top: 6px; right: 6px; width: 26px; height: 26px; border: 0; border-radius: 8px; background: rgba(0,0,0,.55); color: white; display: grid; place-items: center; opacity: 0; cursor: pointer; }
165
+ .board-card:hover .board-delete { opacity: 1; }