Files
ARIA-AGENT/android/src/components/SkillBrowser.tsx
T
duffyduck 8359500476 feat(skills): P3 config_schema + P4 Versionierung mit Rollback
P3 — Skill-Configuration
- aria-brain/skills.py: SKILL_CONFIGS_FILE (/shared/config/skill_configs.json)
  als zentrale Werte-Persistenz. _normalize_config_schema validiert die
  Schema-Felder (name/type/label/secret/description/default), CFG_<UPPER_NAME>
  ENV beim run_skill. create_skill + update_skill akzeptieren config_schema.
- agent.py: skill_set_config Brain-Tool fuer ARIA. skill_create/update um
  config_schema-Property erweitert.
- main.py: GET/POST /skills/{name}/config — secret-Werte in Antwort gemaskt.

P4 — Versionierung mit Rollback
- aria-brain/skills.py: archive_current_version archiviert nach
  versions/v_<ts>/ (ohne venv/logs). update_skill ruft das automatisch auf
  bevor strukturelle Aenderungen passieren. list_skill_versions,
  rollback_skill (mit Safety-Snapshot + automatischem venv-Rebuild),
  delete_skill_version.
- agent.py: skill_list_versions, skill_rollback Brain-Tools.
- main.py: GET /skills/{name}/versions, POST /skills/{name}/rollback,
  DELETE /skills/{name}/versions/{version_id}.

UI
- diagnostic/index.html: Skill-Detail um Config-Form (typ-spezifisch,
  Secrets als password-Input mit ***SET***-Hinweis) und Versions-Liste
  mit Rollback-/Delete-Button.
- android SkillBrowser: SkillDetailModal laedt config_schema + versions
  on-mount. Config-Form (TextInput + Switch fuer boolean), Versionen mit
  Rollback-Confirm. brainApi um SkillConfigField/SkillVersion +
  getSkillConfig/setSkillConfig/listSkillVersions/rollbackSkill/
  deleteSkillVersion erweitert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:52:46 +02:00

