/* ===================================================================
KabelFlux v5 — Project home, Connectors, Settings, Empty
=================================================================== */
/* ---------- Project home (NEW screen) ----------
Bento-style overview answering: "what is the state of this harness?"
============================================================ */
const ProjectHome = ({ project, onGoto, wires=[], connectors=[], nodes=[], bundles=[], realPid=null, onExport }) => {
const wireCount = wires.length;
const openWires = wires.filter(w => (w.notes || '').toLowerCase().includes('open')).length;
const lastEdit = '2 min ago by L. Kohli';
const RECENT_EVENTS = [
{ t: '2 min', who: 'L. Kohli', kind: 'edit', what: 'changed gauge on cavity 7 (22 → 20 AWG)' },
{ t: '14 min', who: 'L. Kohli', kind: 'add', what: 'added wire cavity 18 · J2:6 → J3:6 · BK' },
{ t: '1 h', who: 'R. Bedi', kind: 'review', what: 'marked rev B for engineering review' },
{ t: '3 h', who: 'L. Kohli', kind: 'edit', what: 'updated J5 type to D-Sub DB-9' },
{ t: '5 h', who: 'system', kind: 'backup', what: 'pre-deploy snapshot taken (224 KB)' },
{ t: 'Yesterday', who: 'R. Bedi',kind: 'note', what: 'added revision note: "Shield drain wire to chassis ring per IPC-WHMA-A-620"' },
];
return (
{project.code}
{project.status === 'released' ? 'Released' : project.status === 'review' ? 'In review' : project.status === 'draft' ? 'Draft' : 'Active'}
Rev {project.revision}
{project.name}
Last edit {lastEdit} · Owned by {project.owner}
Revision history
Share
onExport && onExport('prod')}>Production drawing
{/* Big harness preview */}
Layout preview
Schematic
onGoto('layout')}>Open Layout
{/* Health card */}
Harness health 0 ? 'warning' : 'success'} dot>{openWires > 0 ? `${openWires} open` : 'OK'}
w.shield).length} total={wires.filter(w=>w.shield).length}/>
a+c.pins,0)}/>
{/* Quick stats */}
At a glance
a+w.length,0)/1000).toFixed(2)} m`}/>
w.shield).length}/>
{/* Activity feed */}
Recent activity
View all
{RECENT_EVENTS.map((e, i) => (
))}
{/* Exports row */}
Recent exports All formats
);
};
/* Mini-layout for the Project Home preview card.
- Reads real connectors/wires/nodes/bundles from props.
- Computes a tight viewBox around the data + padding, lets the SVG
scale to fit the card.
- Empty-state placeholder when the project has no connectors yet. */
const MiniHarness = ({ wires=[], connectors=[], nodes=[], bundles=[] }) => {
// Empty-state: no connectors → show a gentle "open the Layout view"
// placeholder instead of a misleading generic harness diagram.
if (connectors.length === 0 && nodes.length === 0) {
return (
No layout yet
Add connectors and wires to see them here
);
}
// Compute bounding box from real connector + node positions, then pad.
const points = [
...connectors.map(c => ({ x: c.x || 0, y: c.y || 0 })),
...nodes.map(n => ({ x: n.x || 0, y: n.y || 0 })),
];
const xs = points.map(p => p.x);
const ys = points.map(p => p.y);
const PAD = 60;
const minX = Math.min.apply(null, xs) - PAD;
const minY = Math.min.apply(null, ys) - PAD;
const maxX = Math.max.apply(null, xs) + PAD;
const maxY = Math.max.apply(null, ys) + PAD;
const vbW = Math.max(maxX - minX, 200);
const vbH = Math.max(maxY - minY, 200);
// Lookup table: wire endpoint names → {x,y}. Matches LayoutView's
// `connectors.find(...) || nodes.find(...)` semantics so a wire that
// terminates at a node still draws.
const byName = {};
connectors.forEach(c => { byName[c.id] = c; });
nodes.forEach(n => { byName[n.id] = n; });
const wireD = (w) => {
const from = byName[w.fromConn];
const to = byName[w.toConn];
if (!from || !to) return null;
// Simple straight line for the preview — full LayoutView has bezier
// + 90° formboard modes but this thumbnail doesn't need them.
return `M ${from.x} ${from.y} L ${to.x} ${to.y}`;
};
const COLOR_HEX = (typeof window !== 'undefined' && window.COLOR_HEX) || {};
return (
{/* Bundles — soft tinted bands underneath the wires */}
{bundles.map((b, i) => (
b && b.from && b.to ? (
) : null
))}
{/* Wires — coloured by w.color, falls back to neutral grey */}
{wires.map(w => {
const d = wireD(w);
if (!d) return null;
const hex = COLOR_HEX[w.color] || '#888';
return (
);
})}
{/* Nodes — small amber dots so they read as "junctions" */}
{nodes.map(n => (
{n.label || n.id}
))}
{/* Connectors — outer ring + perimeter pin dots + label.
Pin count caps at 32 in the preview to keep the SVG light. */}
{connectors.map(c => {
const pins = Math.min(c.pins || 0, 32);
const r = 22;
const pinR = r - 7;
return (
{Array.from({ length: pins }).map((_, i) => {
const a = i / Math.max(pins, 1) * Math.PI * 2 - Math.PI / 2;
return (
);
})}
{c.id}
);
})}
);
};
const HealthRow = ({ label, done, total, warn=0 }) => {
const pct = total > 0 ? (done / total) * 100 : 100;
const ok = warn === 0 && done >= total;
return (
{label}
{done}/{total}
{warn > 0 && · {warn} warn }
);
};
const QStat = ({ icon, label, v }) => (
);
const ExportCard = ({ icon, label, sub, time, tone }) => (
{time}
);
/* ---------- Connectors view ---------- */
const ConnectorsView = ({ connectors=[], wires=[], loading=false, realPid=null, onRefresh }) => {
const toast = useToast();
const [editing, setEditing] = useState(null); // connector being edited, or 'new'
const [confirm, setConfirm] = useState(null);
/* Click-to-expand: on narrow viewports only one card body is visible
at a time. Initialise to the first connector so the user lands on
a non-empty view. Toggling closes the current card; clicking a
different card switches to it. */
const [expandedId, setExpandedId] = React.useState(null);
React.useEffect(() => {
if (expandedId == null && connectors.length) setExpandedId(connectors[0].id);
}, [connectors, expandedId]);
const toggleExpand = (id) => setExpandedId(prev => prev === id ? null : id);
const openNew = () => setEditing({
id: '', name: '', type: '', description: '',
pins: 8, gender: 'M', x: 100, y: 100,
});
const openEdit = (c) => setEditing({
id: c.id, name: c.id, type: c.type || '',
description: c.label && c.label.includes(' — ') ? c.label.split(' — ')[1] : '',
pins: c.pins || 0, gender: c.gender || 'M',
x: c.x || 100, y: c.y || 100,
_raw: c._raw,
});
const save = async (draft) => {
try {
if (draft._raw && draft._raw.id) {
await kfxUpdateConnector(draft._raw.id, {
id: draft.name, name: draft.name, type: draft.type, description: draft.description,
pins: draft.pins, gender: draft.gender, x: draft.x, y: draft.y,
});
toast.ok('Connector updated', draft.name);
} else {
if (!realPid) throw new Error('No project loaded');
await kfxCreateConnector(realPid, {
id: draft.name, name: draft.name, type: draft.type, description: draft.description,
pins: draft.pins, gender: draft.gender, x: draft.x, y: draft.y,
});
toast.ok('Connector added', draft.name);
}
setEditing(null);
if (onRefresh) await onRefresh();
} catch (e) {
toast.err('Could not save connector', (e && e.message) || 'Server error');
}
};
const del = (c) => {
setConfirm({
title: `Delete connector ${c.id}?`,
body: <>This removes {c.id} from the harness. Any wires terminating on it will become orphaned.>,
onConfirm: async () => {
if (!c._raw || !c._raw.id) { toast.err('No backend id for this connector'); return; }
try {
await kfxDeleteConnector(c._raw.id);
toast.ok(`Deleted ${c.id}`);
if (onRefresh) await onRefresh();
} catch (e) {
toast.err('Could not delete connector', (e && e.message) || 'Server error');
}
}
});
};
if (loading && connectors.length === 0) {
return (
);
}
if (connectors.length === 0) {
return (
Symbol library
New connector
>
}/>
Add the first connector}/>
{editing && setEditing(null)} onSave={save}/>}
);
}
return (
Symbol library
New connector
>
}/>
{connectors.map(c => {
const wired = wires.filter(w => w.fromConn === c.id || w.toConn === c.id).length;
const pct = c.pins > 0 ? (wired / c.pins) * 100 : 0;
const desc = c.label && c.label.includes(' — ') ? c.label.split(' — ')[1] : (c.type || '');
const isExpanded = expandedId === c.id;
return (
{/* Header is a button-ish row: clicking the chip+type area
toggles expansion; edit/trash stay separately clickable. */}
toggleExpand(c.id)}
aria-expanded={isExpanded}
aria-controls={`conn-body-${c.id}`}
aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${c.id}`}>
{c.id}
{c.type}
{ e && e.stopPropagation && e.stopPropagation(); openEdit(c); }}/>
{ e && e.stopPropagation && e.stopPropagation(); del(c); }}/>
{Array.from({ length: c.pins }).map((_, i) => {
const a = i / c.pins * Math.PI * 2 - Math.PI / 2;
return c.gender === 'M'
?
: ;
})}
Gender {c.gender === 'M' ? 'Male' : c.gender === 'F' ? 'Female' : '—'}
Pins {c.pins}
Wired {wired}/{c.pins}
{desc}
);
})}
Add connector
{editing && setEditing(null)} onSave={save}/>}
{confirm && setConfirm(null)}/>}
);
};
/* Simple connector edit/create modal — name + type + pins + gender. */
const ConnectorEditModal = ({ draft, onChange, onCancel, onSave }) => {
const isNew = !(draft._raw && draft._raw.id);
const setF = (k, v) => onChange({ ...draft, [k]: v });
const canSubmit = draft.name && draft.name.trim().length > 0 && draft.pins > 0;
return (
Cancel
{ if (canSubmit) await onSave(draft); }}>
{isNew ? 'Create connector' : 'Save changes'}
>
}>
);
};
/* ---------- Nodes view ---------- */
const NodesView = ({ nodes=[], wires=[], loading=false, realPid=null, onRefresh }) => {
const toast = useToast();
const [editing, setEditing] = useState(null);
const [confirm, setConfirm] = useState(null);
const openNew = () => setEditing({
id: '', label: '', type: 'splice', value: '',
part_number: '', description: '', length: '', length_unit: 'mm',
x: 300, y: 200,
/* Wires that should route through this node. Mirrors backend
wires.node_ref — we toggle each wire's node_ref to/from the
node label on save. */
wireIds: [],
});
const openEdit = (n) => setEditing({
id: n.id, label: n.label || n.id || '', type: n.type || 'splice',
value: n.value || '',
part_number: (n._raw && n._raw.part_number) || '',
description: (n._raw && n._raw.description) || '',
length: (n._raw && n._raw.length) || '',
length_unit: 'mm',
x: n.x || 300, y: n.y || 200,
/* Seed wireIds from the wires whose backend node_ref equals this
node's label. The user can then check more or uncheck some. */
wireIds: wires
.filter(w => (w._raw && w._raw.node_ref) === (n.label || n.id))
.map(w => w._raw && w._raw.id)
.filter(Boolean),
_raw: n._raw,
});
const save = async (draft) => {
try {
let savedNodeLabel = draft.label;
if (draft._raw && draft._raw.id) {
await kfxUpdateNode(draft._raw.id, draft);
toast.ok('Node updated', draft.label);
} else {
if (!realPid) throw new Error('No project loaded');
await kfxCreateNode(realPid, draft);
toast.ok('Node added', draft.label);
}
/* Sync wire-tags: any wire currently pointing at this node must
either stay (still checked) or be cleared (unchecked); any newly-
checked wire gets node_ref set to the node's label. Best-effort:
a single failed wire write toasts the error but doesn't block the
rest. */
const wantIds = new Set(draft.wireIds || []);
const wasIds = new Set(
wires.filter(w => (w._raw && w._raw.node_ref) === savedNodeLabel)
.map(w => w._raw && w._raw.id)
.filter(Boolean)
);
const toAdd = [...wantIds].filter(id => !wasIds.has(id));
const toRemove = [...wasIds].filter(id => !wantIds.has(id));
for (const id of toAdd) {
try { await kfxUpdateWire(id, { node_ref: savedNodeLabel }); }
catch (e) { toast.err(`Could not tag wire #${id}`, (e && e.message) || ''); }
}
for (const id of toRemove) {
try { await kfxUpdateWire(id, { node_ref: '' }); }
catch (e) { toast.err(`Could not untag wire #${id}`, (e && e.message) || ''); }
}
setEditing(null);
if (onRefresh) await onRefresh();
} catch (e) {
toast.err('Could not save node', (e && e.message) || 'Server error');
}
};
const del = (n) => {
setConfirm({
title: `Delete node ${n.label || n.id}?`,
body: <>This removes {n.label || n.id} from the harness.>,
onConfirm: async () => {
if (!n._raw || !n._raw.id) { toast.err('No backend id for this node'); return; }
try {
await kfxDeleteNode(n._raw.id);
toast.ok(`Deleted ${n.label || n.id}`);
if (onRefresh) await onRefresh();
} catch (e) {
toast.err('Could not delete node', (e && e.message) || 'Server error');
}
},
});
};
if (loading && nodes.length === 0) {
return (
);
}
if (nodes.length === 0) {
return (
Add a node
}/>
Add the first node}/>
{editing && setEditing(null)} onSave={save}/>}
);
}
return (
Add a node
}/>
Ref Type Value X Y
{nodes.map(n => (
{n.label || n.id}
{n.type}
{n.value || '—'}
{n.x}
{n.y}
openEdit(n)}/>
del(n)}/>
))}
{editing && setEditing(null)} onSave={save}/>}
{confirm && setConfirm(null)}/>}
);
};
/* Node-type icon for the preview tile — matches the on-canvas glyph
so the user gets immediate feedback when they change type. */
const NODE_TYPE_META = {
splice: { label: 'Wire junction / fan-out point', glyph: '⊕' },
fuse: { label: 'Inline fuse', glyph: '⊟' },
ground: { label: 'Chassis ground', glyph: '⏚' },
jumper: { label: 'Jumper / strap', glyph: '⇄' },
};
const NodeEditModal = ({ draft, wires=[], onChange, onCancel, onSave }) => {
const isNew = !(draft._raw && draft._raw.id);
const setF = (k, v) => onChange({ ...draft, [k]: v });
const canSubmit = draft.label && draft.label.trim().length > 0;
const wireIds = draft.wireIds || [];
const toggleWire = (id) => {
const set = new Set(wireIds);
if (set.has(id)) set.delete(id); else set.add(id);
setF('wireIds', [...set]);
};
const selectAll = () => setF('wireIds', wires.map(w => w._raw && w._raw.id).filter(Boolean));
const deselectAll = () => setF('wireIds', []);
const typeMeta = NODE_TYPE_META[draft.type] || NODE_TYPE_META.splice;
return (
Cancel
{ if (canSubmit) await onSave(draft); }}>
{isNew ? 'Save Node' : 'Save changes'}
>
}>
);
};
/* ---------- Settings (with proper IA — L3 fix) ---------- */
const SettingsView = ({ section='general' }) => {
const [tab, setTab] = useState(section);
return (
{[
{ k: 'general', l: 'General', icon: 'settings' },
/* Appearance tab dropped — theme is fixed to daylight, density
no UI, no other appearance knobs remain. */
{ k: 'shortcuts', l: 'Keyboard shortcuts',icon: 'keyboard' },
{ k: 'integrations',l: 'Integrations', icon: 'share' },
{ k: 'about', l: 'About KabelFlux', icon: 'info' },
].map(s => (
setTab(s.k)}>
{s.l}
))}
{tab === 'general' &&
}
{tab === 'shortcuts' &&
}
{tab === 'integrations' &&
}
{tab === 'about' &&
}
);
};
const GeneralSettings = () => (
<>
Localization
Used for BOM pricing, exports and timestamps.
Currency
USD — US Dollar ($)
EUR — Euro (€)
GBP — British Pound (£)
JPY — Japanese Yen (¥)
CHF — Swiss Franc (Fr)
INR — Indian Rupee (₹)
CNY — Chinese Yuan (¥)
SGD — Singapore Dollar (S$)
AED — UAE Dirham (د.إ)
CAD — Canadian Dollar (C$)
MXN — Mexican Peso (Mex$)
BRL — Brazilian Real (R$)
AUD — Australian Dollar (A$)
NZD — New Zealand Dollar (NZ$)
Applied to BOM totals, supplier costs and quote exports.
Number format
1,234.56 (US / UK)
1.234,56 (EU)
1 234,56 (FR / SE)
1'234.56 (CH)
Thousands separator and decimal mark.
Language / locale
English (United States)
English (United Kingdom)
Deutsch (Deutschland)
Français (France)
Español (España)
日本語 (日本)
中文 (简体)
Timezone
UTC — Coordinated Universal Time
PST/PDT — Los Angeles
EST/EDT — New York
GMT/BST — London
CET/CEST — Berlin
IST — Kolkata
JST — Tokyo
AEST/AEDT — Sydney
Date format
2026-05-23 (ISO 8601)
May 23, 2026
23 May 2026
23/05/2026
Time format
24-hour (14:30)
12-hour (2:30 PM)
>
);
const ShortcutsSettings = () => (
Keyboard shortcuts Editable
{[
['Command palette', ['⌘','K']],
['New wire', ['N']],
['Toggle Wire mode', ['W']],
['Search current view', ['/']],
['Switch project', ['⌘','P']],
['Export production drawing',['⌘','⇧','E']],
['Toggle sidebar', ['⌘','\\']],
['Layout tab', ['G','1']],
['Wires tab', ['G','2']],
['BOM tab', ['G','3']],
['Undo / redo', ['⌘','Z']],
].map(([label, keys]) => (
{label}
{keys.map((k,i) => {k} )}
))}
);
const IntegrationsSettings = () => (
Connected systems
{[
{ name:'Komax / Schleuniger', status:'connected', sub:'KX-V machine · last sync 2 h ago' },
{ name:'AutoCAD / Fusion 360', status:'connected', sub:'DXF export ready' },
{ name:'Jira (engineering)', status:'disconnect',sub:'Connect to link revisions to issues' },
{ name:'PLM (Teamcenter)', status:'disconnect',sub:'Map title block to part numbers' },
].map(it => (
{it.status === 'connected'
?
Connected
:
Connect }
))}
);
const AboutSettings = () => (
About
KabelFlux e-Harness Suite · v5.0 UX refresh
A wiring-harness design and documentation tool for aerospace engineers.
Compliant outputs: AS50881 conventions, IPC-WHMA-A-620, IEC 60757 colour codes, Komax / Schleuniger wire-prep CSV, AutoCAD DXF.
);
/* ---------- Project Settings (per-project, sidebar entry) ----------
Per-project metadata, defaults, access, and danger zone. Everything
personal / workspace-wide (theme, locale, currency, profile) lives in
the global Settings view instead. */
const ProjectSettingsView = ({ project }) => {
const toast = useToast();
const [section, setSection] = useState('overview');
// Team state — keeps which member is the Owner so we can enforce
// "only one Owner at a time". Picking "Transfer ownership…" on any
// non-Owner row moves the Owner role to that member and the previous
// Owner falls back to Designer.
const [team, setTeam] = useState([
{ id:'lk', name:'Laila Kohli', email:'laila@ikran.aero', role:'Owner', last:'now' },
{ id:'rb', name:'Reza Bedi', email:'reza@ikran.aero', role:'Approver', last:'12 min ago' },
{ id:'so', name:'Sam Okonkwo', email:'sam.o@ikran.aero', role:'Reviewer', last:'2 h ago' },
{ id:'nl', name:'Noemi Lazar', email:'noemi@ikran.aero', role:'Designer', last:'2 d ago' },
{ id:'ap', name:'Aiden Park', email:'aiden@ikran.aero', role:'Viewer', last:'yesterday' },
]);
const [pendingTransfer, setPendingTransfer] = useState(null); // {id, name}
const setMemberRole = (id, role) => {
if (role === '__transfer__') {
const target = team.find(m => m.id === id);
setPendingTransfer({ id, name: target.name });
return;
}
setTeam(t => t.map(m => m.id === id ? { ...m, role } : m));
};
const confirmTransfer = () => {
setTeam(t => t.map(m => {
if (m.id === pendingTransfer.id) return { ...m, role: 'Owner' };
if (m.role === 'Owner') return { ...m, role: 'Designer' };
return m;
}));
toast.ok('Ownership transferred', `${pendingTransfer.name} is now the Owner`);
setPendingTransfer(null);
};
const sections = [
{ k:'overview', l:'Overview', icon:'folder' },
{ k:'titleblock',l:'Title block', icon:'title' },
{ k:'team', l:'Team & access', icon:'user' },
{ k:'defaults', l:'Harness defaults', icon:'wires' },
{ k:'compliance',l:'Compliance', icon:'check' },
{ k:'exports', l:'Exports & sync', icon:'share' },
{ k:'danger', l:'Danger zone', icon:'warn' },
];
return (
Settings for {project.name} ({project.code} ). For personal preferences, currency or theme, open Settings from your avatar menu.}
actions={null}/>
{sections.map(s => (
setSection(s.k)}>
{s.l}
))}
{section === 'overview' && (
)}
{section === 'titleblock' && (
Title block
Document header printed on every sheet of the production drawing.
)}
{section === 'team' && (
Project team
Assign a role for every member. Only Admins can change roles; the role gates what each member can do — designers edit, reviewers can comment + sign off, approvers must approve before release, viewers are read-only.
Add member
{/* Role legend — shown FIRST so admins know what each role
can do before they assign one. Compact pill row that
doesn't require scrolling. */}
Owner
Approver
Reviewer
Designer
Viewer
Hover a role for details.
Member
Project role
Status
Last active
{team.map(m => {
const isOwner = m.role === 'Owner';
const tone = m.role === 'Owner' ? 'success' : m.role === 'Approver' ? 'warning' : m.role === 'Reviewer' ? 'violet' : m.role === 'Designer' ? 'info' : undefined;
return (
{m.name.split(' ').map(s => s[0]).join('').slice(0,2)}
{isOwner ? (
{m.role}
) : (
setMemberRole(m.id, e.target.value)}>
Approver
Reviewer
Designer
Viewer
──────────
Transfer ownership to {m.name.split(' ')[0]}…
)}
active
{m.last}
{isOwner ? (
) : (
setTeam(t => t.filter(x => x.id !== m.id))}/>
)}
);
})}
)}
{section === 'defaults' && (
<>
Harness defaults Defaults for new wires and connectors in this project.
Project units
Millimetres (mm)
Inches (in)
Overrides your personal units for this project only.
Wire spec
MIL-DTL-22759 (PTFE, aerospace)
MIL-DTL-81044 (XL-ETFE)
MIL-DTL-81381 (polyimide)
ISO 6722 (automotive)
Default gauge
{['28','26','24','22','20','18','16','14','12','10'].map(g => {g} AWG )}
Default colour code
IEC 60757
MIL-STD-681
Custom palette
Operating temperature
−40 °C → +105 °C
−55 °C → +150 °C
−55 °C → +200 °C
−65 °C → +260 °C
>
)}
{section === 'compliance' && (
Compliance & standards
{[
{ id:'as50881', l:'AS50881', desc:'Aerospace wiring practice', on:true },
{ id:'ipc620', l:'IPC/WHMA-A-620', desc:'Cable & wire harness assemblies', on:true },
{ id:'iec60757', l:'IEC 60757', desc:'Colour-coding designation', on:true },
{ id:'mils8081', l:'MIL-STD-681', desc:'US-military colour code (alt)', on:false },
{ id:'rohs', l:'RoHS-3', desc:'Restriction of hazardous substances', on:true },
{ id:'reach', l:'REACH SVHC', desc:'EU substance compliance', on:true },
{ id:'itar', l:'ITAR-controlled', desc:'Restricted to US persons in workspace', on:false },
].map(c => (
{c.l}
{c.desc}
{c.on &&
Enforced }
))}
)}
{section === 'exports' && (
<>
Default export targets
Production drawing PDF
A3 landscape · 14 sheets
A2 landscape · 8 sheets
Tabloid (11×17)
Wire-prep CSV format
Komax (TopWin .crimp)
Schleuniger CAYMAN
Generic CSV
>
)}
{section === 'danger' && (
Danger zone
Archive project
Project becomes read-only and is hidden from the default project list. Restorable any time.
Archive
Transfer ownership
Hand off the project to another workspace member. You'll keep editor access.
Transfer…
Delete project
Permanently removes wires, connectors, drawings and the project itself. Backups remain for 90 days.
toast.warn('Delete requires Admin confirmation')}>Delete…
)}
{pendingTransfer && (
setPendingTransfer(null)}
onConfirm={confirmTransfer}
body={
You are about to transfer ownership of
{project.name} to
{pendingTransfer.name} .
Only one member can be the Owner at a time.
The current Owner will be re-assigned the Designer role and can be changed afterwards.
This action is logged in the Audit log.
}/>
)}
);
};
/* ---------- Symbol Library (workspace-level) ----------
Shared catalog of connector symbols available across every project.
Categories along the top, search + filters in a toolbar, then a grid
of symbol cards each showing a tiny preview, family, pin count, and
usage stats. Clicking a card would open a detail drawer (placeholder). */
const SYMBOL_FAMILIES = [
{ k:'all', l:'All', n:48 },
{ k:'circular', l:'Circular MS', n:11 },
{ k:'deutsch', l:'Deutsch DT', n: 7 },
{ k:'ampseal', l:'AmpSeal', n: 5 },
{ k:'weatherpack', l:'Weatherpack', n: 4 },
{ k:'dsub', l:'D-Sub', n: 6 },
{ k:'molex', l:'Molex MX150', n: 5 },
{ k:'jst', l:'JST', n: 4 },
{ k:'custom', l:'Custom', n: 6 },
];
const SYMBOL_ROWS = [
{ id:'sym01', family:'circular', name:'MS3470 · 6-pin', pins: 6, gender:'plug', shell:'A', used:14, updated:'2026-04-22', tags:['mil-c-26482'] },
{ id:'sym02', family:'circular', name:'MS3476 · 19-pin', pins:19, gender:'recep', shell:'C', used:23, updated:'2026-04-22', tags:['mil-c-26482'] },
{ id:'sym03', family:'circular', name:'D38999 · 8-pin', pins: 8, gender:'plug', shell:'11', used:31, updated:'2026-05-04', tags:['series III'] },
{ id:'sym04', family:'circular', name:'D38999 · 24-pin', pins:24, gender:'recep', shell:'17', used:48, updated:'2026-05-12', tags:['series III'] },
{ id:'sym05', family:'deutsch', name:'DT04-2P', pins: 2, gender:'plug', shell:'—', used:62, updated:'2026-03-15', tags:[] },
{ id:'sym06', family:'deutsch', name:'DT04-4P', pins: 4, gender:'plug', shell:'—', used:54, updated:'2026-03-15', tags:[] },
{ id:'sym07', family:'deutsch', name:'DT06-6S', pins: 6, gender:'recep', shell:'—', used:33, updated:'2026-03-15', tags:[] },
{ id:'sym08', family:'ampseal', name:'AS-23', pins:23, gender:'plug', shell:'—', used:18, updated:'2026-02-08', tags:[] },
{ id:'sym09', family:'ampseal', name:'AS-35', pins:35, gender:'plug', shell:'—', used:12, updated:'2026-02-08', tags:[] },
{ id:'sym10', family:'weatherpack', name:'WP-2', pins: 2, gender:'plug', shell:'—', used:41, updated:'2026-01-29', tags:[] },
{ id:'sym11', family:'weatherpack', name:'WP-4', pins: 4, gender:'recep', shell:'—', used:29, updated:'2026-01-29', tags:[] },
{ id:'sym12', family:'dsub', name:'DB9', pins: 9, gender:'plug', shell:'—', used:22, updated:'2026-05-01', tags:['serial'] },
{ id:'sym13', family:'dsub', name:'DB25', pins:25, gender:'recep', shell:'—', used:17, updated:'2026-05-01', tags:['parallel'] },
{ id:'sym14', family:'molex', name:'MX150 · 8-pos', pins: 8, gender:'plug', shell:'—', used:24, updated:'2026-04-11', tags:['sealed'] },
{ id:'sym15', family:'jst', name:'JST PH · 4-pin', pins: 4, gender:'plug', shell:'—', used:38, updated:'2026-03-30', tags:[] },
{ id:'sym16', family:'jst', name:'JST XH · 6-pin', pins: 6, gender:'plug', shell:'—', used:14, updated:'2026-03-30', tags:[] },
{ id:'sym17', family:'custom', name:'Mira-S splice ring', pins: 4, gender:'—', shell:'—', used: 6, updated:'2026-05-21', tags:['internal'] },
{ id:'sym18', family:'custom', name:'Cherokee bulkhead', pins:32, gender:'recep', shell:'—', used: 3, updated:'2026-05-09', tags:['internal'] },
];
const SymbolGlyph = ({ row }) => {
// Parametric mode — when the row carries explicit dimensions (rows, cols,
// pitch, shell W/H, shellShape), draw the outline + pin grid at scale.
if (row.dims && row.dims.rows > 0 && row.dims.cols > 0) {
const { rows, cols, pitch = 6, shellW, shellH, shellShape = 'rect', corner = 4 } = row.dims;
const gridW = (cols - 1) * pitch;
const gridH = (rows - 1) * pitch;
const margin = pitch * 1.6;
const w = shellW || Math.max(gridW + margin * 2, pitch * 2);
const h = shellH || Math.max(gridH + margin * 2, pitch * 2);
const vbPad = 8;
const vbW = w + vbPad * 2;
const vbH = h + vbPad * 2;
// ---- shell-shape primitives ----
// Each returns an array of [outerEl, innerEl] for the outline + the
// thin inset stroke. Drawn at the origin (0,0); width/height come in.
const outline = (() => {
const outerProps = { fill:'var(--bg-3)', stroke:'var(--line-strong)', strokeWidth:1.5 };
const innerProps = { fill:'none', stroke:'var(--line)', strokeWidth:1 };
const inset = 4;
switch (shellShape) {
case 'circle': {
const r = w / 2;
return (<>
>);
}
case 'hex': {
// Pointy-top hex sized so width = w, height = h
const hx = w / 2, hy = h / 2;
const xQ = hx * 0.5;
const outer = `${-hx + xQ},${-hy} ${hx - xQ},${-hy} ${hx},0 ${hx - xQ},${hy} ${-hx + xQ},${hy} ${-hx},0`;
const sx = (hx - inset) * 1, sy = (hy - inset) * 1;
const sxQ = sx * 0.5;
const inner = `${-sx + sxQ},${-sy} ${sx - sxQ},${-sy} ${sx},0 ${sx - sxQ},${sy} ${-sx + sxQ},${sy} ${-sx},0`;
return (<>
>);
}
case 'diamond': {
const outer = `0,${-h / 2} ${w / 2},0 0,${h / 2} ${-w / 2},0`;
const sx = w / 2 - inset, sy = h / 2 - inset;
const inner = `0,${-sy} ${sx},0 0,${sy} ${-sx},0`;
return (<>
>);
}
case 'triangle': {
const outer = `0,${-h / 2} ${w / 2},${h / 2} ${-w / 2},${h / 2}`;
const inner = `0,${-h / 2 + inset * 1.5} ${w / 2 - inset},${h / 2 - inset} ${-w / 2 + inset},${h / 2 - inset}`;
return (<>
>);
}
case 'dshape': {
// Classic D-Sub trapezoid — wider at top, narrower at bottom.
const tx = w * 0.5, bx = w * 0.42;
const outer = `${-tx},${-h / 2} ${tx},${-h / 2} ${bx},${h / 2} ${-bx},${h / 2}`;
const stx = tx - inset, sbx = bx - inset;
const inner = `${-stx},${-h / 2 + inset} ${stx},${-h / 2 + inset} ${sbx},${h / 2 - inset} ${-sbx},${h / 2 - inset}`;
return (<>
>);
}
case 'arrow': {
// Right-facing arrow pentagon.
const ax = w * 0.42, tip = w * 0.5;
const outer = `${-tip},${-h * 0.35} ${ax},${-h * 0.35} ${ax},${-h * 0.5} ${tip},0 ${ax},${h * 0.5} ${ax},${h * 0.35} ${-tip},${h * 0.35}`;
return (<>
>);
}
case 'cross': {
// Plus-sign cross.
const a = w * 0.18, b = w * 0.5;
const outer = `${-a},${-b} ${a},${-b} ${a},${-a} ${b},${-a} ${b},${a} ${a},${a} ${a},${b} ${-a},${b} ${-a},${a} ${-b},${a} ${-b},${-a} ${-a},${-a}`;
return (<>
>);
}
case 'rect':
default: {
return (<>
>);
}
}
})();
return (
{outline}
{Array.from({ length: rows * cols }).map((_, i) => {
const r = Math.floor(i / cols);
const c = i % cols;
const x = -gridW / 2 + c * pitch;
const y = -gridH / 2 + r * pitch;
return ;
})}
);
}
// Original auto-layout glyph — used by library cards without explicit dims.
const cap = Math.min(row.pins, 12);
const cols = cap <= 4 ? cap : Math.ceil(Math.sqrt(cap));
const rows = Math.ceil(cap / cols);
const cw = 16, gap = 4;
const w = cols * cw + (cols - 1) * gap;
const h = rows * cw + (rows - 1) * gap;
const isRound = row.family === 'circular' || row.family === 'deutsch';
return (
{isRound ? (
<>
>
) : (
<>
>
)}
{Array.from({ length: cap }).map((_, i) => {
const r = Math.floor(i / cols);
const c = i % cols;
return ;
})}
{row.pins > cap && +{row.pins - cap} }
);
};
const LibraryView = () => {
const [family, setFamily] = useState('all');
const [q, setQ] = useState('');
const [gender, setGender] = useState('all');
const [view, setView] = useState('grid');
const [createOpen, setCreateOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [symbols, setSymbols] = useState([]);
const [loading, setLoading] = useState(true);
const toast = useToast();
/* Hydrate from /api/connlib on mount. Backend seeds the 180 built-in
parts; user-added customs come back in the same list. */
React.useEffect(() => {
let alive = true;
(async () => {
try {
const rows = await window.kfxLoadConnectorLibrary();
if (!alive) return;
setSymbols((rows || []).map(window.kfxMapLibraryRow));
} catch (e) {
if (alive) toast.err && toast.err('Could not load symbol library', (e && e.message) || '');
} finally {
if (alive) setLoading(false);
}
})();
return () => { alive = false; };
}, []);
/* Families are derived from the loaded data — one per manufacturer —
so the existing kf-library-fam tab strip becomes manufacturer-driven
instead of hardcoded SYMBOL_FAMILIES. */
const families = useMemo(() => {
const counts = {};
for (const s of symbols) counts[s.family] = (counts[s.family] || 0) + 1;
const dynamic = Object.keys(counts)
.sort()
.map(k => ({
k,
/* Display label: pull the original (cased) manufacturer string from
any row in this family. */
l: (symbols.find(s => s.family === k) || {}).manufacturer || k,
n: counts[k],
}));
return [{ k: 'all', l: 'All', n: symbols.length }, ...dynamic];
}, [symbols]);
const filtered = useMemo(() => {
let list = symbols;
if (family !== 'all') list = list.filter(s => s.family === family);
if (gender !== 'all') list = list.filter(s => s.gender === gender);
const needle = q.trim().toLowerCase();
if (needle) list = list.filter(s =>
(s.name + ' ' + s.manufacturer + ' ' + s.series + ' ' + s.description + ' ' + s.tags.join(' '))
.toLowerCase().includes(needle)
);
return list;
}, [family, gender, q, symbols]);
const builtinCount = symbols.filter(s => s.isBuiltin).length;
const customCount = symbols.length - builtinCount;
return (
Workspace · shared across all projects
Symbol Library
{loading ? 'Loading…' : <>
{builtinCount} built-in · {customCount} custom · grouped by manufacturer.
>}
setImportOpen(true)}>Import symbol
setCreateOpen(true)}>New symbol
{families.map(f => (
setFamily(f.k)}>
{f.l}
{f.n}
))}
setQ(e.target.value)}
placeholder="Search by name, tag or part number…"
aria-label="Search symbols"/>
{['all','plug','recep'].map(g => (
setGender(g)}>
{g === 'all' ? 'Both' : g === 'plug' ? 'Plug' : 'Receptacle'}
))}
setView('grid')} aria-label="Grid view">
setView('list')} aria-label="List view">
{filtered.length === 0 && (
)}
{view === 'grid' && (
{filtered.map(s => (
{s.name}
{s.description &&
{s.description}
}
{s.pins}p
{s.gender !== '—' && {s.gender === 'plug' ? 'Male' : s.gender === 'recep' ? 'Female' : s.gender} }
{s.termination && {s.termination} }
{!s.isBuiltin && custom }
{s.used} uses
{s.updated}
))}
)}
{view === 'list' && (
Symbol Family Pins Gender Shell Used in Updated
{filtered.map(s => (
{s.name}
{s.tags.join(' · ') || '—'}
{s.manufacturer || s.family}
{s.pins}
{s.gender !== '—' && {s.gender} }
{s.shell}
{s.used}
{s.updated}
))}
)}
{createOpen && (
setCreateOpen(false)}
onCreate={(sym) => {
setSymbols(s => [{ ...sym, id: `sym${Date.now()}`, used: 0, updated: new Date().toISOString().slice(0,10) }, ...s]);
toast.ok('Symbol added', sym.name);
setCreateOpen(false);
}}/>
)}
{importOpen && (
setImportOpen(false)}
onImport={(rows) => {
setSymbols(s => [...rows, ...s]);
toast.ok(`Imported ${rows.length} symbol${rows.length === 1 ? '' : 's'}`);
setImportOpen(false);
}}/>
)}
);
};
/* ===================================================================
Catalog of standard connectors — the 90% case.
Real-world harness designers spend most of their time picking from a
vendor catalog (Deutsch, TE Connectivity, MIL-C, JST, Molex, etc.).
Free-form SVG authoring is for the rare custom bulkhead or splice ring.
=================================================================== */
const CATALOG = [
// Aerospace / MIL-C
{ id:'c01', mfr:'Amphenol', pn:'MS3470L8-33P', family:'circular', name:'MS3470 · 8-pin', pins: 8, gender:'plug', shell:'A', series:'MIL-C-26482', sealed:true, contact:'crimp', awg:'20-24', tags:['mil-c-26482','flight-critical'] },
{ id:'c02', mfr:'Amphenol', pn:'MS3476W19-32S', family:'circular', name:'MS3476 · 19-pin', pins:19, gender:'recep', shell:'C', series:'MIL-C-26482', sealed:true, contact:'crimp', awg:'20-24', tags:['mil-c-26482'] },
{ id:'c03', mfr:'TE', pn:'D38999/26WB35PN', family:'circular', name:'D38999 · 13-pin', pins:13, gender:'plug', shell:'11', series:'D38999 III', sealed:true, contact:'crimp', awg:'22-26', tags:['series III','flight-critical'] },
{ id:'c04', mfr:'TE', pn:'D38999/26WC35SN', family:'circular', name:'D38999 · 24-pin', pins:24, gender:'recep', shell:'17', series:'D38999 III', sealed:true, contact:'crimp', awg:'22-26', tags:['series III','flight-critical'] },
// Automotive / off-highway
{ id:'c05', mfr:'Deutsch', pn:'DT04-2P', family:'deutsch', name:'DT04-2P', pins: 2, gender:'plug', shell:'—', series:'DT', sealed:true, contact:'crimp', awg:'14-18', tags:['automotive','ip67'] },
{ id:'c06', mfr:'Deutsch', pn:'DT04-4P', family:'deutsch', name:'DT04-4P', pins: 4, gender:'plug', shell:'—', series:'DT', sealed:true, contact:'crimp', awg:'14-18', tags:['automotive','ip67'] },
{ id:'c07', mfr:'Deutsch', pn:'DT06-6S', family:'deutsch', name:'DT06-6S', pins: 6, gender:'recep', shell:'—', series:'DT', sealed:true, contact:'crimp', awg:'14-18', tags:['automotive'] },
{ id:'c08', mfr:'Deutsch', pn:'DTM04-12PA', family:'deutsch', name:'DTM04-12PA', pins:12, gender:'plug', shell:'—', series:'DTM', sealed:true, contact:'crimp', awg:'18-20', tags:['automotive','ecu'] },
// Sealed automotive
{ id:'c09', mfr:'TE', pn:'1-770966-0', family:'ampseal', name:'AmpSeal 23-pin', pins:23, gender:'plug', shell:'—', series:'AmpSeal', sealed:true, contact:'crimp', awg:'16-22', tags:['automotive','ecu'] },
{ id:'c10', mfr:'TE', pn:'1-776164-1', family:'ampseal', name:'AmpSeal 35-pin', pins:35, gender:'plug', shell:'—', series:'AmpSeal', sealed:true, contact:'crimp', awg:'16-22', tags:['automotive'] },
{ id:'c11', mfr:'Delphi', pn:'12015193', family:'weatherpack', name:'Weatherpack 2-pin', pins: 2, gender:'plug', shell:'—', series:'WP', sealed:true, contact:'crimp', awg:'14-20', tags:['automotive'] },
{ id:'c12', mfr:'Delphi', pn:'12010974', family:'weatherpack', name:'Weatherpack 4-pin', pins: 4, gender:'recep', shell:'—', series:'WP', sealed:true, contact:'crimp', awg:'14-20', tags:['automotive'] },
// D-Sub serial
{ id:'c13', mfr:'Amphenol', pn:'L17DEFRA9PA309', family:'dsub', name:'DB9', pins: 9, gender:'plug', shell:'—', series:'D-Sub', sealed:false, contact:'solder', awg:'24-28', tags:['serial','rs-232'] },
{ id:'c14', mfr:'Amphenol', pn:'L17DBFRA25SA309', family:'dsub', name:'DB25', pins:25, gender:'recep', shell:'—', series:'D-Sub', sealed:false, contact:'solder', awg:'24-28', tags:['parallel'] },
// Molex sealed
{ id:'c15', mfr:'Molex', pn:'33472-1206', family:'molex', name:'MX150 · 8-pos', pins: 8, gender:'plug', shell:'—', series:'MX150', sealed:true, contact:'crimp', awg:'16-22', tags:['automotive','sealed'] },
{ id:'c16', mfr:'Molex', pn:'33482-1201', family:'molex', name:'MX150 · 12-pos', pins:12, gender:'recep', shell:'—', series:'MX150', sealed:true, contact:'crimp', awg:'16-22', tags:['automotive'] },
// Tiny board-level
{ id:'c17', mfr:'JST', pn:'PHR-4', family:'jst', name:'JST PH · 4-pin', pins: 4, gender:'plug', shell:'—', series:'PH', sealed:false, contact:'crimp', awg:'24-32', tags:['board','2.0mm'] },
{ id:'c18', mfr:'JST', pn:'XHP-6', family:'jst', name:'JST XH · 6-pin', pins: 6, gender:'plug', shell:'—', series:'XH', sealed:false, contact:'crimp', awg:'22-26', tags:['board','2.5mm'] },
];
/* ----- Symbol creation widget -----
Two paths:
· From catalog (default, 90% case) — search a vendor catalog, click
to add. All metadata (pin count, gender, contact, AWG range) comes
in automatically.
· Build custom — for proprietary connectors. Family + pin layout
(rows×cols or linear/circular) + pinout table. Shape primitives
only — no raw SVG/XY editing in the front door.
*/
const SymbolCreator = ({ onClose, onCreate }) => {
const [path, setPath] = useState('catalog');
return (
e.stopPropagation()}>
Add a connector to the library
Pick from 1,800+ standard parts in the catalog, or build a custom symbol for proprietary connectors.
setPath('catalog')}>
From catalog
{CATALOG.length * 100}+
setPath('build')}>
Build custom
setPath('image')}>
From image
beta
{path === 'catalog' &&
}
{path === 'build' && }
{path === 'image' && }
);
};
/* ----- Path A: Catalog ----- */
const CatalogPicker = ({ onPick }) => {
const [q, setQ] = useState('');
const [fam, setFam] = useState('all');
const [sealed, setSealed] = useState('any');
const families = useMemo(() => {
const out = {};
CATALOG.forEach(c => { out[c.family] = (out[c.family] || 0) + 1; });
return out;
}, []);
const filtered = useMemo(() => {
let list = CATALOG;
if (fam !== 'all') list = list.filter(c => c.family === fam);
if (sealed === 'sealed') list = list.filter(c => c.sealed);
if (sealed === 'unsealed') list = list.filter(c => !c.sealed);
const needle = q.trim().toLowerCase();
if (needle) list = list.filter(c => (c.pn + ' ' + c.name + ' ' + c.mfr + ' ' + c.tags.join(' ')).toLowerCase().includes(needle));
return list;
}, [q, fam, sealed]);
return (
setQ(e.target.value)}
placeholder="Search by part number, manufacturer, or tag (e.g. ‘DT04’, ‘D38999’, ‘sealed automotive’)…"
autoFocus aria-label="Search catalog"/>
{q && setQ('')} aria-label="Clear"> }
{[['any','Any'],['sealed','Sealed'],['unsealed','Unsealed']].map(([k, l]) => (
setSealed(k)}>{l}
))}
{/* Family rail */}
setFam('all')}>
All families {CATALOG.length}
{SYMBOL_FAMILIES.filter(f => f.k !== 'all' && families[f.k]).map(f => (
setFam(f.k)}>
{f.l} {families[f.k]}
))}
{/* Results */}
{filtered.length === 0 ? (
) : (
filtered.map(c => (
onPick({ ...c, used: 0, updated: new Date().toISOString().slice(0,10) })}>
{c.pn}
{c.name}
{c.mfr}
·
{c.pins} p · {c.gender}
{c.sealed && <>· sealed >}
Add
))
)}
);
};
/* ----- Path B: Build custom ----- */
const CustomBuilder = ({ onCreate, onCancel }) => {
const [family, setFamily] = useState('circular');
const [name, setName] = useState('');
const [mfr, setMfr] = useState('');
const [pn, setPn] = useState('');
const [gender, setGender] = useState('plug');
const [shell, setShell] = useState('');
const [contact, setContact] = useState('crimp');
const [awg, setAwg] = useState('22-24');
const [sealed, setSealed] = useState(true);
// Parametric layout — every dimension is a NUMBER, not a drawing op.
// The preview redraws to-scale from these so the user can dial in the
// actual connector geometry without dragging vectors.
const [dims, setDims] = useState({
rows: 2, cols: 4,
pitch: 5.08, // pin center-to-center, in mm (0.2" default)
shellShape: 'rect', // 'rect' | 'circle' — outline
shellW: 0, // 0 = auto-size from grid + margin
shellH: 0,
corner: 4,
autoShell: true, // recompute shell W/H from grid each tick
});
const setDim = (k, v) => setDims(d => ({ ...d, [k]: v }));
const pinCount = dims.rows * dims.cols;
const [showAdvanced, setShowAdvanced] = useState(false);
// Pinout rows — one per pin. Auto-grow/shrink to pin count.
const [pins, setPins] = useState(() => Array.from({ length: 8 }, (_, i) => ({
label: String(i + 1), signal: '', awg: '', notes: '',
})));
useEffect(() => {
setPins(prev => {
const n = pinCount;
if (prev.length === n) return prev;
if (n > prev.length) {
return [...prev, ...Array.from({ length: n - prev.length }, (_, i) => ({
label: String(prev.length + i + 1), signal: '', awg: '', notes: '',
}))];
}
return prev.slice(0, n);
});
}, [pinCount]);
const updatePin = (i, key, val) => setPins(p => p.map((pin, j) => j === i ? { ...pin, [key]: val } : pin));
const renumber = () => setPins(p => p.map((pin, i) => ({ ...pin, label: String(i + 1) })));
const alphabetize = () => setPins(p => p.map((pin, i) => ({ ...pin, label: String.fromCharCode(65 + i) })));
// Auto-compute shell when in auto mode.
const effectiveDims = useMemo(() => {
if (!dims.autoShell) return dims;
const margin = dims.pitch * 1.6;
const gridW = (dims.cols - 1) * dims.pitch;
const gridH = (dims.rows - 1) * dims.pitch;
// Shapes that need a bounding-square to keep pins inside.
const enclosing = ['circle','hex','diamond','triangle','cross','arrow'];
if (enclosing.includes(dims.shellShape)) {
const diam = Math.hypot(gridW, gridH) + margin * 2;
// Triangle needs extra vertical headroom to fit pins inside the
// tapered top; arrow needs horizontal headroom for its tip.
const w = dims.shellShape === 'arrow' ? diam * 1.4 : diam;
const h = dims.shellShape === 'triangle' ? diam * 1.4 : diam;
return { ...dims, shellW: w, shellH: h };
}
// Rectangular + dshape: snug fit.
return {
...dims,
shellW: gridW + margin * 2 + (dims.shellShape === 'dshape' ? margin : 0),
shellH: gridH + margin * 2,
};
}, [dims]);
const canSubmit = name.trim() && pinCount > 0;
const preview = {
family, name: name || pn || 'New connector',
pins: pinCount, gender, shell,
dims: effectiveDims,
tags: [],
};
return (
<>
{/* LEFT — identity & shape */}
Outline & pin grid (parametric)
Family
setFamily(e.target.value)}>
{SYMBOL_FAMILIES.filter(f => f.k !== 'all').map(f => {f.l} )}
Shell shape
{[
{ k:'rect', l:'Rect' },
{ k:'circle', l:'Round' },
{ k:'hex', l:'Hex' },
{ k:'diamond', l:'Diamond' },
{ k:'triangle', l:'Tri' },
{ k:'dshape', l:'D-Sub' },
{ k:'arrow', l:'Arrow' },
{ k:'cross', l:'Cross' },
].map(s => (
setDim('shellShape', s.k)}
title={s.l}>
{s.l}
))}
{/* Pin grid — rows × cols. Pin count is computed from these. */}
{/* Outline dimensions */}
Termination
Contact type
{[['crimp','Crimp'],['solder','Solder'],['idc','IDC'],['screw','Screw']].map(([k, l]) => (
setContact(k)}>{l}
))}
Wire gauge range
setAwg(e.target.value)}>
28-32 24-28 22-26
22-24 20-24 18-22
16-22 16-20 14-20 14-18 12-16
setSealed(e.target.checked)}/>
Environmentally sealed (IP67+)
{/* Power-user escape hatch — hidden by default, opt-in. */}
setShowAdvanced(s => !s)}>
Advanced shape (SVG / outline override)
{showAdvanced && (
Custom outline (SVG path)
Optional — only if your connector has a non-standard body shape. Skip unless you really need it.
)}
{/* RIGHT — pinout table + live preview */}
Signal names + AWG export to the wire-prep CSV. Empty is fine — fill them in later as the harness develops.
Live preview
{preview.name}
{preview.pins}p
{gender !== '—' && {gender} }
{sealed && sealed }
{SYMBOL_FAMILIES.find(f => f.k === family)?.l}
{contact} · {awg} AWG
Cancel
onCreate({
family, name: name.trim(), mfr, pn, pins: pinCount, gender, shell, contact, awg, sealed,
dims: effectiveDims, pinout: pins,
tags: [contact, sealed ? 'sealed' : 'unsealed'].filter(Boolean),
})}>
Add to library
>
);
};
/* ----- Symbol importer -----
Lets the user paste a CSV / DXF reference or pick a file to import
multiple symbols at once. Demo: shows three "matched" rows the user
can confirm. */
/* ----- Path C: From image -----
Drop a JPG/PNG of a connector face. The user marks pin centers by
clicking on the preview, picks the outline shape, and the symbol is
reconstructed parametrically. Real auto-detect would need OpenCV in
the backend; here we provide the manual mark-up workflow that drives
the same parametric model used by Build custom. */
const ImageBuilder = ({ onCreate, onCancel }) => {
const toast = useToast();
const [imgUrl, setImgUrl] = useState(null);
const [imgName, setImgName] = useState('');
const [pins, setPins] = useState([]); // {x,y in 0..1 normalised}
const [shape, setShape] = useState('circle');
const [scaleMm, setScaleMm] = useState(30); // assumed real width of the image
const [name, setName] = useState('');
const [mfr, setMfr] = useState('');
const [pn, setPn] = useState('');
const [gender, setGender] = useState('plug');
const [opacity, setOpacity] = useState(0.55);
const imgRef = useRef(null);
const handleFile = (file) => {
if (!file) return;
if (!file.type.startsWith('image/')) { toast.warn('Only JPG / PNG files'); return; }
const url = URL.createObjectURL(file);
setImgUrl(url);
setImgName(file.name);
setPins([]);
toast.ok('Image loaded', file.name);
};
const onDrop = (e) => { e.preventDefault(); handleFile(e.dataTransfer.files?.[0]); };
const onPick = (e) => handleFile(e.target.files?.[0]);
const addPin = (e) => {
if (!imgRef.current) return;
const r = imgRef.current.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width;
const y = (e.clientY - r.top) / r.height;
setPins(p => [...p, { x, y, label: String(p.length + 1) }]);
};
const removePin = (i, e) => { e.stopPropagation(); setPins(p => p.filter((_, j) => j !== i)); };
// Build a parametric dims object from the marked pins so the resulting
// symbol renders using the same engine as Build custom. We use a 1×N
// fake grid and store the actual normalised positions in pins[] for
// the preview card. (For the saved symbol, we approximate with the
// bounding box dims so it shows up properly in the library.)
const computed = useMemo(() => {
if (pins.length === 0) return null;
const xs = pins.map(p => p.x);
const ys = pins.map(p => p.y);
const minX = Math.min(...xs), maxX = Math.max(...xs);
const minY = Math.min(...ys), maxY = Math.max(...ys);
const wMm = scaleMm; // image full width in mm
const hMm = wMm * (maxY > minY ? 1 : 1); // assume square aspect for now
return {
bbox: { minX, maxX, minY, maxY },
bboxMm: {
w: (maxX - minX) * wMm,
h: (maxY - minY) * wMm,
},
shellMm: shape === 'circle' ? wMm : wMm,
};
}, [pins, scaleMm, shape]);
const canSubmit = imgUrl && name.trim() && pins.length > 0;
return (
<>
{/* LEFT — image + pin marking */}
{!imgUrl ? (
e.preventDefault()}
onDrop={onDrop}>
Drop a connector photo here
JPG, PNG, or WebP. Front-on view of the pin face works best.
Browse files
) : (
<>
{/* Outline guide */}
{pins.length > 1 && (
{(() => {
if (shape === 'rect' || shape === 'dshape') {
const xs = pins.map(p => p.x * 100), ys = pins.map(p => p.y * 100);
const pad = 8;
const x = Math.min(...xs) - pad, y = Math.min(...ys) - pad;
const w = Math.max(...xs) - Math.min(...xs) + pad * 2;
const h = Math.max(...ys) - Math.min(...ys) + pad * 2;
return ;
}
if (shape === 'circle') {
const cx = pins.reduce((a, p) => a + p.x, 0) / pins.length * 100;
const cy = pins.reduce((a, p) => a + p.y, 0) / pins.length * 100;
const r = Math.max(...pins.map(p => Math.hypot(p.x * 100 - cx, p.y * 100 - cy))) + 10;
return ;
}
return null;
})()}
)}
{/* Pin markers */}
{pins.map((p, i) => (
{p.label}
removePin(i, e)} aria-label={`Remove pin ${p.label}`}>×
))}
>
)}
{/* RIGHT — config + preview */}
Outline & scale
Detected outline
{[
{ k:'rect', l:'Rect' },
{ k:'circle', l:'Round' },
{ k:'hex', l:'Hex' },
{ k:'dshape', l:'D-Sub' },
].map(s => (
setShape(s.k)}>
{s.l}
))}
Gender
{[['plug','Plug'],['recep','Recep'],['—','N/A']].map(([k, l]) => (
setGender(k)}>{l}
))}
Pins detected
{pins.length === 0 ? (
Click on the photo to mark pin centers. The order you click sets the pin numbers (1, 2, 3…).
) : (
{pins.map((p, i) => (
{p.label}
({(p.x * 100).toFixed(0)}%, {(p.y * 100).toFixed(0)}%)
setPins(pp => pp.filter((_, j) => j !== i))} aria-label={`Delete pin ${p.label}`}>×
))}
)}
Cancel
{
// Approximate a 1-row grid for now; pin positions are stored
// for a future "free-form" renderer. The symbol still shows up
// in the library with the chosen shape and pin count.
onCreate({
family: shape === 'circle' ? 'circular' : shape === 'dshape' ? 'dsub' : 'custom',
name: name.trim(), mfr, pn, pins: pins.length, gender, shell: '—',
dims: { rows: 1, cols: Math.max(1, pins.length), pitch: scaleMm / Math.max(1, pins.length),
shellShape: shape, shellW: scaleMm, shellH: scaleMm, corner: 4, autoShell: false },
sourceImage: imgUrl, sourcePins: pins,
tags: ['from-image', 'custom'],
});
}}>
Save as new symbol
>
);
};
const SymbolImporter = ({ onClose, onImport }) => {
const [source, setSource] = useState('csv');
const demoMatches = [
{ id:'i1', family:'deutsch', name:'DT06-12S', pins:12, gender:'recep', shell:'—', tags:['imported'], pick:true },
{ id:'i2', family:'circular', name:'MS3475 · 14-pin', pins:14, gender:'plug', shell:'B', tags:['imported'], pick:true },
{ id:'i3', family:'molex', name:'MX150 · 12-pos', pins:12, gender:'plug', shell:'—', tags:['imported'], pick:false },
];
const [matches, setMatches] = useState(demoMatches);
const toggle = (id) => setMatches(m => m.map(r => r.id === id ? { ...r, pick: !r.pick } : r));
const picked = matches.filter(m => m.pick);
return (
e.stopPropagation()}>
Import symbols
Bring in symbols from a CSV file, KiCad library, or vendor catalogue.
setSource('csv')}>CSV
setSource('kicad')}>KiCad .lib
setSource('vendor')}>Vendor catalogue
Drop a {source.toUpperCase()} file here
or paste {source === 'csv' ? 'rows like: family,name,pins,gender,shell' : source === 'kicad' ? 'a KiCad symbol library' : 'a Deutsch/AMP/TE part number'}
Browse files
Matched symbols · {picked.length}/{matches.length} selected
{matches.map(m => (
toggle(m.id)}/>
{m.name}
{SYMBOL_FAMILIES.find(f => f.k === m.family)?.l} · {m.pins} pins · {m.gender}
imported
))}
Cancel
onImport(picked.map((p, i) => ({ ...p, id: `imp${Date.now()}-${i}`, used: 0, updated: new Date().toISOString().slice(0,10) })))}>
Add {picked.length} to library
);
};
/* ===================================================================
NOTES & REVISIONS VIEW
Two stacked sections:
- Design notes — general / electrical / production groups. Each note
has a number printed on the drawing, a category, and an optional
zone reference (where on the drawing the callout sits).
- Revision history — printed in the title-block revision triangle.
ECO ref, zone, description, approver, date.
=================================================================== */
const DEFAULT_NOTES = [
{ id:'n1', num:'1', cat:'general', body:'All dimensions in millimetres. Tolerances per ISO 2768-mK unless otherwise specified.', zone:'A1', author:'L. Kohli', date:'2026-05-12' },
{ id:'n2', num:'2', cat:'general', body:'Bend radii ≥ 10× conductor diameter on all flexed bundles per AS50881.', zone:'A2', author:'L. Kohli', date:'2026-05-12' },
{ id:'n3', num:'3', cat:'general', body:'Bundle wrap: spiral wrap 50% overlap, 25 mm pitch.', zone:'B1', author:'R. Bedi', date:'2026-05-14' },
{ id:'n4', num:'4', cat:'electrical', body:'Insulate all shields with heat-shrink HS-200 at termination points.', zone:'C3', author:'L. Kohli', date:'2026-05-15' },
{ id:'n5', num:'5', cat:'electrical', body:'24 AWG twisted pairs: 4 turns per inch minimum. Differential signals: route together.', zone:'D2', author:'L. Kohli', date:'2026-05-15' },
{ id:'n6', num:'6', cat:'electrical', body:'Shield drain wires terminate to chassis at J1 only. Do NOT terminate at J3.2.', zone:'D4', author:'R. Bedi', date:'2026-05-18' },
{ id:'n7', num:'7', cat:'production', body:'Apply Kapton tape to all backshell-to-jacket transitions before strain relief.', zone:'E1', author:'L. Kohli', date:'2026-05-20' },
{ id:'n8', num:'8', cat:'production', body:'Crimp validation: pull-test 1 in 25 per MIL-PRF-22520/7. Log results in QA-187.', zone:'E2', author:'S. Okonkwo',date:'2026-05-21' },
];
const DEFAULT_REVS = [
{ id:'r1', rev:'-', date:'2026-04-08', eco:'ECO-0184', zone:'—', desc:'Initial release.', by:'L. Kohli', approved:'M. Adler', status:'released' },
{ id:'r2', rev:'A', date:'2026-04-22', eco:'ECO-0207', zone:'D3', desc:'Re-route W-014, W-015 to clear fuel-line clamp at FS22.', by:'L. Kohli', approved:'R. Bedi', status:'released' },
{ id:'r3', rev:'B', date:'2026-05-12', eco:'ECO-0231', zone:'C3', desc:'Add shield drain on differential pair to fix CAN bus error rate.', by:'L. Kohli', approved:'R. Bedi', status:'released' },
{ id:'r4', rev:'B', date:'2026-05-12', eco:'ECO-0231', zone:'A2', desc:'Update bend-radius note to AS50881 §3.5.2.', by:'L. Kohli', approved:'R. Bedi', status:'released' },
{ id:'r5', rev:'C', date:'2026-05-22', eco:'ECO-0258', zone:'E1', desc:'Switch backshell from EN3155-008 to -012 at J3.2 (vendor change).', by:'L. Kohli', approved:'—', status:'pending' },
];
const CAT_META = {
general: { label:'General', tone:'info', color:'var(--info)' },
electrical: { label:'Electrical', tone:'violet', color:'var(--violet)' },
production: { label:'Production', tone:'warning', color:'var(--warning)' },
};
const NotesView = ({ project }) => {
const toast = useToast();
const [tab, setTab] = useState('notes');
const [notes, setNotes] = useState(DEFAULT_NOTES);
const [revs, setRevs] = useState(DEFAULT_REVS);
const [catFilter, setCatFilter] = useState('all');
const [q, setQ] = useState('');
const [editing, setEditing] = useState(null); // {mode, item} | null
const filtered = useMemo(() => {
let list = notes;
if (catFilter !== 'all') list = list.filter(n => n.cat === catFilter);
const needle = q.trim().toLowerCase();
if (needle) list = list.filter(n => (n.body + ' ' + n.zone).toLowerCase().includes(needle));
return list;
}, [notes, catFilter, q]);
const grouped = useMemo(() => {
const out = { general:[], electrical:[], production:[] };
filtered.forEach(n => out[n.cat].push(n));
return out;
}, [filtered]);
const counts = useMemo(() => ({
all: notes.length,
general: notes.filter(n => n.cat === 'general').length,
electrical: notes.filter(n => n.cat === 'electrical').length,
production: notes.filter(n => n.cat === 'production').length,
}), [notes]);
const removeNote = (id) => { setNotes(ns => ns.filter(n => n.id !== id)); toast.warn('Note removed'); };
const saveNote = (next) => {
setNotes(ns => {
if (next.id) return ns.map(n => n.id === next.id ? next : n);
const nextNum = String(Math.max(...ns.map(n => +n.num || 0)) + 1);
return [...ns, { ...next, id: `n${Date.now()}`, num: nextNum, author:'L. Kohli', date: new Date().toISOString().slice(0,10) }];
});
setEditing(null);
toast.ok(next.id ? 'Note updated' : `Note ${next.num || ''} added`);
};
return (
Drawing notes and revision history for {project.name} . Notes are numbered and printed on the drawing; revisions populate the title-block revision triangle.}
actions={
<>
{tab === 'notes' && setEditing({ mode:'create', item: { cat:'general', body:'', zone:'' } })}>Add note }
{tab === 'revs' && New revision }
>
}/>
setTab('notes')}>
Notes {notes.length}
setTab('revs')}>
Revisions {revs.length}
{tab === 'notes' && (
<>
setQ(e.target.value)} placeholder="Search notes or zone…" aria-label="Search notes"/>
{[
{ k:'all', l:'All', tone:null },
{ k:'general', l:'General', tone:'info' },
{ k:'electrical', l:'Electrical', tone:'violet' },
{ k:'production', l:'Production', tone:'warning' },
].map(c => (
setCatFilter(c.k)}>
{c.tone && }
{c.l}
{counts[c.k]}
))}
{filtered.length === 0 ? (
{ setQ(''); setCatFilter('all'); }}>Clear filters}/>
) : (
{['general','electrical','production'].map(cat => {
if (!grouped[cat].length) return null;
const m = CAT_META[cat];
return (
{m.label} notes
{grouped[cat].length} entries
{grouped[cat].map(n => (
{n.num}
{n.body}
Zone {n.zone}
·
{n.author}
·
{n.date}
setEditing({ mode:'edit', item: n })}/>
removeNote(n.id)}/>
))}
);
})}
)}
>
)}
{tab === 'revs' && (
Rev
Date
ECO ref
Zone
Description
Drawn by
Approved
Status
{revs.map(r => (
{r.rev}
{r.date}
{r.eco}
{r.zone}
{r.desc}
{r.by}
{r.approved}
{r.status}
))}
Revisions in pending are saved as drafts but won't print on the drawing until they're approved.
)}
{editing && (
setEditing(null)}
onSave={saveNote}/>
)}
);
};
const NoteEditor = ({ item, mode, onClose, onSave }) => {
const [cat, setCat] = useState(item.cat);
const [body, setBody] = useState(item.body);
const [zone, setZone] = useState(item.zone || '');
const canSave = body.trim().length > 0;
return (
e.stopPropagation()}>
{mode === 'edit' ? `Edit note ${item.num}` : 'Add note'}
{mode === 'edit' ? 'Updating the note will renumber other notes only if its category changes.' : 'Notes are auto-numbered within their category and printed on the drawing.'}
Category
{['general','electrical','production'].map(c => (
setCat(c)}>
{CAT_META[c].label}
))}
Cancel
onSave({ ...item, cat, body: body.trim(), zone: zone.trim() })}>
{mode === 'edit' ? 'Save changes' : 'Add note'}
);
};
/* ---------- Welcome / no-project (Empty) ---------- */
const WelcomeView = ({ onPickProject, onImportCsv }) => (
KabelFlux
e-Harness Suite · v5
Browse projects}
secondary={Import CSV }/>
Press ⌘ K any time to search across projects, wires and connectors.
);
/* ---------- Projects browser (full page) ----------
Replaces the old in-sidebar list. Filter + sort + status chips along the
top, then a responsive card grid. Pinned projects always render first. */
const ProjectsView = ({ projects, currentId, onOpen, onCreateNew, onImportCsv }) => {
const [q, setQ] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [sort, setSort] = useState('recent');
const counts = useMemo(() => ({
all: projects.length,
active: projects.filter(p => p.status === 'active').length,
review: projects.filter(p => p.status === 'review').length,
released: projects.filter(p => p.status === 'released').length,
draft: projects.filter(p => p.status === 'draft').length,
}), [projects]);
const filtered = useMemo(() => {
let list = projects.slice();
const needle = q.trim().toLowerCase();
if (needle) list = list.filter(p => (p.name + ' ' + p.code + ' ' + p.owner).toLowerCase().includes(needle));
if (statusFilter !== 'all') list = list.filter(p => p.status === statusFilter);
// sort
if (sort === 'name') list.sort((a, b) => a.name.localeCompare(b.name));
if (sort === 'wires') list.sort((a, b) => b.wires - a.wires);
if (sort === 'recent') {/* preserve incoming order */}
// pinned first
list.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0));
return list;
}, [projects, q, statusFilter, sort]);
return (
Workspace
Projects
{filtered.length} of {projects.length} harnesses · Last sync 4s ago
Import CSV
New project
setQ(e.target.value)}
placeholder="Search by name, part number, owner…"
aria-label="Search projects"/>
{q && setQ('')} aria-label="Clear search"> }
{[
{ k: 'all', l: 'All' },
{ k: 'active', l: 'Active' },
{ k: 'review', l: 'In review' },
{ k: 'released', l: 'Released' },
{ k: 'draft', l: 'Draft' },
].map(s => (
setStatusFilter(s.k)}>
{s.k !== 'all' && }
{s.l}
{counts[s.k]}
))}
Sort
setSort(e.target.value)}>
Recently modified
Name (A–Z)
Wire count
{filtered.length === 0 ? (
) : (
{filtered.map(p => (
onOpen(p.id)}>
{p.status}
Rev {p.revision}
{p.pinned && }
{p.name}
{p.code}
{p.owner}
{p.modified}
))}
)}
);
};
const KabelFluxMark = ({ size = 32 }) => (
);
const STYLE = `
/* — KabelFlux logo mark —
Uses the alpha channel of the PNG as a mask so the visible color is
driven by --accent (which retunes per theme). Drop the mask and switch
to background-image to show the original blue artwork. */
.kf-mark {
display: inline-block;
background-color: var(--accent);
-webkit-mask: url("assets/kableflux-mark.png") center/contain no-repeat;
mask: url("assets/kableflux-mark.png") center/contain no-repeat;
flex-shrink: 0;
transition: background-color .18s var(--ease);
}
/* — Notes & Revisions — */
.kf-notes { padding: 0; display: flex; flex-direction: column; overflow: hidden; }
.kf-notes .kf-view-head { padding: 22px 24px 14px; }
.kf-notes-tabs {
display: flex; gap: 4px; padding: 0 24px;
border-bottom: 1px solid var(--line);
margin-bottom: 16px;
}
.kf-notes-tab {
padding: 10px 4px; margin-right: 18px;
background: transparent; border: none;
border-bottom: 2px solid transparent;
color: var(--t-3);
font-family: inherit; font-size: 13px; font-weight: 600;
cursor: pointer;
display: inline-flex; align-items: center; gap: 8px;
transition: color .12s var(--ease), border-color .12s var(--ease);
}
.kf-notes-tab:hover { color: var(--t-1); }
.kf-notes-tab.on { color: var(--t-1); border-bottom-color: var(--accent); }
.kf-notes-tab-n { font-family: var(--f-mono); font-size: 10.5px; padding: 1px 7px; background: var(--bg-3); color: var(--t-3); border-radius: 999px; }
.kf-notes-tab.on .kf-notes-tab-n { background: var(--accent-soft); color: var(--accent); }
.kf-notes-toolbar { display: flex; align-items: center; gap: 14px; padding: 0 24px 14px; flex-wrap: wrap; }
.kf-notes-chips { display: flex; gap: 4px; flex-wrap: wrap; }
.kf-notes-chip {
display: inline-flex; align-items: center; gap: 6px;
height: 28px; padding: 0 10px;
background: transparent;
border: 1px solid transparent;
color: var(--t-2);
font-family: inherit; font-size: 12px; font-weight: 500;
border-radius: var(--r-2); cursor: pointer;
transition: background .12s var(--ease), color .12s var(--ease), border-color .12s var(--ease);
}
.kf-notes-chip:hover:not(.on) { background: var(--bg-3); color: var(--t-1); }
.kf-notes-chip.on { background: var(--bg-2); border-color: var(--line-strong); color: var(--t-1); }
.kf-notes-chip-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; }
.kf-notes-chip-dot-info { background: var(--info); }
.kf-notes-chip-dot-violet { background: var(--violet); }
.kf-notes-chip-dot-warning { background: var(--warning); }
.kf-notes-groups { padding: 0 24px 28px; overflow-y: auto; }
.kf-notes-group { margin-bottom: 26px; }
.kf-notes-group-head {
display: flex; align-items: center; gap: 10px;
padding-bottom: 10px;
border-bottom: 1px dashed var(--line);
margin-bottom: 10px;
}
.kf-notes-group-head h3 { font-family: var(--f-display); font-size: 15px; font-weight: 600; color: var(--t-1); margin: 0; }
.kf-notes-group-head .kf-admin-muted { margin-left: auto; font-family: var(--f-mono); font-size: 11px; }
.kf-notes-list { display: flex; flex-direction: column; gap: 8px; }
.kf-notes-row {
display: grid; grid-template-columns: 36px 1fr auto;
gap: 14px; align-items: flex-start;
padding: 12px 14px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-3);
transition: background .12s var(--ease), border-color .12s var(--ease);
}
.kf-notes-row:hover { background: var(--bg-3); border-color: var(--line-strong); }
.kf-notes-num {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px;
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: 50%;
font-family: var(--f-mono); font-size: 11.5px; font-weight: 700;
color: var(--t-1);
}
.kf-notes-text { font-size: 13px; color: var(--t-1); line-height: 1.55; }
.kf-notes-meta {
display: flex; gap: 6px;
margin-top: 6px;
font-size: 11px; color: var(--t-3);
}
.kf-notes-meta .num { font-family: var(--f-mono); color: var(--t-2); font-weight: 500; }
.kf-notes-actions { display: flex; gap: 2px; opacity: 0; transition: opacity .12s var(--ease); }
.kf-notes-row:hover .kf-notes-actions { opacity: 1; }
/* Revisions table */
.kf-notes-revtable { padding: 0 24px 28px; overflow-y: auto; }
.kf-rev-triangle {
display: inline-flex; align-items: center; justify-content: center;
width: 26px; height: 22px;
background: var(--accent-soft);
color: var(--accent);
font-family: var(--f-mono); font-size: 11px; font-weight: 700;
clip-path: polygon(50% 0%, 100% 100%, 0% 100%);
padding-top: 6px;
}
.kf-notes-revfoot {
display: flex; align-items: center; gap: 8px;
font-size: 12px; color: var(--t-3);
padding: 14px 24px 0;
}
@media (max-width: 720px) {
.kf-notes-row { grid-template-columns: 28px 1fr; }
.kf-notes-actions { grid-column: 2; opacity: 1; }
}
/* — Symbol creator v2 (catalog-first + custom build) — */
.kf-symmodal-wide { width: 1080px; max-height: 92vh; }
.kf-sym-tabs {
display: flex; gap: 4px;
padding: 8px 18px 0;
border-bottom: 1px solid var(--line);
}
.kf-sym-tab {
display: inline-flex; align-items: center; gap: 8px;
padding: 10px 14px;
background: transparent;
border: none; border-bottom: 2px solid transparent;
color: var(--t-3);
font-family: inherit; font-size: 13px; font-weight: 600;
cursor: pointer;
margin-bottom: -1px;
transition: color .12s var(--ease), border-color .12s var(--ease);
}
.kf-sym-tab:hover { color: var(--t-1); }
.kf-sym-tab.on { color: var(--t-1); border-bottom-color: var(--accent); }
.kf-sym-tab svg { stroke: currentColor; }
/* CATALOG MODE */
.kf-cat-pick { padding: 0; display: flex; flex-direction: column; overflow: hidden; flex: 1; }
.kf-cat-filters { display: flex; gap: 12px; padding: 14px 18px; border-bottom: 1px solid var(--line); align-items: center; }
.kf-cat-layout { display: grid; grid-template-columns: 200px 1fr; flex: 1; overflow: hidden; }
.kf-cat-rail {
border-right: 1px solid var(--line);
padding: 8px 6px;
display: flex; flex-direction: column; gap: 1px;
overflow-y: auto;
background: var(--bg-1);
}
.kf-cat-rail-item {
display: flex; justify-content: space-between; align-items: center; gap: 8px;
padding: 8px 10px;
background: transparent; border: none;
border-radius: var(--r-2);
color: var(--t-2);
font-family: inherit; font-size: 12.5px; font-weight: 500;
cursor: pointer; text-align: left;
transition: background .12s var(--ease), color .12s var(--ease);
}
.kf-cat-rail-item:hover { background: var(--bg-3); color: var(--t-1); }
.kf-cat-rail-item.on { background: var(--accent-soft); color: var(--accent); }
.kf-cat-rail-item .num { font-family: var(--f-mono); font-size: 10.5px; color: var(--t-3); }
.kf-cat-rail-item.on .num { color: var(--accent); }
.kf-cat-results { padding: 12px 14px; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; }
.kf-cat-card {
display: grid; grid-template-columns: 60px 1fr auto;
gap: 14px; align-items: center;
padding: 10px 14px;
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-2);
cursor: pointer; text-align: left;
font-family: inherit;
transition: background .12s var(--ease), border-color .12s var(--ease), transform .1s var(--ease);
}
.kf-cat-card:hover { background: var(--bg-3); border-color: var(--accent); transform: translateX(2px); }
.kf-cat-card-glyph {
width: 56px; height: 38px;
display: flex; align-items: center; justify-content: center;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-2);
}
.kf-cat-card-glyph svg { width: 50px; height: 32px; }
.kf-cat-card-pn { font-family: var(--f-mono); font-size: 12px; color: var(--accent); font-weight: 600; }
.kf-cat-card-name { font-size: 13px; font-weight: 600; color: var(--t-1); margin: 2px 0; }
.kf-cat-card-meta { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--t-3); }
.kf-cat-card-meta .num { font-family: var(--f-mono); color: var(--t-2); font-weight: 500; }
/* CUSTOM BUILD MODE */
.kf-build { padding: 0; overflow: hidden; flex: 1; }
.kf-build-grid {
display: grid; grid-template-columns: 1fr 1fr;
gap: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
.kf-build-col {
padding: 16px 18px;
overflow-y: auto;
display: flex; flex-direction: column; gap: 18px;
}
.kf-build-col + .kf-build-col { border-left: 1px solid var(--line); background: var(--bg-1); }
.kf-build-section {}
.kf-build-section-head {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
font-family: var(--f-mono); font-size: 10.5px; font-weight: 600;
color: var(--t-3); text-transform: uppercase; letter-spacing: .08em;
margin-bottom: 10px;
padding-bottom: 6px;
border-bottom: 1px dashed var(--line);
}
.kf-build-section-actions { display: flex; gap: 4px; }
.kf-build-advanced-toggle {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 10px;
background: transparent; border: 1px dashed var(--line);
border-radius: var(--r-2);
color: var(--t-3); font-family: inherit; font-size: 12px; font-weight: 500;
cursor: pointer;
transition: color .12s var(--ease), border-color .12s var(--ease);
}
.kf-build-advanced-toggle:hover { color: var(--t-1); border-color: var(--line-strong); }
.kf-build-advanced { margin-top: 10px; }
/* Shape picker grid — replaces the binary Rect/Round segment.
Each tile shows a small live-rendered preview of that shell shape. */
.kf-shape-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
}
.kf-shape-grid-compact { grid-template-columns: repeat(4, 1fr); }
.kf-shape-tile {
display: flex; flex-direction: column; align-items: center; gap: 4px;
padding: 8px 6px 6px;
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-2);
cursor: pointer;
font-family: inherit; font-size: 11px; font-weight: 500;
color: var(--t-3);
transition: background .12s var(--ease), border-color .12s var(--ease), color .12s var(--ease);
}
.kf-shape-tile:hover { background: var(--bg-3); border-color: var(--line-strong); color: var(--t-1); }
.kf-shape-tile.on { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
.kf-shape-tile svg { width: 36px; height: 28px; pointer-events: none; }
/* From-image builder */
.kf-img-build { padding: 0; overflow: hidden; flex: 1; }
.kf-img-grid {
display: grid; grid-template-columns: 1fr 380px;
gap: 0; height: 100%; overflow: hidden;
}
.kf-img-pane {
display: flex; flex-direction: column;
padding: 14px 16px;
background: var(--bg-1);
border-right: 1px solid var(--line);
overflow: hidden;
}
.kf-img-drop {
flex: 1;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
padding: 32px;
background: var(--bg-2);
border: 2px dashed var(--line-strong);
border-radius: var(--r-3);
text-align: center;
}
.kf-img-drop-title { font-size: 15px; font-weight: 600; color: var(--t-1); margin-top: 6px; }
.kf-img-drop-hint { font-size: 12px; color: var(--t-3); }
.kf-img-canvas-wrap { flex: 1; display: flex; align-items: center; justify-content: center; overflow: hidden; background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--r-3); }
.kf-img-canvas {
position: relative;
display: inline-block;
max-width: 100%; max-height: 100%;
cursor: crosshair;
}
.kf-img-canvas img { display: block; max-width: 100%; max-height: 100%; transition: opacity .15s var(--ease); user-select: none; }
.kf-img-outline { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.kf-img-pin {
position: absolute;
transform: translate(-50%, -50%);
display: flex; align-items: center; gap: 4px;
pointer-events: none;
}
.kf-img-pin-dot { display: block; width: 12px; height: 12px; border-radius: 50%; background: var(--accent); border: 2px solid #fff; box-shadow: 0 1px 3px rgba(0,0,0,.4); flex-shrink: 0; }
.kf-img-pin-dot-row { width: 10px; height: 10px; }
.kf-img-pin-label {
font-family: var(--f-mono); font-size: 10px; font-weight: 700; color: #fff;
background: var(--accent);
padding: 1px 5px; border-radius: 4px;
box-shadow: 0 1px 2px rgba(0,0,0,.4);
}
.kf-img-pin-rm {
pointer-events: auto;
width: 16px; height: 16px;
display: flex; align-items: center; justify-content: center;
background: var(--danger); color: #fff;
border: 1.5px solid #fff;
border-radius: 50%;
font-size: 11px; font-weight: 700; line-height: 1;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0,0,0,.4);
margin-left: 2px;
}
.kf-img-canvas-bar {
display: flex; align-items: center; gap: 10px;
padding-top: 10px;
}
.kf-img-empty {
display: flex; align-items: center; gap: 8px;
padding: 12px;
background: var(--bg-1); border: 1px dashed var(--line);
border-radius: var(--r-2);
font-size: 12px; color: var(--t-3); line-height: 1.4;
}
.kf-img-pin-list { display: flex; flex-direction: column; gap: 4px; max-height: 280px; overflow-y: auto; }
.kf-img-pin-row {
display: flex; align-items: center; gap: 8px;
padding: 6px 8px;
background: var(--bg-1); border: 1px solid var(--line-soft);
border-radius: var(--r-2);
}
.kf-img-pin-row-label { font-family: var(--f-mono); font-size: 11px; font-weight: 700; color: var(--t-1); }
.kf-img-pin-row-rm {
width: 22px; height: 22px;
display: flex; align-items: center; justify-content: center;
background: transparent; border: none;
color: var(--t-3); font-size: 16px; font-weight: 600; line-height: 1;
cursor: pointer; border-radius: 4px;
}
.kf-img-pin-row-rm:hover { background: var(--bg-3); color: var(--danger); }
@media (max-width: 720px) {
.kf-img-grid { grid-template-columns: 1fr; }
.kf-img-pane { border-right: none; border-bottom: 1px solid var(--line); }
}
/* Parametric dimension grid */
.kf-dim-grid {
display: flex; align-items: center; gap: 8px;
}
.kf-dim-cell {
display: flex; flex-direction: column; align-items: center; gap: 2px;
}
.kf-dim-cell .kf-input { width: 64px; text-align: center; }
.kf-dim-cell-total {
margin-left: 12px;
padding: 0 10px;
border-left: 1px dashed var(--line);
}
.kf-dim-total { font-family: var(--f-mono); font-size: 18px; font-weight: 700; color: var(--t-1); }
.kf-dim-x { font-family: var(--f-mono); font-size: 14px; color: var(--t-3); padding-bottom: 16px; }
.kf-dim-l { font-size: 10px; color: var(--t-3); text-transform: uppercase; letter-spacing: .06em; font-family: var(--f-mono); }
/* Pinout table */
.kf-pinout-table {
border: 1px solid var(--line);
border-radius: var(--r-2);
overflow: hidden;
background: var(--bg-2);
}
.kf-pinout-head, .kf-pinout-row {
display: grid;
grid-template-columns: 60px 1.4fr 60px 1fr;
gap: 4px;
align-items: center;
}
.kf-pinout-head {
padding: 8px 8px;
font-family: var(--f-mono); font-size: 10px; font-weight: 600;
color: var(--t-3); text-transform: uppercase; letter-spacing: .06em;
background: var(--bg-1);
border-bottom: 1px solid var(--line);
}
.kf-pinout-rows { max-height: 320px; overflow-y: auto; }
.kf-pinout-row {
padding: 4px 8px;
border-bottom: 1px solid var(--line-soft);
}
.kf-pinout-row:last-child { border-bottom: none; }
.kf-pinout-row .kf-input-sm { height: 28px; padding: 0 8px; font-size: 12px; }
@media (max-width: 720px) {
.kf-symmodal-wide { width: 100vw; max-height: 100vh; }
.kf-cat-layout { grid-template-columns: 1fr; }
.kf-cat-rail { border-right: none; border-bottom: 1px solid var(--line); flex-direction: row; overflow-x: auto; }
.kf-build-grid { grid-template-columns: 1fr; }
.kf-build-col + .kf-build-col { border-left: none; border-top: 1px solid var(--line); }
}
/* — Symbol Library (workspace-level) — */
.kf-symmodal-bg {
position: fixed; inset: 0; z-index: 800;
background: rgba(7,9,15,.55); backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
padding: 24px;
}
.kf-symmodal {
background: var(--bg-2);
border: 1px solid var(--line-strong);
border-radius: var(--r-4);
box-shadow: 0 24px 80px -20px rgba(0,0,0,.55);
width: 560px; max-width: 100%; max-height: 90vh;
display: flex; flex-direction: column;
}
.kf-symmodal-wide { width: 920px; }
.kf-symmodal-head {
display: flex; align-items: flex-start; justify-content: space-between;
padding: 18px 22px 12px; gap: 18px;
border-bottom: 1px solid var(--line);
}
.kf-symmodal-head h2 { font-family: var(--f-display); font-size: 18px; font-weight: 600; color: var(--t-1); margin: 0 0 4px; letter-spacing: -.01em; }
.kf-symmodal-head p { font-size: 12.5px; color: var(--t-3); margin: 0; line-height: 1.5; }
.kf-symmodal-body { padding: 18px 22px; overflow-y: auto; flex: 1; }
.kf-symmodal-foot { display: flex; align-items: center; padding: 12px 22px; border-top: 1px solid var(--line); gap: 8px; }
/* Symbol creator — split layout: stepper+form on the left, live preview right */
.kf-sym-create { display: grid; grid-template-columns: 1fr 280px; gap: 22px; padding: 0; }
.kf-sym-create-form { display: flex; flex-direction: column; gap: 14px; }
.kf-sym-create-preview {
padding-left: 22px; border-left: 1px solid var(--line);
display: flex; flex-direction: column; gap: 12px;
}
.kf-sym-preview-label { font-family: var(--f-mono); font-size: 10.5px; font-weight: 600; color: var(--t-3); text-transform: uppercase; letter-spacing: .08em; }
.kf-symcard-preview-card { background: var(--bg-1); }
.kf-sym-preview-tags { display: flex; flex-wrap: wrap; gap: 4px; }
/* Stepper at the top of the creator */
.kf-sym-steps { display: flex; gap: 6px; border-bottom: 1px solid var(--line); padding-bottom: 12px; margin-bottom: 4px; }
.kf-sym-step {
flex: 1;
display: flex; align-items: center; gap: 8px;
padding: 8px 12px;
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-2);
color: var(--t-3);
font-family: inherit; font-size: 12px; font-weight: 500;
cursor: pointer;
transition: background .12s var(--ease), color .12s var(--ease);
}
.kf-sym-step .num { display: inline-flex; width: 20px; height: 20px; align-items: center; justify-content: center; background: var(--bg-3); border-radius: 50%; font-size: 10.5px; font-family: var(--f-mono); font-weight: 700; color: var(--t-2); }
.kf-sym-step:hover { color: var(--t-1); }
.kf-sym-step.on { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
.kf-sym-step.on .num { background: var(--accent); color: #fff; }
/* Family picker grid */
.kf-sym-grid-pick { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
.kf-sym-fam-card {
padding: 12px;
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-3);
cursor: pointer; text-align: center;
font-family: inherit;
transition: background .12s var(--ease), border-color .12s var(--ease);
}
.kf-sym-fam-card:hover { background: var(--bg-3); border-color: var(--line-strong); }
.kf-sym-fam-card.on { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.kf-sym-fam-card-glyph { height: 56px; display: flex; align-items: center; justify-content: center; }
.kf-sym-fam-card-glyph svg { width: 80px; height: 50px; }
.kf-sym-fam-card-name { font-size: 12px; font-weight: 600; color: var(--t-1); margin-top: 4px; }
.kf-sym-fam-card-count { font-size: 10.5px; color: var(--t-3); margin-top: 2px; }
/* Pin stepper */
.kf-pin-stepper { display: flex; gap: 4px; align-items: center; }
.kf-pin-stepper button {
width: 32px; height: 36px;
background: var(--bg-3); border: 1px solid var(--line); border-radius: var(--r-2);
color: var(--t-2); cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.kf-pin-stepper button:hover { background: var(--bg-4); color: var(--t-1); }
.kf-pin-stepper input { text-align: center; flex: 1; max-width: 80px; }
/* Segmented control */
.kf-seg {
display: inline-flex;
padding: 3px;
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-2);
gap: 2px;
}
.kf-seg button {
padding: 6px 12px;
background: transparent; border: none;
color: var(--t-2);
font-family: inherit; font-size: 12px; font-weight: 500;
border-radius: 4px; cursor: pointer;
transition: background .12s var(--ease), color .12s var(--ease);
}
.kf-seg button:hover { color: var(--t-1); }
.kf-seg button.on { background: var(--bg-3); color: var(--t-1); }
.kf-req { color: var(--danger); }
/* Importer matched rows */
.kf-sym-import-head { font-family: var(--f-mono); font-size: 11px; font-weight: 600; color: var(--t-3); text-transform: uppercase; letter-spacing: .06em; margin-bottom: 8px; }
.kf-sym-import-row {
display: flex; align-items: center; gap: 12px;
padding: 10px 12px;
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-2);
margin-bottom: 6px;
cursor: pointer;
transition: background .12s var(--ease), border-color .12s var(--ease);
}
.kf-sym-import-row:hover { background: var(--bg-3); }
.kf-sym-import-row.on { border-color: var(--accent); background: var(--accent-soft); }
.kf-sym-import-glyph { width: 56px; height: 36px; background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--r-2); display: flex; align-items: center; justify-content: center; }
.kf-sym-import-glyph svg { width: 48px; height: 30px; }
@media (max-width: 720px) {
.kf-sym-create { grid-template-columns: 1fr; }
.kf-sym-create-preview { border-left: none; border-top: 1px solid var(--line); padding-left: 0; padding-top: 18px; }
}
/* — Symbol Library (workspace-level) — */.kf-library { padding: 28px 32px 48px; overflow-y: auto; }
.kf-library-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 22px; flex-wrap: wrap; }
.kf-library-eyebrow { font-family: var(--f-mono); font-size: 11px; font-weight: 600; color: var(--t-3); text-transform: uppercase; letter-spacing: .08em; margin-bottom: 8px; }
.kf-library-title { font-family: var(--f-display); font-size: 32px; font-weight: 700; letter-spacing: -.6px; color: var(--t-1); margin: 0 0 6px; }
.kf-library-sub { font-size: 13px; color: var(--t-3); max-width: 560px; line-height: 1.5; }
.kf-library-head-actions { display: flex; gap: 8px; flex-shrink: 0; }
.kf-library-families {
display: flex; gap: 4px; flex-wrap: wrap;
padding: 6px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-3);
margin-bottom: 12px;
}
.kf-library-fam {
display: inline-flex; align-items: center; gap: 6px;
height: 30px; padding: 0 12px;
background: transparent;
border: 1px solid transparent;
color: var(--t-2);
font-family: inherit; font-size: 12px; font-weight: 500;
border-radius: var(--r-2); cursor: pointer;
transition: background .12s var(--ease), color .12s var(--ease), border-color .12s var(--ease);
}
.kf-library-fam:hover:not(.on) { background: var(--bg-3); color: var(--t-1); }
.kf-library-fam.on { background: var(--bg-1); border-color: var(--line-strong); color: var(--t-1); }
.kf-library-fam-n {
font-family: var(--f-mono); font-size: 10.5px; font-weight: 500;
color: var(--t-3);
padding: 1px 6px;
background: var(--bg-2); border: 1px solid var(--line-soft);
border-radius: 999px;
}
.kf-library-fam.on .kf-library-fam-n { background: var(--bg-3); color: var(--t-2); }
.kf-library-toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 18px; flex-wrap: wrap; }
.kf-library-genders { display: flex; gap: 4px; padding: 4px; background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--r-3); }
.kf-library-views { display: flex; gap: 4px; padding: 4px; background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--r-3); }
.kf-library-views .kf-iconbtn.on { background: var(--bg-1); border: 1px solid var(--line-strong); color: var(--accent); }
.kf-symcard-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
.kf-symcard {
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-3);
overflow: hidden;
transition: background .14s var(--ease), border-color .14s var(--ease), transform .14s var(--ease);
cursor: grab;
}
.kf-symcard:hover { background: var(--bg-3); border-color: var(--line-strong); transform: translateY(-1px); }
.kf-symcard-preview {
background: var(--bg-1);
border-bottom: 1px solid var(--line);
padding: 24px;
display: flex; align-items: center; justify-content: center;
}
.kf-symcard-glyph { width: 96px; height: 64px; }
.kf-symcard-body { padding: 14px 16px; }
.kf-symcard-name { font-size: 13.5px; font-weight: 600; color: var(--t-1); margin-bottom: 6px; }
.kf-symcard-meta { display: flex; gap: 8px; align-items: center; font-size: 11.5px; color: var(--t-3); margin-bottom: 8px; }
.kf-symcard-meta .num { font-family: var(--f-mono); font-weight: 600; color: var(--t-2); }
.kf-symcard-foot { display: flex; justify-content: space-between; font-size: 11px; color: var(--t-3); padding-top: 8px; border-top: 1px dashed var(--line); }
.kf-symcard-foot .num { font-family: var(--f-mono); color: var(--t-1); font-weight: 600; }
.kf-library-list { background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--r-3); overflow: hidden; }
@media (max-width: 720px) {
.kf-library { padding: 20px 18px; }
.kf-library-title { font-size: 26px; }
}
/* — Home — */
.kf-home { padding: 22px 24px; overflow-y: auto; }
/* — Projects browser (full-page) — */
.kf-projects-view { padding: 28px 32px 48px; overflow-y: auto; max-width: 1400px; }
.kf-projects-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 24px; flex-wrap: wrap; }
.kf-projects-eyebrow { font-family: var(--f-mono); font-size: 11px; font-weight: 600; color: var(--t-3); text-transform: uppercase; letter-spacing: .08em; margin-bottom: 8px; }
.kf-projects-title { font-family: var(--f-display); font-size: 32px; font-weight: 700; letter-spacing: -.6px; color: var(--t-1); margin: 0 0 4px; }
.kf-projects-sub { font-size: 13px; color: var(--t-3); }
.kf-projects-head-actions { display: flex; gap: 8px; flex-shrink: 0; }
.kf-projects-toolbar {
display: flex; align-items: center; gap: 14px;
padding: 10px 12px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-3);
margin-bottom: 18px;
flex-wrap: wrap;
}
.kf-projects-search {
display: flex; align-items: center; gap: 8px;
flex: 1; min-width: 240px;
height: 34px; padding: 0 12px;
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-2);
transition: border-color .12s var(--ease);
}
.kf-projects-search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.kf-projects-search input {
flex: 1; background: transparent; border: none; outline: none;
color: var(--t-1); font-family: inherit; font-size: 13px;
}
.kf-projects-search input::placeholder { color: var(--t-4); }
.kf-projects-clear {
background: transparent; border: none; cursor: pointer;
color: var(--t-3); padding: 2px; border-radius: 4px;
display: flex; align-items: center;
}
.kf-projects-clear:hover { color: var(--t-1); background: var(--bg-3); }
.kf-projects-chips { display: flex; gap: 4px; flex-wrap: wrap; }
.kf-projects-chip {
display: inline-flex; align-items: center; gap: 6px;
height: 30px; padding: 0 12px;
background: transparent;
border: 1px solid transparent;
color: var(--t-2);
font-family: inherit; font-size: 12px; font-weight: 500;
border-radius: var(--r-2);
cursor: pointer;
transition: background .12s var(--ease), color .12s var(--ease), border-color .12s var(--ease);
}
.kf-projects-chip:hover:not(.on) { background: var(--bg-3); color: var(--t-1); }
.kf-projects-chip.on { background: var(--bg-1); border-color: var(--line-strong); color: var(--t-1); box-shadow: 0 1px 2px rgba(0,0,0,.08); }
.kf-projects-chip-count {
font-family: var(--f-mono); font-size: 10.5px; font-weight: 500;
color: var(--t-3);
padding: 1px 6px;
background: var(--bg-2); border-radius: 999px;
border: 1px solid var(--line-soft);
}
.kf-projects-chip.on .kf-projects-chip-count { color: var(--t-2); }
.kf-projects-sort { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--t-3); }
.kf-projects-sort label { font-family: var(--f-mono); text-transform: uppercase; letter-spacing: .06em; font-size: 10.5px; }
.kf-projects-sort select {
height: 30px; padding: 0 10px;
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-2);
color: var(--t-1);
font-family: inherit; font-size: 12px;
cursor: pointer;
}
.kf-projects-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}
.kf-pcard {
display: flex; flex-direction: column; gap: 0;
padding: 18px;
text-align: left;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-3);
color: var(--t-1); font-family: inherit;
cursor: pointer;
transition: background .14s var(--ease), border-color .14s var(--ease), transform .14s var(--ease);
}
.kf-pcard:hover { background: var(--bg-3); border-color: var(--line-strong); transform: translateY(-1px); }
.kf-pcard.on { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.kf-pcard-head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; font-size: 11px; color: var(--t-3); text-transform: uppercase; letter-spacing: .05em; font-family: var(--f-mono); }
.kf-pcard-status-label { color: var(--t-2); font-weight: 600; }
.kf-pcard-rev { margin-left: auto; color: var(--t-3); }
.kf-pcard-name { font-size: 16px; font-weight: 600; line-height: 1.25; color: var(--t-1); margin-bottom: 4px; }
.kf-pcard-code { font-family: var(--f-mono); font-size: 11.5px; color: var(--t-3); margin-bottom: 16px; }
.kf-pcard-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding: 12px 0; border-top: 1px dashed var(--line); border-bottom: 1px dashed var(--line); }
.kf-pcard-stat-n { font-family: var(--f-mono); font-size: 18px; font-weight: 600; color: var(--t-1); }
.kf-pcard-stat-l { font-size: 10.5px; color: var(--t-3); text-transform: uppercase; letter-spacing: .05em; margin-top: 2px; }
.kf-pcard-foot { display: flex; justify-content: space-between; margin-top: 12px; font-size: 11.5px; color: var(--t-3); }
@media (max-width: 720px) {
.kf-projects-view { padding: 20px 18px; }
.kf-projects-title { font-size: 26px; }
}
.kf-home-hero { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 22px; flex-wrap: wrap; }
.kf-home-hero > div:first-child { min-width: 0; flex: 1; }
.kf-home-eyebrow { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.kf-home-rev { font-size: 11px; color: var(--t-3); font-family: var(--f-mono); }
.kf-home-title { font-family: var(--f-display); font-size: 26px; font-weight: 700; letter-spacing: -.5px; margin-bottom: 4px; max-width: 640px; line-height: 1.15; }
.kf-home-meta { font-size: 12.5px; color: var(--t-3); }
.kf-home-hero-actions { display: flex; gap: 8px; flex-shrink: 0; }
.kf-home-grid {
display: grid;
grid-template-columns: 1.4fr 1fr;
grid-template-rows: auto auto auto;
grid-template-areas:
"preview health"
"preview stats"
"activity exports";
gap: 14px;
}
.kf-home-preview { grid-area: preview; display: flex; flex-direction: column; }
.kf-home-preview-svg { flex: 1; min-height: 320px; background: radial-gradient(circle, var(--grid-dot) 1px, transparent 1px) 0 0/24px 24px, var(--bg-1); border-top: 1px solid var(--line); }
.kf-home-activity { grid-area: activity; }
.kf-home-exports { grid-area: exports; }
@media (max-width: 1100px) {
.kf-home-grid { grid-template-columns: 1fr; grid-template-areas: "preview" "health" "stats" "activity" "exports"; }
}
.kf-health { display: flex; flex-direction: column; gap: 10px; }
.kf-health-row { display: grid; grid-template-columns: 110px 1fr 80px; gap: 10px; align-items: center; font-size: 12px; }
.kf-health-label { color: var(--t-2); }
.kf-health-bar { height: 6px; background: var(--bg-3); border-radius: 3px; overflow: hidden; }
.kf-health-fill { height: 100%; border-radius: 3px; transition: width .3s var(--ease); }
.kf-health-val { color: var(--t-1); text-align: right; font-weight: 600; }
.kf-health-warn { color: var(--warning); font-weight: 500; }
.kf-quick-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.kf-qstat { background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--r-2); padding: 12px; display: flex; flex-direction: column; gap: 4px; }
.kf-qstat-icon { color: var(--t-3); }
.kf-qstat-val { font-family: var(--f-display); font-size: 20px; font-weight: 700; color: var(--t-1); }
.kf-qstat-label { font-size: 10.5px; text-transform: uppercase; letter-spacing: .8px; color: var(--t-3); font-weight: 700; }
.kf-activity { padding: 12px 4px; }
.kf-activity-row {
display: grid;
grid-template-columns: 64px 8px 1fr;
gap: 12px; align-items: center;
padding: 7px 14px;
font-size: 12px;
}
.kf-activity-row:hover { background: var(--bg-3); }
.kf-activity-time { color: var(--t-3); font-size: 11px; }
.kf-activity-dot { width: 8px; height: 8px; border-radius: 50%; }
.kf-activity-dot-edit { background: var(--info); }
.kf-activity-dot-add { background: var(--success); }
.kf-activity-dot-review { background: var(--warning); }
.kf-activity-dot-backup { background: var(--violet); }
.kf-activity-dot-note { background: var(--t-3); }
.kf-activity-text { color: var(--t-2); }
.kf-activity-text b { color: var(--t-1); font-weight: 600; }
.kf-exports-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; padding: 12px; }
@media (max-width: 1100px) { .kf-exports-row { grid-template-columns: repeat(2, 1fr); } }
.kf-export-card {
display: flex; align-items: center; gap: 10px;
padding: 12px;
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-3);
text-align: left; cursor: pointer; font-family: inherit;
transition: all .15s var(--ease);
}
.kf-export-card:hover { background: var(--bg-3); border-color: var(--line-strong); }
.kf-export-card-primary { border-color: rgba(74,158,255,.4); background: var(--info-soft); }
.kf-export-card-primary:hover { background: rgba(74,158,255,.2); }
.kf-export-icon { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; background: var(--bg-3); border-radius: 6px; color: var(--info); }
.kf-export-card-primary .kf-export-icon { background: var(--info); color: #fff; }
.kf-export-text { flex: 1; min-width: 0; }
.kf-export-label { font-size: 12px; font-weight: 600; color: var(--t-1); }
.kf-export-sub { font-size: 10.5px; color: var(--t-3); margin-top: 2px; }
.kf-export-time { font-size: 10px; color: var(--t-3); font-family: var(--f-mono); }
/* — Connectors — */
/* Connectors — responsive grid.
- auto-fill + minmax adapts column count to container width
- container queries on each card adjust internal layout when a card
becomes narrow (e.g. 1-column phone or split-pane). Falls back to
viewport @media breakpoints for older engines that don't support
container queries. */
.kf-conn-grid {
flex: 1; overflow-y: auto;
padding: clamp(10px, 2vw, 20px);
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr));
gap: clamp(8px, 1.4vw, 14px);
align-content: start;
}
.kf-conn-card {
display: flex; flex-direction: column;
container-type: inline-size;
container-name: connCard;
min-width: 0; /* prevent grid blow-out from long descriptions */
transition: box-shadow .15s var(--ease);
}
.kf-conn-card.is-expanded {
box-shadow: 0 4px 16px rgba(0,0,0,.06), 0 0 0 1px var(--info-soft);
border-color: var(--info);
}
.kf-conn-card .card-head {
flex-wrap: wrap;
gap: 6px 8px;
padding: 10px 12px;
min-height: 44px;
}
.kf-conn-card-head {
/* header row: toggle button takes available space, edit/trash sit at end */
display: flex; align-items: center; justify-content: space-between;
gap: 6px;
}
.kf-conn-card-toggle {
display: flex; align-items: center; gap: 8px;
background: transparent; border: 0;
padding: 4px 4px;
margin: -4px -4px -4px 0;
font: inherit; color: var(--t-1);
cursor: pointer;
flex: 1 1 auto; min-width: 0;
text-align: left;
border-radius: var(--r-2);
transition: background .12s var(--ease);
}
.kf-conn-card-toggle:hover { background: var(--bg-3); }
.kf-conn-card-toggle:focus-visible { outline: 2px solid var(--info); outline-offset: 1px; }
.kf-conn-card-toggle > .kf-conn-card-type { flex: 1 1 auto; min-width: 0; }
.kf-conn-card-toggle > svg:last-of-type {
flex-shrink: 0;
transition: transform .15s var(--ease);
}
.kf-conn-card-type {
font-size: 11px; color: var(--t-3);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
min-width: 0; flex: 1 1 auto;
}
.kf-conn-card-body {
padding: 14px; display: flex; flex-direction: column;
gap: 12px; align-items: stretch;
}
.kf-conn-card-row1 {
display: flex; gap: 14px; align-items: center;
width: 100%; min-width: 0;
}
.kf-conn-card-row1 > svg { flex-shrink: 0; }
.kf-conn-meta {
display: grid; grid-template-columns: auto 1fr;
row-gap: 4px; column-gap: 12px;
flex: 1; min-width: 0;
font-size: 12px;
}
.kf-conn-meta-row {
display: contents; /* let label + value flow into the grid cells */
font-size: 12px; color: var(--t-3);
}
.kf-conn-meta-row span { color: var(--t-3); white-space: nowrap; }
.kf-conn-meta-row b { color: var(--t-1); font-weight: 600; text-align: right; }
.kf-conn-progress {
width: 100%; height: 4px;
background: var(--bg-3); border-radius: 2px; overflow: hidden;
}
.kf-conn-progress-fill {
height: 100%; border-radius: 2px;
transition: width .3s var(--ease);
}
.kf-conn-desc {
width: 100%; font-size: 11.5px; color: var(--t-3); text-align: center;
/* Clamp to 2 lines so cards stay the same height in the grid. */
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden; line-height: 1.35;
min-height: 1.35em;
}
.kf-conn-add {
display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 8px;
background: transparent;
border: 1.5px dashed var(--line-strong);
cursor: pointer;
color: var(--t-3); font-size: 12px; font-weight: 600;
font-family: inherit;
min-height: 200px;
transition: all .15s var(--ease);
}
.kf-conn-add:hover { border-color: var(--info); color: var(--info); background: var(--info-soft); }
/* ── Expand / collapse behaviour ───────────────────────────────────────
- On wide viewports (≥ 1100px) every card body is shown regardless
of the React expandedId state — there's plenty of room to scan
them all at once.
- On narrower viewports only the card whose id matches expandedId
keeps its body. Collapsed cards shrink to header height so the
grid stays dense and the user can scroll a long list quickly.
- The chevron rotates 90° between collapsed (▶) and expanded (▼)
for a touch of motion feedback. */
.kf-conn-card.is-collapsed .kf-conn-card-body { display: none; }
@media (min-width: 1100px) {
/* show all bodies regardless of state */
.kf-conn-card.is-collapsed .kf-conn-card-body { display: flex; }
}
/* Container queries: when a card is narrower than 240px (typical when
the grid is in 1-column mode on phones, or when the side panel is
open), stack the pin diagram above the meta and shrink the SVG. */
@container connCard (max-width: 240px) {
.kf-conn-card-body { padding: 12px; gap: 10px; }
.kf-conn-card-row1 { flex-direction: column; gap: 10px; align-items: center; }
.kf-conn-card-row1 > svg { width: 76px; height: 76px; }
.kf-conn-meta {
grid-template-columns: 1fr auto;
width: 100%;
}
}
/* At very narrow card widths (e.g. 1-column on 320px-wide screens),
hide the pin diagram entirely — the meta carries the same info and
the diagram becomes too small to read anyway. */
@container connCard (max-width: 180px) {
.kf-conn-card-row1 > svg { display: none; }
.kf-conn-card .card-head { padding: 8px 10px; }
.kf-conn-add { min-height: 140px; font-size: 11.5px; }
}
/* Fallback for browsers without container-query support — same idea
but driven by the viewport. Below 720px the main content area is
usually a single column anyway. */
@supports not (container-type: inline-size) {
@media (max-width: 720px) {
.kf-conn-card-row1 { flex-direction: column; }
.kf-conn-card-row1 > svg { width: 76px; height: 76px; }
.kf-conn-meta { grid-template-columns: 1fr auto; width: 100%; }
}
}
/* View-header polish for narrow viewports — keep the count badge with
the title, let the action buttons wrap onto a new row. */
.kf-view-head {
padding: clamp(10px, 1.8vw, 18px) clamp(12px, 2vw, 22px) clamp(8px, 1.4vw, 14px);
}
@media (max-width: 540px) {
.kf-view-head-r .kf-btn-label { display: none; } /* icon-only on phones */
.kf-view-head-r .kf-btn { padding-left: 10px; padding-right: 10px; }
}
/* — Settings — */
.kf-settings-body { flex: 1; display: grid; grid-template-columns: 188px 1fr; overflow: hidden; }
.kf-settings-nav { padding: 14px 8px; border-right: 1px solid var(--line); background: var(--bg-2); display: flex; flex-direction: column; gap: 2px; }
.kf-settings-nav-item {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px;
background: transparent; border: none; cursor: pointer;
color: var(--t-2); text-align: left;
border-radius: var(--r-2);
font-family: inherit; font-size: 12.5px; font-weight: 500;
transition: all .12s var(--ease);
}
.kf-settings-nav-item:hover { background: var(--bg-3); color: var(--t-1); }
.kf-settings-nav-item.on { background: var(--info-soft); color: var(--info); font-weight: 600; }
/* Settings forms — horizontal label/input rows.
Project Settings and Workspace Settings cards reuse the .kf-form pattern.
In the original (used by Wires, etc.) the label sits ABOVE the field;
inside the settings panels that wastes vertical space and causes the two-
column form-row to overflow at narrow widths. Inside .kf-settings we flip
to a horizontal row: label on the left (160px), control on the right
(flex), hint underneath. The row stacks back to vertical at < 540px. */
.kf-settings .kf-form { gap: 4px; }
.kf-settings .kf-form-row {
display: flex; flex-direction: column;
gap: 4px;
padding: 12px 0;
border-bottom: 1px dashed var(--line);
}
.kf-settings .kf-form-row:last-child { border-bottom: none; }
.kf-settings .kf-field {
display: grid;
grid-template-columns: 180px 1fr;
align-items: center;
gap: 16px;
min-width: 0;
}
.kf-settings .kf-field > label {
margin: 0;
font-size: 11px; font-weight: 600;
text-transform: uppercase; letter-spacing: .04em;
color: var(--t-2);
}
.kf-settings .kf-field > .kf-input,
.kf-settings .kf-field > select.kf-input,
.kf-settings .kf-field > textarea.kf-input {
grid-column: 2;
width: 100%;
max-width: 480px;
}
.kf-settings .kf-field > .kf-field-hint {
grid-column: 2;
margin-top: 0;
}
/* Stack at narrow widths so labels don't crush against the input. */
@media (max-width: 720px) {
.kf-settings .kf-field { grid-template-columns: 1fr; gap: 4px; }
.kf-settings .kf-field > .kf-input { max-width: none; }
}
.kf-settings-panel { padding: 18px; overflow-y: auto; display: flex; flex-direction: column; gap: 14px; }
/* Settings view header tweaks — give the description more breathing room
and bump its size; the short title leaves plenty of space underneath. */
.kf-settings .kf-view-head { flex-direction: column; align-items: flex-start; gap: 6px; padding: 22px 24px 18px; }
.kf-settings .kf-view-head-l h1 { font-size: 24px; }
.kf-settings .kf-view-hint { font-size: 14px; line-height: 1.55; max-width: 760px; color: var(--t-2); }
.kf-settings .kf-view-hint b { color: var(--t-1); font-weight: 600; }
.kf-settings .kf-view-head-r { margin-left: 0; }
/* Project Settings — collaborator, compliance, danger rows. */
.kf-collab-row {
display: flex; align-items: center; gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--line-soft);
}
.kf-collab-row:last-child { border-bottom: none; }
/* Project team table — full-width with role dropdown column. */
.kf-team-table { width: 100%; }
.kf-team-table td .kf-input-sm { max-width: 160px; }
.kf-team-legend {
display: flex; align-items: center; flex-wrap: wrap;
gap: 6px;
margin-bottom: 14px;
padding: 10px 12px;
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-2);
}
.kf-team-legend-hint {
margin-left: auto;
font-size: 11px; color: var(--t-3); font-style: italic;
}
.kf-compliance-row {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--line-soft);
}
.kf-compliance-row:last-child { border-bottom: none; }
.kf-compliance-row .kf-checkfield { align-items: flex-start; gap: 10px; flex: 1; }
.kf-compliance-row .kf-checkfield input { margin-top: 3px; }
.kf-compliance-row label { display: flex; flex-direction: column; gap: 2px; cursor: pointer; }
.kf-compliance-l { font-size: 12.5px; font-weight: 600; color: var(--t-1); font-family: var(--f-mono); }
.kf-compliance-d { font-size: 11.5px; color: var(--t-3); }
.kf-danger-zone { border-color: rgba(239,78,78,.22); }
.kf-danger-zone .card-head h3 { color: var(--danger); }
.kf-danger-row {
display: flex; align-items: center; justify-content: space-between; gap: 16px;
padding: 14px 0;
border-bottom: 1px dashed var(--line);
}
.kf-danger-row:last-child { border-bottom: none; }
.kf-danger-title { font-size: 13px; font-weight: 600; color: var(--t-1); margin-bottom: 4px; }
.kf-danger-title.kf-danger-strong { color: var(--danger); }
.kf-danger-hint { font-size: 11.5px; color: var(--t-3); max-width: 520px; line-height: 1.45; }
.kf-swatch-row { display: flex; gap: 10px; flex-wrap: wrap; }
.kf-swatch { display: flex; flex-direction: column; gap: 6px; padding: 8px; background: var(--bg-1); border: 1.5px solid var(--line); border-radius: var(--r-3); cursor: pointer; font: inherit; color: var(--t-2); font-size: 11.5px; font-weight: 600; }
.kf-swatch.on { border-color: var(--info); color: var(--info); }
.kf-swatch-preview { width: 80px; height: 50px; border-radius: 4px; border: 1px solid var(--line); }
/* — NodeEditModal — */
.kf-node-form .kf-form-row { gap: 12px; }
.kf-field-hint { font-weight: 400; color: var(--t-3); font-size: 11px; }
.kf-link { background: transparent; border: 0; color: var(--info); font: inherit; font-size: 11.5px; cursor: pointer; padding: 0; }
.kf-link:hover { text-decoration: underline; }
.kf-node-wires {
max-height: 200px; overflow-y: auto;
border: 1px solid var(--line); border-radius: var(--r-2);
background: var(--bg-1); padding: 4px;
display: flex; flex-direction: column; gap: 2px;
}
.kf-node-wires-empty { padding: 16px; text-align: center; color: var(--t-3); font-size: 12px; }
.kf-node-wire {
display: flex; align-items: center; gap: 8px;
padding: 6px 8px;
border-radius: var(--r-1);
font-size: 11.5px; font-family: var(--f-mono); color: var(--t-2);
cursor: pointer;
}
.kf-node-wire:hover { background: var(--bg-3); }
.kf-node-wire.on { background: var(--info-soft); color: var(--t-1); }
.kf-node-wire input[type="checkbox"] { margin: 0; flex-shrink: 0; }
.kf-node-wire-tag { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.kf-node-wire-swatch { width: 10px; height: 10px; border-radius: 2px; border: 1px solid var(--line); flex-shrink: 0; }
.kf-node-wires-count { font-size: 11.5px; color: var(--success); margin-top: 4px; }
.kf-node-preview-wrap { display: flex; justify-content: center; margin: 12px 0; }
.kf-node-preview {
width: 200px; padding: 16px;
background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--r-3);
text-align: center;
}
.kf-node-preview-label { font-size: 10px; font-weight: 700; color: var(--t-3); letter-spacing: 1px; text-transform: uppercase; margin-bottom: 8px; }
.kf-node-preview-glyph { font-size: 32px; color: var(--warning); margin: 4px 0; }
.kf-node-preview-sub { font-size: 10.5px; color: var(--t-3); }
.kf-radio-row { display: inline-flex; gap: 2px; padding: 3px; background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--r-2); }
.kf-radio { padding: 6px 16px; background: transparent; border: none; cursor: pointer; color: var(--t-3); font: inherit; font-size: 12px; font-weight: 600; border-radius: var(--r-1); }
.kf-radio.on { background: var(--info); color: #fff; }
.kf-sc-list { display: flex; flex-direction: column; gap: 1px; }
.kf-sc-row { display: flex; justify-content: space-between; align-items: center; padding: 9px 4px; border-bottom: 1px solid var(--line); font-size: 12.5px; color: var(--t-2); }
.kf-sc-row:last-child { border-bottom: none; }
.kf-sc-keys { display: flex; gap: 4px; }
.kf-integrations { display: flex; flex-direction: column; gap: 1px; }
.kf-int-row { display: flex; align-items: center; justify-content: space-between; padding: 12px 4px; border-bottom: 1px solid var(--line); }
.kf-int-row:last-child { border-bottom: none; }
.kf-int-name { font-size: 13px; font-weight: 600; color: var(--t-1); }
.kf-int-sub { font-size: 11px; color: var(--t-3); margin-top: 2px; }
/* — Welcome — */
.kf-welcome { display: flex; align-items: center; justify-content: center; height: 100%; padding: 40px; }
.kf-welcome-inner { display: flex; flex-direction: column; align-items: center; gap: 8px; text-align: center; max-width: 560px; }
.kf-welcome-logo { margin-bottom: 4px; }
.kf-welcome-title { font-family: var(--f-display); font-size: 34px; font-weight: 800; letter-spacing: -1px; }
.kf-welcome-sub { font-size: 12px; color: var(--t-3); text-transform: uppercase; letter-spacing: 2px; margin-bottom: 16px; }
.kf-welcome-shortcuts { font-size: 12px; color: var(--t-3); margin-top: 8px; }
.kf-welcome-shortcuts > span { display: inline-flex; align-items: center; gap: 6px; }
`;
document.head.appendChild(Object.assign(document.createElement('style'), { textContent: STYLE }));
Object.assign(window, { ProjectHome, ConnectorsView, SettingsView, ProjectSettingsView, NotesView, WelcomeView, ProjectsView, LibraryView, KabelFluxMark });