DOSSIER Multi-Phase: C#-Plugin + Yak + Wandstile + UX-Polish

- C#-Plugin "DOSSIER" mit 23 nativen Commands (dWall, dDoor, ..., dSection)
  - Native Command-Namen + Autocomplete + saubere History
  - Idle-Defer + RhinoCode-API → kein _-RunPythonScript-Echo
  - Yak-Paket via build.sh, Install in ~/Library/.../packages/8.0/
- Launcher (Tauri):
  - dossier_init Tauri-Command + Setup-Tab in Settings
  - Yak-Install + StartupCommands-XML + Window-Layout in einem Schritt
  - clean-rhino.sh fuer reproduzierbare Resets
  - check_dossier_initialized triggert Auto-Open-Setup beim ersten Start
- Wand-Architektur:
  - Chain-Logik DEAKTIVIERT → jede Wand baut eigenes Volume (individuell
    anwaehlbar, einzeln loeschbar)
  - Polyline-Wand: jedes Segment = eigene Wand
  - Smart-Split fuer wand_axis/decke/dach/raum/aussparung/traeger
  - Auto-Group axis+volume → kein ChooseOne-Dialog, Delete loescht beides
  - Stale-Mitre-Fix: Joint-Cache wird vor jedem Wand-Regen invalidiert
  - T-Junction-Tolerance auf 1mm (war 1cm, lieferte falsche T-Mitres)
- Wand-Stile:
  - Schema in dossier_project_settings.wand_styles (Material + Prio +
    Default-Dicke + Referenz, oder Layered mit Schichten)
  - dWall-Command Stil-Picker
  - ProjectSettingsDialog: Sidebar-Layout (Pill-Selection) +
    Wandstile-Tab mit Liste/Editor
  - _wand_chain_compat benutzt style_id
  - Prio-Dominanz: hoehere Prio gewinnt Eckverbindung, niedrigere wird
    T-mitered (siehe _resolve_corner_miter)