658 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Skill-Browser — Liste aller Skills mit Toggle, Tap-zum-Details, Run,
* Logs und Loeschen.
*
* Eingesetzt von SettingsScreen → Sektion "Skills".
*
* Brain-API ueber brainApi (RVS-Brain-Proxy). Code-Edits laufen NICHT
* ueber diese UI — Skill-Code-Aenderungen sind ARIAs Domaene
* (skill_update Brain-Tool). Hier nur Manifest-Felder + Run + Cleanup.
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Modal,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import brainApi, { Skill, SkillConfigField, SkillVersion } from '../services/brainApi';
const COL_ACTIVE = '#34C759';
const COL_INACTIVE = '#555570';
const COL_ARIA = '#FFD60A';
const COL_STEFAN = '#0096FF';
function relTime(iso: string | null | undefined): string {
if (!iso) return '—';
const t = new Date(iso).getTime();
if (!t) return '—';
const diffSec = Math.floor((Date.now() - t) / 1000);
if (diffSec < 60) return `vor ${diffSec}s`;
if (diffSec < 3600) return `vor ${Math.floor(diffSec / 60)}min`;
if (diffSec < 86400) return `vor ${Math.floor(diffSec / 3600)}h`;
return `vor ${Math.floor(diffSec / 86400)}d`;
}
export const SkillBrowser: React.FC = () => {
const [items, setItems] = useState<Skill[]>([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [filter, setFilter] = useState<'all' | 'active' | 'inactive'>('all');
const [detail, setDetail] = useState<Skill | null>(null);
const load = useCallback(() => {
setLoading(true); setErr(null);
brainApi.listSkills()
.then(s => {
s.sort((a, b) => {
if (a.active !== b.active) return a.active ? -1 : 1;
return (a.name || '').localeCompare(b.name || '');
});
setItems(s);
})
.catch(e => setErr(String(e?.message || e)))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
const visible = items.filter(s => {
if (filter === 'active') return s.active;
if (filter === 'inactive') return !s.active;
return true;
});
const toggleActive = (s: Skill) => {
brainApi.updateSkill(s.name, { active: !s.active })
.then(() => load())
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
};
const renderItem = ({ item }: { item: Skill }) => {
const isAria = (item.author || '').toLowerCase() === 'aria';
const authorColor = isAria ? COL_ARIA : COL_STEFAN;
const authorLabel = isAria ? '🤖 von ARIA' : '👤 von Stefan';
return (
<TouchableOpacity style={s.row} onPress={() => setDetail(item)}>
<View style={{flex: 1, marginRight: 8}}>
<View style={{flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 4}}>
<Text style={{color: authorColor, fontSize: 10, fontWeight: '700'}}>{authorLabel}</Text>
<Text style={{color: '#E0E0F0', fontWeight: '600', flex: 1}} numberOfLines={1}>{item.name}</Text>
</View>
<Text style={{color: '#8888AA', fontSize: 12}} numberOfLines={2}>{item.description}</Text>
{item.setup_error ? (
<Text style={{color: '#FF6B6B', fontSize: 11, marginTop: 4}} numberOfLines={2}>
Setup-Fehler: {item.setup_error}
</Text>
) : null}
<Text style={{color: '#444460', fontSize: 10, marginTop: 4}}>
{item.execution} · {item.use_count || 0}× ausgefuehrt · zuletzt: {relTime(item.last_used)}
</Text>
</View>
<Switch
value={item.active}
onValueChange={() => toggleActive(item)}
trackColor={{ false: '#1E1E2E', true: COL_ACTIVE }}
thumbColor="#E0E0F0"
/>
</TouchableOpacity>
);
};
return (
<View style={{flex: 1}}>
<View style={s.toolbar}>
{(['all', 'active', 'inactive'] as const).map(f => (
<TouchableOpacity
key={f}
style={[s.chip, filter === f && s.chipActive]}
onPress={() => setFilter(f)}
>
<Text style={{color: filter === f ? '#0D0D1A' : '#8888AA', fontSize: 12, fontWeight: '600'}}>
{f === 'all' ? 'Alle' : f === 'active' ? 'Aktive' : 'Inaktive'}
</Text>
</TouchableOpacity>
))}
<View style={{flex: 1}} />
<TouchableOpacity onPress={load} style={s.iconBtn}>
<Text style={{fontSize: 16}}>{'↻'}</Text>
</TouchableOpacity>
</View>
{err ? <Text style={s.err}>{err}</Text> : null}
{loading && items.length === 0 ? (
<ActivityIndicator color="#0096FF" style={{marginTop: 20}} />
) : (
<FlatList
data={visible}
keyExtractor={s => s.name}
renderItem={renderItem}
nestedScrollEnabled={true}
ListEmptyComponent={
<Text style={{color: '#555570', textAlign: 'center', padding: 20, fontStyle: 'italic'}}>
{items.length === 0
? '(noch keine Skills — frag ARIA: "bau mir einen Skill der ...")'
: '(keine Treffer für diesen Filter)'}
</Text>
}
contentContainerStyle={{paddingBottom: 20}}
/>
)}
{detail ? (
<SkillDetailModal
skill={detail}
onClose={() => setDetail(null)}
onReload={() => { load(); brainApi.getSkill(detail.name).then(setDetail).catch(() => {}); }}
/>
) : null}
</View>
);
};
// ── Detail-Modal mit Run + Logs + Delete ─────────────────────────────
interface DetailProps {
skill: Skill;
onClose: () => void;
onReload: () => void;
}
const SkillDetailModal: React.FC<DetailProps> = ({ skill, onClose, onReload }) => {
const [argValues, setArgValues] = useState<Record<string, string>>({});
const [running, setRunning] = useState(false);
const [runResult, setRunResult] = useState<{
ok: boolean; exit_code: number; stdout: string; stderr: string; duration_sec: number;
} | null>(null);
const [logs, setLogs] = useState<any[] | null>(null);
const [loadingLogs, setLoadingLogs] = useState(false);
// P3: Skill-Config (statische Werte je Skill, z.B. API-Keys)
const [cfgSchema, setCfgSchema] = useState<SkillConfigField[]>([]);
const [cfgValues, setCfgValues] = useState<Record<string, any>>({});
const [cfgDraft, setCfgDraft] = useState<Record<string, string>>({});
const [cfgSaving, setCfgSaving] = useState(false);
// P4: Versionen + Rollback
const [versions, setVersions] = useState<SkillVersion[]>([]);
const [versionsLoading, setVersionsLoading] = useState(false);
const args = Array.isArray(skill.args) ? skill.args : [];
// Config + Versionen beim Mount laden
useEffect(() => {
brainApi.getSkillConfig(skill.name)
.then(r => { setCfgSchema(r.schema || []); setCfgValues(r.values || {}); })
.catch(() => {});
setVersionsLoading(true);
brainApi.listSkillVersions(skill.name)
.then(setVersions)
.catch(() => setVersions([]))
.finally(() => setVersionsLoading(false));
}, [skill.name]);
const setArg = (name: string, value: string) =>
setArgValues(prev => ({ ...prev, [name]: value }));
const run = () => {
setRunning(true); setRunResult(null);
const argsObj: Record<string, any> = {};
for (const a of args) {
if (a?.name && argValues[a.name] !== undefined && argValues[a.name] !== '') {
argsObj[a.name] = argValues[a.name];
}
}
brainApi.runSkill(skill.name, argsObj)
.then(r => setRunResult(r))
.catch(e => setRunResult({
ok: false, exit_code: -1, stdout: '', stderr: String(e?.message || e), duration_sec: 0,
}))
.finally(() => setRunning(false));
};
const loadLogs = () => {
setLoadingLogs(true);
brainApi.getSkillLogs(skill.name, 20)
.then(setLogs)
.catch(e => Alert.alert('Logs-Fehler', String(e?.message || e)))
.finally(() => setLoadingLogs(false));
};
const remove = () => {
Alert.alert(
'Skill loeschen?',
`"${skill.name}" wird komplett entfernt (venv, logs, manifest). Nicht rueckholbar.`,
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Loeschen',
style: 'destructive',
onPress: () => {
brainApi.deleteSkill(skill.name)
.then(() => { onReload(); onClose(); })
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
},
},
],
);
};
const saveConfig = () => {
// secret-Felder die als '***SET***' angezeigt sind und vom User NICHT
// angefasst wurden, bleiben auf dem alten Wert. cfgDraft enthaelt nur
// explizit getippte Werte; alles andere uebernehmen wir aus cfgValues.
const next: Record<string, any> = { ...cfgValues };
for (const f of cfgSchema) {
const draft = cfgDraft[f.name];
const isSecret = f.secret || f.type === 'password';
if (draft === undefined) continue;
if (isSecret && draft === '') continue; // leer = unveraendert
if (draft === '') { delete next[f.name]; continue; }
if (f.type === 'number') {
const n = Number(draft); next[f.name] = isNaN(n) ? draft : n;
} else if (f.type === 'boolean') {
next[f.name] = draft === 'true' || draft === '1';
} else {
next[f.name] = draft;
}
}
// Maskierte Werte (***SET***) niemals zurueckschreiben
for (const k of Object.keys(next)) if (next[k] === '***SET***') delete next[k];
setCfgSaving(true);
brainApi.setSkillConfig(skill.name, next)
.then(() => {
// frisch laden um neuen masked-State zu zeigen
return brainApi.getSkillConfig(skill.name);
})
.then(r => { setCfgSchema(r.schema || []); setCfgValues(r.values || {}); setCfgDraft({}); })
.catch(e => Alert.alert('Speichern fehlgeschlagen', String(e?.message || e)))
.finally(() => setCfgSaving(false));
};
const reloadVersions = () => {
setVersionsLoading(true);
brainApi.listSkillVersions(skill.name)
.then(setVersions)
.catch(() => {})
.finally(() => setVersionsLoading(false));
};
const doRollback = (versionId: string) => {
Alert.alert(
'Rollback?',
`Skill "${skill.name}" auf ${versionId} zuruecksetzen?\n\nDer aktuelle Stand wird vorher automatisch gesichert (safety-snapshot).`,
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Rollback', style: 'destructive',
onPress: () => {
brainApi.rollbackSkill(skill.name, versionId)
.then(r => {
Alert.alert('Rollback OK', `Safety-Snapshot: ${r.safety_snapshot}`);
reloadVersions(); onReload();
})
.catch(e => Alert.alert('Rollback fehlgeschlagen', String(e?.message || e)));
},
},
],
);
};
const removeVersion = (versionId: string) => {
Alert.alert(
'Version loeschen?',
`${versionId} dauerhaft entfernen?`,
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Loeschen', style: 'destructive',
onPress: () => {
brainApi.deleteSkillVersion(skill.name, versionId)
.then(reloadVersions)
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
},
},
],
);
};
return (
<Modal visible animationType="slide" onRequestClose={onClose} transparent={false}>
<View style={s.modal}>
<View style={s.modalHeader}>
<Text style={s.modalTitle} numberOfLines={1}>{skill.name}</Text>
<TouchableOpacity onPress={onClose} hitSlop={{top:8,bottom:8,left:8,right:8}}>
<Text style={{color: '#8888AA', fontSize: 18}}>{'✕'}</Text>
</TouchableOpacity>
</View>
<ScrollView style={{flex: 1}} contentContainerStyle={{padding: 16}}>
<Text style={s.label}>Beschreibung</Text>
<Text style={{color: '#E0E0F0', marginBottom: 12}}>{skill.description}</Text>
<View style={s.metaBox}>
<Text style={s.meta}>execution: {skill.execution} · entry: {skill.entry}</Text>
<Text style={s.meta}>author: {skill.author || '?'} · version: {skill.version || '?'}</Text>
<Text style={s.meta}>{skill.use_count || 0}× ausgefuehrt · zuletzt: {relTime(skill.last_used)}</Text>
{skill.setup_error ? (
<Text style={[s.meta, {color: '#FF6B6B'}]}>setup_error: {skill.setup_error}</Text>
) : null}
{Array.isArray(skill.requires?.pip) && skill.requires!.pip!.length > 0 ? (
<Text style={s.meta}>pip: {skill.requires!.pip!.join(', ')}</Text>
) : null}
</View>
{/* Args-Inputs */}
{args.length > 0 ? (
<>
<Text style={[s.label, {marginTop: 18}]}>Argumente</Text>
{args.map((a: any) => (
<View key={a.name} style={{marginBottom: 10}}>
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 4}}>
{a.name}{a.required ? ' *' : ''} {a.description ? `${a.description}` : ''}
</Text>
<TextInput
style={s.input}
value={argValues[a.name] || ''}
onChangeText={(v) => setArg(a.name, v)}
placeholder={a.type || 'string'}
placeholderTextColor="#444460"
autoCapitalize="none"
autoCorrect={false}
/>
</View>
))}
</>
) : null}
{/* Config-Schema-Form (P3) */}
{cfgSchema.length > 0 ? (
<>
<Text style={[s.label, {marginTop: 18}]}> Konfiguration</Text>
{cfgSchema.map((f) => {
const isSecret = f.secret || f.type === 'password';
const cur = cfgValues[f.name];
const isSet = isSecret && cur === '***SET***';
const placeholder = isSet ? '••• gesetzt — leer lassen = unverändert'
: (f.default !== undefined && f.default !== null ? `Default: ${String(f.default)}` : (f.type || 'string'));
const valStr = cfgDraft[f.name] !== undefined
? cfgDraft[f.name]
: (isSecret ? '' : (cur !== undefined && cur !== null && cur !== '***SET***' ? String(cur) : ''));
if (f.type === 'boolean') {
const bv = cfgDraft[f.name] !== undefined
? (cfgDraft[f.name] === 'true')
: (cur === true || cur === 'true');
return (
<View key={f.name} style={{marginBottom: 10, flexDirection: 'row', alignItems: 'center', gap: 10}}>
<Switch value={bv} onValueChange={(v) => setCfgDraft(p => ({...p, [f.name]: v ? 'true' : 'false'}))}
trackColor={{false: '#1E1E2E', true: '#0096FF'}} thumbColor="#fff" />
<View style={{flex: 1}}>
<Text style={{color: '#E0E0F0', fontSize: 13}}>{f.label || f.name}</Text>
{f.description ? <Text style={{color: '#555570', fontSize: 11}}>{f.description}</Text> : null}
</View>
</View>
);
}
return (
<View key={f.name} style={{marginBottom: 10}}>
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 4}}>
{f.label || f.name}{isSecret ? ' 🔒' : ''}
{f.description ? <Text style={{color: '#555570'}}> {f.description}</Text> : null}
</Text>
<TextInput
style={s.input}
value={valStr}
onChangeText={(v) => setCfgDraft(p => ({...p, [f.name]: v}))}
placeholder={placeholder}
placeholderTextColor="#444460"
autoCapitalize="none"
autoCorrect={false}
secureTextEntry={isSecret}
keyboardType={f.type === 'number' ? 'numeric' : 'default'}
/>
</View>
);
})}
<TouchableOpacity
style={[s.btn, {backgroundColor: '#1A1A2E', borderColor: COL_ACTIVE, marginTop: 4}]}
onPress={saveConfig}
disabled={cfgSaving}
>
<Text style={{color: COL_ACTIVE, textAlign: 'center', fontWeight: '700'}}>
{cfgSaving ? 'Speichere...' : '💾 Konfiguration speichern'}
</Text>
</TouchableOpacity>
</>
) : null}
{/* Versionen (P4) */}
{versions.length > 0 ? (
<>
<Text style={[s.label, {marginTop: 18}]}>📦 Versionen ({versions.length})</Text>
{versions.map(v => (
<View key={v.version_id} style={[s.metaBox, {marginTop: 6, flexDirection: 'row', alignItems: 'center', gap: 6}]}>
<View style={{flex: 1}}>
<Text style={[s.meta, {fontFamily: 'monospace', color: '#E0E0F0'}]}>{v.version_id}</Text>
<Text style={s.meta}>{v.archived_at ? new Date(v.archived_at).toLocaleString('de-DE') : '—'}</Text>
{v.summary ? <Text style={[s.meta, {fontStyle: 'italic'}]} numberOfLines={2}>{v.summary}</Text> : null}
</View>
<TouchableOpacity onPress={() => doRollback(v.version_id)}
style={[s.btn, {paddingHorizontal: 10, paddingVertical: 6, borderColor: COL_ARIA, backgroundColor: '#1A1A2E'}]}>
<Text style={{color: COL_ARIA, fontSize: 12}}></Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => removeVersion(v.version_id)}
style={[s.btn, {paddingHorizontal: 10, paddingVertical: 6, borderColor: '#FF6B6B', backgroundColor: '#1A1A2E'}]}>
<Text style={{color: '#FF6B6B', fontSize: 12}}>🗑</Text>
</TouchableOpacity>
</View>
))}
</>
) : versionsLoading ? (
<ActivityIndicator color="#0096FF" style={{marginTop: 14}} />
) : null}
<View style={{flexDirection: 'row', gap: 8, marginTop: 14}}>
<TouchableOpacity
style={[s.btn, {backgroundColor: skill.active ? '#0096FF' : '#1E1E2E', flex: 1}]}
onPress={run}
disabled={!skill.active || running}
>
<Text style={{color: skill.active ? '#fff' : '#555570', fontWeight: '700', textAlign: 'center'}}>
{running ? 'läuft...' : '▶ Ausführen'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[s.btn, {backgroundColor: '#1A1A2E', flex: 1}]}
onPress={loadLogs}
>
<Text style={{color: '#8888AA', textAlign: 'center'}}>📜 Logs</Text>
</TouchableOpacity>
</View>
{!skill.active ? (
<Text style={{color: '#FFD60A', fontSize: 12, marginTop: 6, fontStyle: 'italic'}}>
Skill ist deaktiviert toggle in der Liste zum Aktivieren.
</Text>
) : null}
{/* Run-Result */}
{runResult ? (
<View style={[s.metaBox, {marginTop: 14, borderLeftWidth: 3, borderLeftColor: runResult.ok ? COL_ACTIVE : '#FF6B6B'}]}>
<Text style={[s.meta, {color: runResult.ok ? COL_ACTIVE : '#FF6B6B', fontWeight: '700'}]}>
{runResult.ok ? '✓ OK' : `✗ FEHLER (exit ${runResult.exit_code})`} · {runResult.duration_sec}s
</Text>
{runResult.stdout ? (
<>
<Text style={[s.meta, {marginTop: 6, color: '#8888AA', fontWeight: '600'}]}>stdout:</Text>
<Text style={[s.meta, {fontFamily: 'monospace', color: '#C0C0D0'}]}>{runResult.stdout}</Text>
</>
) : null}
{runResult.stderr ? (
<>
<Text style={[s.meta, {marginTop: 6, color: '#FF6B6B', fontWeight: '600'}]}>stderr:</Text>
<Text style={[s.meta, {fontFamily: 'monospace', color: '#FF9999'}]}>{runResult.stderr}</Text>
</>
) : null}
</View>
) : null}
{/* Logs */}
{loadingLogs ? (
<ActivityIndicator color="#0096FF" style={{marginTop: 14}} />
) : logs ? (
<View style={{marginTop: 14}}>
<Text style={[s.label, {marginTop: 0}]}>Letzte Runs (Top 20)</Text>
{logs.length === 0 ? (
<Text style={{color: '#555570', fontStyle: 'italic'}}>(keine Logs)</Text>
) : logs.map((log, idx) => (
<View key={idx} style={[s.metaBox, {marginTop: 6, borderLeftWidth: 2, borderLeftColor: log.ok ? COL_ACTIVE : '#FF6B6B'}]}>
<Text style={[s.meta, {color: log.ok ? COL_ACTIVE : '#FF6B6B'}]}>
{log.ok ? '✓' : '✗'} {log.ts ? new Date(log.ts).toLocaleString('de-DE') : '?'} · {log.duration_sec || 0}s
</Text>
{log.stdout ? (
<Text style={[s.meta, {fontFamily: 'monospace', color: '#C0C0D0'}]} numberOfLines={3}>
{String(log.stdout).slice(0, 300)}
</Text>
) : null}
</View>
))}
</View>
) : null}
<View style={{height: 30}} />
</ScrollView>
<View style={s.modalFooter}>
<TouchableOpacity style={[s.btn, {backgroundColor: '#3A1F1F', borderColor: '#FF6B6B'}]} onPress={remove}>
<Text style={{color: '#FF6B6B', fontWeight: '700'}}>🗑 Loeschen</Text>
</TouchableOpacity>
<View style={{flex: 1}} />
<TouchableOpacity style={[s.btn, {backgroundColor: '#1A1A2E'}]} onPress={onClose}>
<Text style={{color: '#8888AA'}}>Schliessen</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
};
// ── Styles ───────────────────────────────────────────────────────────
const s = StyleSheet.create({
toolbar: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 10,
paddingVertical: 8,
backgroundColor: '#0D0D1A',
borderBottomWidth: 1,
borderBottomColor: '#1E1E2E',
},
chip: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 12,
backgroundColor: '#1A1A2E',
},
chipActive: {
backgroundColor: '#FFD60A',
},
iconBtn: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 6,
backgroundColor: '#1A1A2E',
},
row: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 14,
backgroundColor: '#0D0D1A',
borderBottomWidth: 1,
borderBottomColor: '#1E1E2E',
},
err: {
color: '#FF6B6B',
padding: 12,
fontSize: 12,
},
modal: {
flex: 1,
backgroundColor: '#0D0D1A',
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#1E1E2E',
},
modalTitle: {
color: '#E0E0F0',
fontSize: 16,
fontWeight: '700',
flex: 1,
marginRight: 12,
},
modalFooter: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderTopWidth: 1,
borderTopColor: '#1E1E2E',
gap: 8,
},
label: {
color: '#8888AA',
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginTop: 8,
marginBottom: 4,
},
input: {
backgroundColor: '#1A1A2E',
borderWidth: 1,
borderColor: '#1E1E2E',
borderRadius: 6,
color: '#E0E0F0',
padding: 10,
fontSize: 14,
},
metaBox: {
backgroundColor: '#1A1A2E',
borderRadius: 6,
padding: 10,
marginTop: 6,
gap: 4,
},
meta: {
color: '#8888AA',
fontSize: 12,
},
btn: {
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 6,
borderWidth: 1,
borderColor: 'transparent',
},
});
export default SkillBrowser;