- Cmd+G fuer Group (Geschoss-Up auf Alias 'gu')
- Welcome + Cheatsheet borderless mit X/Back-Buttons
- BeginCommand-Hook fuer Gestaltung-Panel-Auto-Open
- panel_base: Python.NET-Enum-Fix fuer Material-Render
This commit is contained in:
2026-05-30 12:46:53 +02:00
parent 7930705d01
commit 18d6d98e07
54 changed files with 5575 additions and 398 deletions
+65
View File
@@ -0,0 +1,65 @@
#!/bin/bash
# clean-rhino.sh — setzt DOSSIER in Rhino zurueck auf "frisch installiert" Zustand.
# Damit kann das Setup im Launcher (Settings → Setup tab) jederzeit von Null
# durchgespielt werden.
#
# Aufgaben:
# 1. yak uninstall dossier (Plugin raus)
# 2. Window-Layout-Datei loeschen (workspaces/<guid>.xml)
# 3. StartupCommands-XML-Eintrag entfernen (Python-Bootstrap-Trigger)
#
# Bleibt unangetastet:
# - dossier_settings.json (User-Praeferenzen, Tags, etc.)
# - launcher recent.json
# - alles ausserhalb DOSSIER
set -e
RHINO_APP="/Applications/Rhino 8.app"
YAK="$RHINO_APP/Contents/Resources/bin/yak"
SETTINGS_XML="$HOME/Library/Application Support/McNeel/Rhinoceros/8.0/settings/settings-Scheme__Default.xml"
WORKSPACES_DIR="$HOME/Library/Application Support/McNeel/Rhinoceros/8.0/settings/Scheme__Default/workspaces"
LAYOUT_GUID="b6b68c03-3031-4899-bca2-fe6e425146fc"
# --- Safety: Rhino muss zu sein ---
if pgrep -f "Rhino 8.app/Contents/MacOS/Rhinoceros$" >/dev/null; then
echo "FEHLER: Rhino laeuft. Bitte beenden und nochmal."
exit 1
fi
# --- 1. Yak uninstall (idempotent — meldet 'package not installed' wenn schon weg) ---
echo "==> 1. Yak uninstall dossier"
if [ -x "$YAK" ]; then
"$YAK" uninstall dossier 2>&1 | sed 's/^/ /' || true
else
echo " WARN: yak nicht gefunden — skip"
fi
# --- 2. Window-Layout-Datei loeschen ---
echo "==> 2. Window-Layout-Datei loeschen"
LAYOUT_FILE="$WORKSPACES_DIR/$LAYOUT_GUID.xml"
if [ -f "$LAYOUT_FILE" ]; then
rm -v "$LAYOUT_FILE" | sed 's/^/ /'
else
echo " schon weg"
fi
# --- 3. StartupCommands-Eintrag aus XML entfernen ---
echo "==> 3. StartupCommands-Eintrag entfernen"
if [ -f "$SETTINGS_XML" ]; then
# sed: matche genau unsere DOSSIER-Zeile und loesche
# (egal welcher Pfad — solange startup.py drin steht)
if grep -q 'StartupCommands.*startup.py' "$SETTINGS_XML"; then
# macOS sed braucht leeres Backup-Suffix
sed -i '' '/<entry key="StartupCommands">.*startup\.py.*<\/entry>/d' "$SETTINGS_XML"
echo " entfernt"
else
echo " schon weg"
fi
else
echo " WARN: Rhino-settings-XML nicht gefunden"
fi
echo
echo "Clean fertig. Naechster Schritt:"
echo " → Launcher → Settings → Setup → 'Setup starten'"
+266 -2
View File
@@ -303,9 +303,17 @@ fn splash_owner_marker_path() -> PathBuf {
fn open_rhino_internal(app: &tauri::AppHandle, path3dm: &str) -> Result<(), String> {
let settings = load_settings();
// XML-Edit nur sinnvoll wenn Rhino nicht laeuft (sonst ueberschreibt's
// beim Beenden) UND der Eintrag fuer den naechsten Start eh schon greift.
// Setup-Schritte nur wenn Rhino NICHT laeuft sonst ueberschreibt Rhino
// unsere XML-Edits beim Beenden, und yak install kann die in-use .rhp
// nicht ersetzen.
if settings.auto_load_plugin && !is_rhino_running() {
// Schritt 1: .rhp via yak installieren/aktualisieren. Soft-Fail wenn
// .yak nicht gebundelt ist (Dev-Setup ohne build.sh ausgefuehrt) —
// Bootstrap via XML reicht alleine, Commands fehlen nur.
if let Err(e) = ensure_rhino_plugin_installed() {
eprintln!("[DOSSIER] Plugin-Install skip: {e}");
}
// Schritt 2: StartupCommands-XML fuer Python-Bootstrap setzen.
let startup_path = settings.plugin_startup_path
.clone()
.unwrap_or_else(default_plugin_startup_path);
@@ -466,11 +474,264 @@ fn ensure_rhino_startup_command(startup_path: &str) -> Result<(), String> {
Ok(())
}
// ===== Rhino-Plugin Install via Yak =====
// Mac Rhino lehnt Auto-Load fuer dritte Plugins ab (egal welche Methode).
// Wir installieren `.yak` aber trotzdem in den User-Plugin-Pfad — beim ersten
// dWall/dDoor/... laedt Rhino das Plugin on-demand und cached es danach.
// Der Python-Bootstrap (Panels/Aliases) laeuft parallel via StartupCommands-
// XML (ensure_rhino_startup_command). Beide Pfade zusammen = full setup.
fn yak_binary_path() -> PathBuf {
PathBuf::from("/Applications/Rhino 8.app/Contents/Resources/bin/yak")
}
fn rhino_packages_dir() -> PathBuf {
let home = std::env::var("HOME").map(PathBuf::from).unwrap_or_default();
home.join("Library/Application Support/McNeel/Rhinoceros/packages/8.0")
}
fn installed_dossier_version() -> Option<String> {
let manifest = rhino_packages_dir().join("DOSSIER/manifest.txt");
fs::read_to_string(&manifest).ok().map(|s| s.trim().to_string())
}
// .yak + version.txt im App-Bundle (Production) oder Repo-dist/ (Dev).
fn bundled_plugin_paths() -> (PathBuf, PathBuf) {
if let Ok(exe) = std::env::current_exe() {
if let Some(contents_dir) = exe.parent().and_then(|p| p.parent()) {
let yak = contents_dir.join("Resources/plugin/dossier.yak");
let ver = contents_dir.join("Resources/plugin/dossier-version.txt");
if yak.is_file() && ver.is_file() {
return (yak, ver);
}
}
}
// Dev-Fallback: Repo-Pfad
let repo = PathBuf::from("/Users/karim/STUDIO/DOSSIER/csharp/DOSSIER/dist");
(repo.join("dossier.yak"), repo.join("dossier-version.txt"))
}
fn bundled_plugin_version() -> Option<String> {
let (_, ver_path) = bundled_plugin_paths();
fs::read_to_string(&ver_path).ok().map(|s| s.trim().to_string())
}
// Installiert/aktualisiert das DOSSIER-Plugin via yak. Idempotent — skip wenn
// installierte Version == gebundelte Version. Soft-Fail wenn .yak fehlt oder
// yak-CLI nicht vorhanden (Logging, kein Error — Bootstrap via XML laeuft eh).
fn ensure_rhino_plugin_installed() -> Result<(), String> {
let yak = yak_binary_path();
if !yak.is_file() {
return Err(format!("yak CLI nicht gefunden: {}", yak.display()));
}
let (yak_pkg, _) = bundled_plugin_paths();
if !yak_pkg.is_file() {
return Err(format!(
"DOSSIER .yak-Paket nicht gefunden: {} (build.sh in csharp/DOSSIER ausfuehren)",
yak_pkg.display()
));
}
let bundled_ver = bundled_plugin_version();
let installed_ver = installed_dossier_version();
if let (Some(b), Some(i)) = (&bundled_ver, &installed_ver) {
if b == i {
return Ok(()); // schon aktuell — kein Re-Install
}
}
if is_rhino_running() {
return Err(
"Rhino laeuft — Plugin-Update kann erst nach Rhino-Quit installiert werden."
.into(),
);
}
let pkg_dir = yak_pkg.parent().ok_or_else(|| "Plugin-Pfad ohne Parent".to_string())?;
let output = Command::new(&yak)
.arg("install")
.arg("dossier")
.arg("--source")
.arg(pkg_dir)
.output()
.map_err(|e| format!("yak install: {e}"))?;
if !output.status.success() {
return Err(format!(
"yak install fehlgeschlagen: {}",
String::from_utf8_lossy(&output.stderr)
));
}
eprintln!(
"[DOSSIER] Plugin via yak installiert: {}{}",
installed_ver.unwrap_or_else(|| "(nicht installiert)".into()),
bundled_ver.unwrap_or_else(|| "(unbekannt)".into())
);
Ok(())
}
#[tauri::command]
fn open_rhino(app: tauri::AppHandle, path3dm: String) -> Result<(), String> {
open_rhino_internal(&app, &path3dm)
}
#[tauri::command]
fn install_rhino_plugin() -> Result<String, String> {
ensure_rhino_plugin_installed()?;
Ok(installed_dossier_version().unwrap_or_else(|| "(version unbekannt)".into()))
}
// ===== Window-Layout Installation =====
// Rhino-Workspaces sind XML-Dateien im User-Pfad benannt nach Layout-GUID.
// Wir bundlen DOSSIERs Master-Layout(s) im Repo unter rhino/workspaces/ und
// kopieren sie beim Init in Rhinos Workspaces-Folder, damit startup.py das
// Layout per `_-WindowLayout "<name>" _Enter` direkt anwenden kann.
fn rhino_workspaces_dir() -> PathBuf {
let home = std::env::var("HOME").map(PathBuf::from).unwrap_or_default();
home.join("Library/Application Support/McNeel/Rhinoceros/8.0/settings/Scheme__Default/workspaces")
}
fn bundled_workspaces_dir() -> PathBuf {
if let Ok(exe) = std::env::current_exe() {
if let Some(contents_dir) = exe.parent().and_then(|p| p.parent()) {
let bundled = contents_dir.join("Resources/rhino/workspaces");
if bundled.is_dir() {
return bundled;
}
}
}
PathBuf::from("/Users/karim/STUDIO/DOSSIER/rhino/workspaces")
}
// Kopiert alle Workspace-XMLs aus dem bundle in Rhinos Workspaces-Folder.
// Vorhandene Files werden ueberschrieben (User-Customizations gehen verloren —
// dafuer ist's reproduzierbar). Returns Anzahl kopierter Files.
fn ensure_window_layout_installed() -> Result<usize, String> {
let src = bundled_workspaces_dir();
if !src.is_dir() {
return Err(format!("Workspace-Quelle fehlt: {}", src.display()));
}
let dst = rhino_workspaces_dir();
fs::create_dir_all(&dst)
.map_err(|e| format!("Workspace-Zielordner erstellen: {e}"))?;
if is_rhino_running() {
return Err("Rhino laeuft — bitte erst beenden (Layout-Datei wird sonst ueberschrieben).".into());
}
let mut count = 0;
for entry in fs::read_dir(&src).map_err(|e| format!("Workspace-Quelle lesen: {e}"))? {
let entry = entry.map_err(|e| format!("DirEntry: {e}"))?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()).map(|s| s.eq_ignore_ascii_case("xml")).unwrap_or(false) {
let file_name = path.file_name().ok_or("FileName")?;
let dst_path = dst.join(file_name);
fs::copy(&path, &dst_path)
.map_err(|e| format!("Workspace kopieren ({}): {e}", file_name.to_string_lossy()))?;
count += 1;
}
}
Ok(count)
}
// ===== DOSSIER INIT =====
// Komplettes Setup auf einem neuen PC: Plugin via Yak + StartupCommands-XML +
// Window-Layout-Files. Returns Status pro Schritt fuer das Frontend-Dialog.
#[derive(Serialize, Clone, Debug)]
struct InitStep {
id: String,
label: String,
status: String, // "ok" | "error" | "skipped"
detail: String,
}
#[derive(Serialize, Clone, Debug)]
struct InitResult {
steps: Vec<InitStep>,
overall_ok: bool,
}
#[derive(Serialize, Clone, Debug)]
struct InitStatus {
plugin_installed: bool,
startup_cmd_set: bool,
layout_installed: bool,
initialized: bool,
}
#[tauri::command]
fn check_dossier_initialized() -> InitStatus {
let plugin_installed = installed_dossier_version().is_some();
let startup_cmd_set = fs::read_to_string(rhino_settings_xml_path())
.map(|s| s.contains("StartupCommands") && s.contains("startup.py"))
.unwrap_or(false);
let layout_installed = rhino_workspaces_dir()
.join("b6b68c03-3031-4899-bca2-fe6e425146fc.xml")
.is_file();
InitStatus {
plugin_installed,
startup_cmd_set,
layout_installed,
// initialized = ALLE drei vorhanden. So zeigt der Dialog auch nach
// teilweisem Clean (z.B. nur layout geloescht) noch an.
initialized: plugin_installed && startup_cmd_set && layout_installed,
}
}
#[tauri::command]
fn dossier_init() -> Result<InitResult, String> {
if is_rhino_running() {
return Err("Rhino laeuft — bitte erst beenden, dann Init nochmal starten.".into());
}
let mut steps = Vec::new();
let mut overall_ok = true;
// Schritt 1: Plugin via Yak installieren/aktualisieren
let (status, detail) = match ensure_rhino_plugin_installed() {
Ok(()) => {
let v = installed_dossier_version().unwrap_or_else(|| "(?)".into());
("ok".into(), format!("Version {v}"))
}
Err(e) => { overall_ok = false; ("error".into(), e) }
};
steps.push(InitStep {
id: "plugin".into(),
label: "DOSSIER-Plugin via Yak installieren".into(),
status, detail,
});
// Schritt 2: StartupCommands-XML eintragen (Python-Bootstrap)
let startup_path = default_plugin_startup_path();
let (status, detail) = if !Path::new(&startup_path).is_file() {
overall_ok = false;
("error".into(), format!("startup.py nicht gefunden: {startup_path}"))
} else {
match ensure_rhino_startup_command(&startup_path) {
Ok(()) => ("ok".into(), startup_path.clone()),
Err(e) => { overall_ok = false; ("error".into(), e) }
}
};
steps.push(InitStep {
id: "startup".into(),
label: "Python-Bootstrap (StartupCommands-XML)".into(),
status, detail,
});
// Schritt 3: Window-Layout-Files in Rhinos Workspaces-Folder kopieren
let (status, detail) = match ensure_window_layout_installed() {
Ok(n) => ("ok".into(), format!("{n} Layout-Datei(en) installiert")),
Err(e) => { overall_ok = false; ("error".into(), e) }
};
steps.push(InitStep {
id: "layout".into(),
label: "Window-Layout in Rhino kopieren".into(),
status, detail,
});
Ok(InitResult { steps, overall_ok })
}
#[tauri::command]
fn trigger_plugin_load_now() -> Result<(), String> {
// Schreibt den `_RunPythonScript <pfad>` Eintrag in Rhinos Startup-Command-
@@ -924,6 +1185,9 @@ pub fn run() {
read_project_config,
open_rhino,
trigger_plugin_load_now,
install_rhino_plugin,
dossier_init,
check_dossier_initialized,
get_default_plugin_startup_path,
show_in_finder,
is_rhino_running,
+3 -1
View File
@@ -56,7 +56,9 @@
},
"resources": {
"../../dist": "dist",
"../../rhino": "rhino"
"../../rhino": "rhino",
"../../csharp/DOSSIER/dist/dossier.yak": "plugin/dossier.yak",
"../../csharp/DOSSIER/dist/dossier-version.txt": "plugin/dossier-version.txt"
}
},
"plugins": {
+151
View File
@@ -126,6 +126,13 @@ export default function App() {
invoke('list_window_layouts').then(setLayouts).catch(() => {})
invoke('read_dossier_settings').then(ds => setActiveLayout(ds?.windowLayout || '')).catch(() => {})
invoke('read_settings').then(s => setTags(s?.tags || [])).catch(() => {})
// Auto-Open Setup-Dialog wenn DOSSIER nicht initialisiert ist (z.B. nach
// clean-rhino.sh oder auf einem neuen Mac).
invoke('check_dossier_initialized')
.then(st => {
if (!st?.initialized) { setSettingsTab('setup'); setSettingsOpen(true) }
})
.catch(() => {})
}, [])
// File-Meta laden sobald recent sich aendert
@@ -750,6 +757,7 @@ function SettingsDialog({ initialTab = 'rhino', onClose }) {
<header style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<span>Einstellungen</span>
<div style={{ display: 'flex', gap: 4, marginLeft: 'auto', flexWrap: 'wrap' }}>
<TabBtn active={tab === 'setup'} onClick={() => setTab('setup')}>Setup</TabBtn>
<TabBtn active={tab === 'rhino'} onClick={() => setTab('rhino')}>Rhino</TabBtn>
<TabBtn active={tab === 'view'} onClick={() => setTab('view')}>View</TabBtn>
<TabBtn active={tab === 'ebenen'} onClick={() => setTab('ebenen')}>Ebenen</TabBtn>
@@ -759,6 +767,7 @@ function SettingsDialog({ initialTab = 'rhino', onClose }) {
</div>
</header>
<div className="body">
{tab === 'setup' && <SetupSettings />}
{tab === 'rhino' && <RhinoSettings />}
{tab === 'view' && <ViewSettings />}
{tab === 'ebenen' && <EbenenSchemaSettings />}
@@ -774,6 +783,148 @@ function SettingsDialog({ initialTab = 'rhino', onClose }) {
)
}
function SetupSettings() {
const [running, setRunning] = useState(false)
const [result, setResult] = useState(null) // { steps, overall_ok }
const [error, setError] = useState(null)
const [rhinoBusy, setRhinoBusy] = useState(false)
const [status, setStatus] = useState(null) // { plugin_installed, startup_cmd_set, layout_installed, initialized }
const [rhinoApp, setRhinoApp] = useState('')
const [startupPath, setStartupPath] = useState('')
// Live-Check: ob Rhino laeuft (Init kann nicht laufen wenn ja)
useEffect(() => {
let cancelled = false
const tick = () => {
invoke('is_rhino_running')
.then(v => { if (!cancelled) setRhinoBusy(!!v) })
.catch(() => {})
}
tick()
const id = setInterval(tick, 2000)
return () => { cancelled = true; clearInterval(id) }
}, [])
// Initialer State-Check + erkannte Rhino-Konfig
const refreshStatus = useCallback(() => {
invoke('check_dossier_initialized').then(setStatus).catch(() => {})
}, [])
useEffect(() => {
refreshStatus()
invoke('read_settings').then(s => setRhinoApp(s?.rhinoApp || 'Rhinoceros 8')).catch(() => {})
invoke('get_default_plugin_startup_path').then(setStartupPath).catch(() => {})
}, [refreshStatus])
const runInit = async () => {
setRunning(true); setError(null); setResult(null)
try {
const r = await invoke('dossier_init')
setResult(r)
refreshStatus()
} catch (e) {
setError(typeof e === 'string' ? e : (e?.message || String(e)))
} finally {
setRunning(false)
}
}
const dot = (ok) => (
<span style={{
display: 'inline-block', width: 8, height: 8, borderRadius: 4,
background: ok ? 'var(--accent)' : '#e87b6b', marginRight: 8,
}} />
)
return (
<div>
<h3 style={{ marginTop: 0 }}>DOSSIER einrichten</h3>
<p style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>
Setzt DOSSIER auf einem frischen Mac komplett auf: installiert das C#-Plugin in Rhino via Yak,
traegt den Python-Bootstrap als Startup-Command ein, und kopiert das DOSSIER-Window-Layout in Rhinos
Workspaces-Folder. Idempotent kann mehrfach ausgefuehrt werden.
</p>
{/* Erkannte Konfiguration */}
<div style={{
marginTop: 14, padding: 10, background: 'rgba(255,255,255,0.04)',
border: '1px solid var(--border)', borderRadius: 6, fontSize: 11,
}}>
<div style={{ color: 'var(--text-muted)', marginBottom: 6 }}>Erkannte Konfiguration:</div>
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 4 }}>
<span style={{ color: 'var(--text-muted)' }}>Rhino-App:</span>
<span>{rhinoApp || '(nicht gesetzt)'}</span>
<span style={{ color: 'var(--text-muted)' }}>startup.py:</span>
<span style={{ wordBreak: 'break-all' }}>{startupPath || '(nicht gefunden)'}</span>
</div>
<div style={{ marginTop: 6, color: 'var(--text-muted)', fontSize: 10 }}>
(Aendern unter Settings Rhino)
</div>
</div>
{/* Aktueller Install-Status (live) */}
{status && (
<div style={{ marginTop: 12, fontSize: 11 }}>
<div style={{ color: 'var(--text-muted)', marginBottom: 4 }}>Status:</div>
<div>{dot(status.plugin_installed)}DOSSIER-Plugin (.rhp) installiert</div>
<div>{dot(status.startup_cmd_set)}Python-Bootstrap in Rhino-StartupCommands</div>
<div>{dot(status.layout_installed)}Window-Layout in Rhino-Workspaces</div>
</div>
)}
<p style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 14 }}>
Hinweis: Rhino muss waehrend des Setups <strong>geschlossen</strong> sein.
</p>
<div style={{ marginTop: 16, display: 'flex', alignItems: 'center', gap: 12 }}>
<button
className="primary pill"
onClick={runInit}
disabled={running || rhinoBusy}
title={rhinoBusy ? 'Rhino laeuft — bitte beenden' : 'Setup starten'}
>
{running ? 'Setup laeuft…' : 'Setup starten'}
</button>
{rhinoBusy && (
<span style={{ fontSize: 11, color: '#e87b6b' }}>
Rhino laeuft bitte beenden.
</span>
)}
</div>
{result && (
<ul style={{ listStyle: 'none', padding: 0, marginTop: 20, borderTop: '1px solid var(--border)' }}>
{result.steps.map(s => (
<li key={s.id} style={{ display: 'flex', alignItems: 'flex-start', gap: 10,
padding: '10px 0', borderBottom: '1px solid var(--border)' }}>
<span style={{ width: 16, fontSize: 14,
color: s.status === 'ok' ? 'var(--accent)' : '#e87b6b' }}>
{s.status === 'ok' ? '✓' : '✗'}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12 }}>{s.label}</div>
<div style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 2,
wordBreak: 'break-all' }}>
{s.detail}
</div>
</div>
</li>
))}
<li style={{ padding: '10px 0', fontSize: 11,
color: result.overall_ok ? 'var(--accent)' : '#e87b6b' }}>
{result.overall_ok
? '✓ Alle Schritte erfolgreich. Rhino oeffnen — Plugin laedt bei erstem dWall/dDoor/...-Aufruf, startup.py bootstrappt automatisch.'
: '✗ Mindestens ein Schritt ist fehlgeschlagen. Details oben.'}
</li>
</ul>
)}
{error && (
<p style={{ color: '#e87b6b', marginTop: 12, fontSize: 12 }}>{error}</p>
)}
</div>
)
}
function TabBtn({ active, onClick, children }) {
return (
<button