<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fusible Studio</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='%2388A888' d='M5 3h14v2H5zm0 16h14v2H5zM3 5h2v14H3zm16 0h2v14h-2zM9 7h6v2H9zm0 8h6v2H9zM7 9h2v6H7zm8 0h2v6h-2zm-4-8h2v2h-2zm0 20h2v2h-2zM1 11h2v2H1zm20 0h2v2h-2zm0-4h2v2h-2zm0 8h2v2h-2zM1 15h2v2H1zm0-8h2v2H1zm6-6h2v2H7zm8 0h2v2h-2zm0 20h2v2h-2zm-8 0h2v2H7z'/%3E%3C/svg%3E">
<link rel="stylesheet" href="/theme.css">
<script type="module" src="/studio.bundle.js"></script>
<style>
.tscircuit-schematic {
background-color: var(--bg) !important;
}
.tscircuit-schematic text {
font-family: 'Undefined', monospace !important;
}
#schematic-container [data-circuit-json-type="schematic_component"] text.sch-text {
fill: var(--text) !important;
}
#schematic-container text.sch-text[fill="rgb(132, 0, 0)"] {
fill: var(--green) !important;
}
#pcb-container > div,
#pcb-container canvas,
#schematic-container > div {
width: 100% !important;
height: 100% !important;
display: block;
}
@font-face {
font-family: 'Undefined';
src: url('/undefined-medium.otf') format('opentype');
font-weight: normal;
font-style: normal;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-weight: normal !important;
font-synthesis: none;
}
:root {
--gutter: 12px;
--sidebar-width: 340px;
}
body {
font-family: 'Undefined', monospace;
background: var(--bg);
color: var(--text);
height: 100vh;
overflow: hidden;
user-select: none;
}
button {
font: inherit;
color: inherit;
}
:focus-visible {
outline: 1px solid var(--green);
outline-offset: 2px;
}
#sidebar, #sidebar *, .sidebar-pane, .sidebar-pane *, .check-item, .check-item *, code, pre {
user-select: text !important;
-webkit-user-select: text !important;
}
.floating {
position: absolute;
z-index: 20;
display: flex;
align-items: center;
gap: 2px;
padding: 4px;
background: var(--glass);
box-shadow: var(--shadow);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
user-select: none !important;
}
.floating-views {
top: var(--gutter);
right: var(--gutter);
}
.floating-dock {
bottom: var(--gutter);
left: 50%;
transform: translateX(-50%);
}
.floating-status {
bottom: var(--gutter);
left: var(--gutter);
gap: 4px;
padding: 4px 4px 4px 10px;
font-size: 11px;
letter-spacing: 0.05em;
}
.panel-open {
display: none !important;
}
body.sidebar-collapsed .panel-open {
display: inline-flex !important;
}
.dock-divider {
width: 1px;
height: 18px;
margin: 0 4px;
background: var(--border);
}
.brand-logo {
font-size: 16px;
color: var(--green);
display: inline-flex;
align-items: center;
}
.board-name {
color: var(--text);
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-transform: uppercase;
padding: 0 6px;
}
.fuse-badge {
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
font-size: 11px;
letter-spacing: 0.05em;
border: none;
cursor: pointer;
transition: filter 0.15s ease;
}
.fuse-badge:hover {
filter: brightness(1.25);
}
.fuse-intact {
background: color-mix(in srgb, var(--green) 14%, transparent);
color: var(--green);
}
.fuse-blown {
background: color-mix(in srgb, var(--alert) 18%, transparent);
color: var(--alert);
animation: fusePulse 1.5s infinite;
}
@keyframes fusePulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.live {
display: inline-flex;
align-items: center;
padding: 0 6px;
}
.status-dot {
width: 6px;
height: 6px;
background: var(--green);
}
.tab-btn,
.btn {
white-space: nowrap;
background: transparent;
border: none;
color: var(--text-subtle);
height: 30px;
padding: 0 12px;
font-size: 11px;
letter-spacing: 0.05em;
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease;
display: inline-flex;
align-items: center;
gap: 8px;
}
.tab-btn:hover,
.btn:hover {
color: var(--text);
background: var(--surface-hover);
}
.tab-btn.active {
background: var(--green);
color: var(--on-green);
}
.btn.icon-only {
width: 30px;
padding: 0;
justify-content: center;
}
.btn.is-on {
color: var(--green);
background: color-mix(in srgb, var(--green) 14%, transparent);
}
.controls {
display: flex;
align-items: center;
gap: 2px;
}
@container viewport (max-width: 900px) {
.board-name {
display: none;
}
}
@container viewport (max-width: 640px) {
.btn .btn-label {
display: none;
}
}
@container viewport (max-width: 560px) {
.tab-btn span,
#fuse-text {
display: none;
}
}
#mechanical-container {
background: var(--bg);
}
#mechanical-container .mechanical-status {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: min(560px, calc(100% - 32px));
background: var(--surface);
padding: 24px;
line-height: 1.5;
white-space: pre-wrap;
pointer-events: none;
}
#assembly-container {
background: var(--bg);
}
#assembly-container .assembly-status {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: min(560px, calc(100% - 32px));
background: var(--surface);
padding: 24px;
gap: 16px;
line-height: 1.5;
white-space: pre-wrap;
}
.assembly-build {
background: var(--green);
color: var(--on-green);
}
.assembly-build:hover {
background: var(--green);
color: var(--on-green);
filter: brightness(1.1);
}
.assembly-list {
position: absolute;
top: 64px;
right: var(--gutter);
width: min(320px, calc(100% - 2 * var(--gutter)));
max-height: calc(100% - 160px);
overflow-y: auto;
display: flex;
flex-direction: column;
background: var(--glass);
box-shadow: var(--shadow);
font-size: 11px;
letter-spacing: 0.05em;
}
.tab-btn[hidden] {
display: none;
}
.assembly-list[hidden] {
display: none;
}
.assembly-head {
padding: 10px 12px;
color: var(--green);
}
.assembly-head.is-blown {
color: var(--alert);
}
.assembly-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: none;
background: transparent;
color: var(--text-subtle);
font: inherit;
letter-spacing: inherit;
text-align: left;
cursor: pointer;
}
.assembly-item:hover,
.assembly-item.active {
background: var(--surface-hover);
color: var(--text);
}
.assembly-rule {
color: var(--alert);
}
#project {
width: 232px;
flex-shrink: 0;
padding: var(--gutter) 0 var(--gutter) var(--gutter);
z-index: 10;
}
#project[hidden] {
display: none;
}
.project-card {
height: 100%;
background: var(--surface);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
overflow: hidden;
}
.project-title {
padding: 14px 16px 8px;
font-size: 11px;
letter-spacing: 0.05em;
color: var(--green);
}
#project-tree {
flex: 1;
overflow-y: auto;
padding-bottom: 8px;
}
.tree-group {
padding: 14px 16px 6px;
font-size: 10px;
letter-spacing: 0.05em;
color: var(--text-subtle);
}
.tree-item {
display: flex;
align-items: center;
gap: 4px;
min-height: 34px;
padding: 4px 8px 4px 16px;
font-size: 11px;
}
.tree-select {
display: flex;
flex: 1;
min-width: 0;
align-items: center;
gap: 8px;
min-height: 26px;
padding: 0;
background: transparent;
border: none;
color: inherit;
font: inherit;
font-size: 11px;
text-align: left;
cursor: pointer;
}
.tree-item:hover {
background: var(--surface-hover);
}
.tree-item.selected {
background: var(--green);
color: var(--on-green);
}
.tree-dot {
width: 8px;
height: 8px;
flex-shrink: 0;
background: var(--text-subtle);
}
.tree-dot.ok {
background: var(--green);
}
.tree-dot.blown {
background: var(--alert);
}
.tree-dot.authored {
background: var(--blue);
}
.tree-dot.generated {
background: var(--olive);
}
.tree-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tree-sub {
font-size: 9px;
letter-spacing: 0.05em;
color: var(--text-subtle);
white-space: nowrap;
}
.tree-item.selected .tree-dot {
background: var(--on-green);
}
.tree-item.selected .tree-sub {
color: color-mix(in srgb, var(--on-green) 60%, transparent);
}
.tree-run {
width: 22px;
height: 22px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--text-subtle);
cursor: pointer;
}
.tree-run:hover {
color: var(--text);
background: var(--surface-hover);
}
.tree-item.selected .tree-run {
color: var(--on-green);
}
.project-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 8px 0 0;
}
.project-head .project-title {
padding: 6px 16px;
}
.tree-open {
display: none !important;
}
body.has-project.tree-collapsed .tree-open {
display: inline-flex !important;
}
body.tree-collapsed #project {
display: none;
}
#tree-scrim {
display: none;
}
@media (max-width: 900px) {
#project {
display: none;
}
body.has-project .tree-open {
display: inline-flex !important;
}
body.tree-drawer-open #project:not([hidden]) {
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
z-index: 40;
}
body.tree-drawer-open #tree-scrim {
display: block;
position: absolute;
inset: 0;
z-index: 35;
background: rgba(0, 0, 0, 0.55);
}
}
#bom-view {
position: absolute;
inset: var(--gutter) var(--gutter) 64px var(--gutter);
z-index: 15;
display: none;
flex-direction: column;
overflow: auto;
background: var(--surface);
box-shadow: var(--shadow);
scrollbar-width: none;
}
#bom-view::-webkit-scrollbar {
display: none;
}
#sidebar, #sidebar * {
scrollbar-width: none;
}
#sidebar::-webkit-scrollbar, #sidebar *::-webkit-scrollbar {
display: none;
}
body.bom-open #bom-view:not([hidden]) {
display: flex;
}
body.bom-open #viewport-grid,
body.bom-open .view-host,
body.bom-open #studio-status,
body.bom-open .floating-views,
body.bom-open .floating-dock,
body.bom-open #hover-tooltip,
body.bom-open #sidebar {
display: none !important;
}
.bom-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 20px 24px 4px;
}
.bom-head .fw-title {
font-size: 14px;
letter-spacing: 0.08em;
}
.bom-sum {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
padding: 16px 24px 8px;
}
.bom-sum > div {
background: var(--bg);
padding: 14px 16px;
font-size: 9px;
letter-spacing: 0.08em;
color: var(--text-subtle);
}
.bom-sum span {
display: block;
margin-bottom: 8px;
}
.bom-sum b {
font-weight: normal;
font-size: 18px;
letter-spacing: 0.02em;
color: var(--text);
}
.bom-sum b i {
font-style: normal;
font-size: 10px;
color: var(--text-subtle);
}
.bom-sum > div.bom-grand {
background: color-mix(in srgb, var(--green) 14%, transparent);
}
.bom-sum > div.bom-grand b {
color: var(--green);
}
.bom-warn {
margin: 8px 24px 0;
padding: 8px 12px;
font-size: 10px;
letter-spacing: 0.05em;
color: var(--alert);
background: color-mix(in srgb, var(--alert) 10%, transparent);
}
.bom-warn.bom-note {
color: var(--olive);
background: color-mix(in srgb, var(--olive) 10%, transparent);
}
.bom-warn:empty {
display: none;
}
.bom-settings {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px 20px;
padding: 16px 24px 8px;
font-size: 9px;
letter-spacing: 0.08em;
color: var(--text-subtle);
}
.bom-settings input:not([type="checkbox"]) {
background: var(--bg) !important;
}
.bom-settings select {
background: var(--bg);
color: var(--text);
border: none;
font: inherit;
padding: 5px 8px;
cursor: pointer;
}
.bom-settings select:focus-visible {
outline: none;
box-shadow: inset 2px 0 0 var(--green);
}
.bom-settings .btn {
height: 28px;
background: var(--bg);
color: var(--green);
}
.bom-settings .bom-num {
width: 88px;
}
.bom-settings label {
display: flex;
flex-direction: column;
gap: 6px;
}
.bom-settings .bom-add {
flex: 1;
min-width: 200px;
}
.bom-scroll {
padding: 0 24px 24px;
}
.bom-table {
width: 100%;
border-collapse: separate;
border-spacing: 0 2px;
table-layout: fixed;
font-size: 11px;
}
.bom-table th {
position: sticky;
top: 0;
z-index: 1;
padding: 10px 10px;
font-size: 9px;
font-weight: normal;
letter-spacing: 0.08em;
text-align: left;
color: var(--text-subtle);
background: var(--surface);
}
.bom-table th.bom-r,
.bom-table td.bom-r {
text-align: right;
}
.bom-table th:nth-child(2),
.bom-table th:nth-child(4),
.bom-table th:nth-child(5),
.bom-table th:nth-child(6) {
padding-left: 18px;
padding-right: 18px;
}
.bom-table th:nth-child(7) {
padding-right: 10px;
}
.bom-table th:nth-child(1) { width: 32px; }
.bom-table th:nth-child(2) { width: 64px; }
.bom-table th:nth-child(4) { width: 110px; }
.bom-table th:nth-child(5) { width: 160px; }
.bom-table th:nth-child(6) { width: 100px; }
.bom-table th:nth-child(7) { width: 80px; }
.bom-table th:nth-child(8) { width: 30px; }
.bom-table td {
padding: 9px 10px;
vertical-align: middle;
background: var(--bg);
}
.bom-table tr.bom-group td {
padding: 18px 10px 6px;
font-size: 9px;
letter-spacing: 0.1em;
color: var(--text-subtle);
background: transparent;
}
.bom-table tr.bom-group b {
font-weight: normal;
color: var(--green);
}
.bom-table tr[data-id]:hover td {
background: var(--surface-hover);
}
.bom-table tr.off td {
opacity: 0.4;
}
.bom-table tr[data-id] td:first-child {
text-align: center;
}
.bom-table td input:not([type="checkbox"]) {
width: 100%;
min-width: 0;
}
#bom-view input[type="number"] {
appearance: textfield;
-moz-appearance: textfield;
}
#bom-view input[type="number"]::-webkit-inner-spin-button,
#bom-view input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
#bom-view input[type="checkbox"] {
accent-color: var(--green);
cursor: pointer;
}
#bom-view input:not([type="checkbox"]),
#bom-view select,
.bom-settings .btn {
height: 32px;
}
#bom-view input:not([type="checkbox"]) {
background: var(--surface);
border: none;
color: var(--text);
font: inherit;
padding: 5px 8px;
}
#bom-view input:not([type="checkbox"])::placeholder {
color: var(--text-subtle);
font-style: italic;
}
#bom-view input:not([type="checkbox"]):hover {
background: var(--surface-hover);
}
#bom-view input:focus-visible {
outline: none;
background: var(--surface-hover);
box-shadow: inset 2px 0 0 var(--green);
}
.bom-num {
width: 100%;
text-align: right;
}
.bom-wide {
width: 100%;
min-width: 140px;
}
.bom-name {
color: var(--text);
}
.bom-unit {
display: flex;
align-items: center;
gap: 6px;
}
.bom-unit input {
flex: 1;
}
.bom-unit span {
color: var(--text-subtle);
}
.bom-link {
display: flex;
align-items: center;
gap: 4px;
}
.bom-link input {
flex: 1;
min-width: 0;
}
.bom-link .btn.icon-only {
flex-shrink: 0;
width: 32px;
height: 32px;
background: var(--surface);
color: var(--blue);
}
.bom-refs {
margin-top: 3px;
font-size: 9px;
letter-spacing: 0.05em;
color: var(--text-subtle);
overflow-wrap: anywhere;
}
.bom-refs a,
.bom-table td a {
color: var(--blue);
text-decoration: none;
}
.bom-refs a:hover,
.bom-table td a:hover {
text-decoration: underline;
}
.bom-total {
white-space: nowrap;
text-align: right;
color: var(--green);
}
.bom-empty {
padding: 32px 10px;
text-align: center;
color: var(--text-subtle);
letter-spacing: 0.08em;
}
.fw-head {
padding: 14px 16px 10px;
}
.fw-title {
font-size: 12px;
letter-spacing: 0.05em;
color: var(--green);
}
.fw-meta,
.fw-file {
margin-top: 4px;
font-size: 10px;
letter-spacing: 0.05em;
color: var(--text-subtle);
overflow-wrap: anywhere;
}
.fw-file {
padding: 0 16px;
}
.fw-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
}
.fw-actions .btn:disabled {
opacity: 0.4;
cursor: default;
}
.fw-summary {
margin: 8px 16px 0;
font-size: 10px;
letter-spacing: 0.05em;
}
.fw-summary.blown,
.fw-error {
color: var(--alert);
}
.fw-error {
padding: 0 16px;
font-size: 11px;
line-height: 1.5;
}
.fw-pin {
display: flex;
width: 100%;
background: transparent;
border: none;
color: inherit;
font: inherit;
text-align: left;
flex-wrap: wrap;
align-items: baseline;
gap: 2px 8px;
padding: 6px 16px;
font-size: 11px;
cursor: pointer;
}
.fw-pin:hover {
background: var(--surface-hover);
}
.fw-pin.selected {
background: color-mix(in srgb, var(--green) 14%, transparent);
}
.fw-pin.blown {
background: color-mix(in srgb, var(--alert) 12%, transparent);
}
.fw-pin-name {
min-width: 56px;
color: var(--olive);
}
.fw-net {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
}
.fw-remote,
.fw-rust {
font-size: 9px;
letter-spacing: 0.05em;
color: var(--text-subtle);
}
.fw-rust {
flex-basis: 100%;
padding-left: 64px;
}
.fw-violation {
display: block;
flex-basis: 100%;
padding-left: 64px;
color: var(--alert);
line-height: 1.4;
}
#runs-toolbar {
display: flex;
gap: 2px;
padding: 4px;
}
#runs-toolbar .btn {
flex: 1;
justify-content: center;
}
#runs-toolbar .btn:disabled {
opacity: 0.4;
cursor: default;
}
#run-list {
max-height: 168px;
overflow-y: auto;
}
.run-item {
display: flex;
width: 100%;
gap: 8px;
align-items: center;
padding: 6px 16px;
background: transparent;
border: none;
color: inherit;
font: inherit;
font-size: 11px;
text-align: left;
cursor: pointer;
}
#run-error {
padding: 8px 16px;
color: var(--alert);
font-size: 11px;
line-height: 1.4;
background: color-mix(in srgb, var(--alert) 10%, transparent);
}
#run-error[hidden] {
display: none;
}
.run-item:hover {
background: var(--surface-hover);
}
.run-item.selected {
background: color-mix(in srgb, var(--green) 14%, transparent);
}
.run-status {
font-size: 9px;
letter-spacing: 0.05em;
min-width: 74px;
}
.run-status.ok {
color: var(--green);
}
.run-status.running {
color: var(--olive);
}
.run-status.fuse_blown,
.run-status.error {
color: var(--alert);
}
#run-log {
flex: 1;
min-height: 0;
margin: 8px 0 0;
padding: 12px 16px;
overflow: auto;
background: var(--bg);
color: var(--text-body);
font: inherit;
font-size: 11px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
#pane-runs.active {
display: flex;
flex-direction: column;
min-height: 0;
}
main {
height: 100%;
display: flex;
position: relative;
overflow: hidden;
}
#viewport-container {
flex: 1;
min-width: 0;
container: viewport / inline-size;
position: relative;
background: var(--bg);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.view-host {
position: absolute;
inset: 0;
z-index: 2;
display: none;
}
.studio-status {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 5;
max-width: min(560px, calc(100% - 32px));
padding: 24px;
background: var(--surface);
color: var(--text-subtle);
font-size: 11px;
letter-spacing: 0.05em;
text-align: center;
}
#viewport-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(to right, color-mix(in srgb, var(--green) 7%, transparent) 1px, transparent 1px),
linear-gradient(to bottom, color-mix(in srgb, var(--green) 7%, transparent) 1px, transparent 1px);
background-size: 32px 32px;
pointer-events: none;
}
#sidebar {
width: calc(var(--sidebar-width) + var(--gutter));
flex-shrink: 0;
padding: var(--gutter) var(--gutter) var(--gutter) 0;
overflow: hidden;
z-index: 10;
transition: width 0.22s ease, padding 0.22s ease;
}
.sidebar-card {
width: var(--sidebar-width);
height: 100%;
background: var(--surface);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
transition: opacity 0.18s ease, transform 0.22s ease;
}
body.sidebar-collapsed #sidebar {
width: 0;
padding-right: 0;
}
body.sidebar-collapsed .sidebar-card {
opacity: 0;
transform: translateX(16px);
pointer-events: none;
}
@media (max-width: 900px) {
#sidebar {
position: absolute;
top: 0;
right: 0;
bottom: 0;
z-index: 30;
}
}
.sidebar-tabs {
display: flex;
align-items: center;
gap: 2px;
padding: 4px;
}
.sidebar-tab-btn {
flex: 1;
height: 28px;
padding: 0 4px;
background: transparent;
border: none;
color: var(--text-subtle);
font-size: 10px;
letter-spacing: 0.05em;
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.sidebar-tab-btn:hover {
color: var(--text);
background: var(--surface-hover);
}
.sidebar-tab-btn.active {
color: var(--green);
background: color-mix(in srgb, var(--green) 12%, transparent);
}
.sidebar-tabs .btn.icon-only {
flex: 0 0 28px;
width: 28px;
height: 28px;
}
.sidebar-pane {
display: none;
flex: 1;
overflow-y: auto;
flex-direction: column;
}
.sidebar-pane.active {
display: flex;
}
.inspector-card {
padding: 14px 16px;
}
.inspector-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 12px;
padding-bottom: 8px;
}
.inspector-name {
font-size: 14px;
color: var(--green);
letter-spacing: 0.05em;
}
.inspector-type {
font-size: 10px;
color: var(--olive);
text-transform: uppercase;
}
.prop-row {
display: flex;
justify-content: space-between;
padding: 4px 0;
font-size: 11px;
}
.prop-key {
color: var(--text-subtle);
}
.prop-val {
color: var(--text-body);
font-family: monospace;
}
.pins-list {
margin-top: 12px;
}
.pins-title {
font-size: 10px;
color: var(--olive);
letter-spacing: 0.05em;
margin-bottom: 6px;
}
.pin-item {
display: flex;
justify-content: space-between;
padding: 3px 6px;
font-size: 10px;
background: var(--surface-hover);
margin-bottom: 3px;
cursor: pointer;
}
.pin-item:hover {
color: var(--blue);
}
.pin-net {
color: var(--blue);
}
.comp-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
font-size: 11px;
cursor: pointer;
transition: background 0.1s;
}
.comp-item:hover {
background: var(--surface-hover);
color: var(--green);
}
.comp-item.selected {
background: color-mix(in srgb, var(--green) 12%, transparent);
}
.comp-sub {
color: var(--text-subtle);
font-size: 10px;
}
.net-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
font-size: 11px;
cursor: pointer;
color: var(--text-body);
}
.net-item:hover {
background: var(--surface-hover);
color: var(--blue);
}
.net-item.selected {
background: color-mix(in srgb, var(--blue) 12%, transparent);
}
.diag-card {
background: color-mix(in srgb, var(--alert) 6%, transparent);
padding: 10px;
margin: 12px 16px;
font-size: 11px;
}
.diag-title {
color: var(--alert);
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 6px;
}
.diag-body {
color: var(--text-body);
line-height: 1.4;
white-space: pre-wrap;
}
.diag-help {
color: var(--olive);
margin-top: 4px;
line-height: 1.3;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px 8px;
color: var(--text-subtle);
font-size: 11px;
letter-spacing: 0.05em;
text-align: center;
}
.hover-tooltip {
position: fixed;
z-index: 1000;
pointer-events: none;
background: var(--glass);
padding: 8px 12px;
font-size: 11px;
line-height: 1.4;
color: var(--text);
max-width: 280px;
box-shadow: 0 4px 16px rgba(0,0,0,0.7);
backdrop-filter: blur(4px);
display: none;
}
.tooltip-title {
color: var(--green);
margin-bottom: 2px;
letter-spacing: 0.05em;
}
.tooltip-meta {
color: var(--olive);
font-size: 10px;
margin-bottom: 4px;
}
.tooltip-row {
display: flex;
justify-content: space-between;
gap: 12px;
color: var(--text-subtle);
font-size: 10px;
margin-top: 2px;
}
.tooltip-val {
color: var(--text-body);
font-family: monospace;
}
iconify-icon {
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
font-size: 13px;
}
#viewport-container :is(button, input, select, kbd, textarea, label) {
font-family: 'Undefined', monospace !important;
}
#viewport-container :is(input[type="checkbox"], input[type="radio"]) {
accent-color: var(--green);
}
[role="menu"],
[data-radix-menu-content],
[data-toolbar-overlay] div[style*="position: absolute"] {
background: var(--glass) !important;
background-image: none !important;
color: var(--text) !important;
border: none !important;
border-radius: 0 !important;
box-shadow: var(--shadow) !important;
backdrop-filter: blur(12px) !important;
-webkit-backdrop-filter: blur(12px) !important;
padding: 4px !important;
font-family: 'Undefined', monospace !important;
font-size: 11px !important;
letter-spacing: 0.05em;
}
[role="menu"] *,
[data-toolbar-overlay] div[style*="position: absolute"] * {
font-family: 'Undefined', monospace !important;
background-image: none !important;
}
[role="menu"] div:not([role]),
[role="menu"] span,
[role="menu"] kbd {
background-color: transparent !important;
border: none !important;
box-shadow: none !important;
}
[role="menu"] kbd,
[role="menu"] span[style*="border"] {
color: var(--text-subtle) !important;
}
[role="menuitem"],
[role="menuitemcheckbox"],
[role="menuitemradio"],
[data-toolbar-overlay] div[style*="position: absolute"] div[style*="cursor: pointer"] {
background: transparent !important;
color: var(--text-body) !important;
border: none !important;
border-radius: 0 !important;
box-shadow: none !important;
backdrop-filter: none !important;
font-size: 11px !important;
padding: 6px 10px !important;
height: auto !important;
}
[role="menuitem"]:is(:hover, :focus-visible, [data-highlighted]),
[role="menuitemcheckbox"]:is(:hover, :focus-visible, [data-highlighted]),
[role="menuitemradio"]:is(:hover, :focus-visible, [data-highlighted]),
[data-toolbar-overlay] div[style*="position: absolute"] div[style*="cursor: pointer"]:hover {
background: var(--surface-hover) !important;
color: var(--green) !important;
}
[role="separator"] {
height: 1px !important;
margin: 4px 0 !important;
background: var(--border-subtle) !important;
border: none !important;
}
[data-toolbar-overlay] {
font-family: 'Undefined', monospace !important;
gap: 4px !important;
align-items: flex-start !important;
}
[data-toolbar-overlay] div[style*="cursor: pointer"],
#schematic-container button,
[data-schematic-warnings] {
background: var(--glass) !important;
color: var(--text-subtle) !important;
border: none !important;
border-radius: 0 !important;
box-shadow: var(--shadow) !important;
backdrop-filter: blur(12px) !important;
-webkit-backdrop-filter: blur(12px) !important;
font-size: 11px !important;
letter-spacing: 0.05em !important;
outline: none;
transition: background 0.15s ease, color 0.15s ease !important;
}
[data-toolbar-overlay] div[style*="cursor: pointer"] {
padding: 7px 10px !important;
}
[data-toolbar-overlay] div[style*="cursor: pointer"]:hover,
#schematic-container button:hover {
background: var(--surface-hover) !important;
color: var(--text) !important;
}
#schematic-container [data-schematic-warnings] {
color: var(--alert) !important;
}
#schematic-container [data-schematic-warnings]:hover,
#schematic-container [data-schematic-warnings][aria-pressed="true"] {
background: color-mix(in srgb, var(--alert) 18%, transparent) !important;
}
[data-schematic-search] > div {
background: var(--glass) !important;
border: none !important;
border-radius: 0 !important;
box-shadow: var(--shadow) !important;
backdrop-filter: blur(12px) !important;
-webkit-backdrop-filter: blur(12px) !important;
color: var(--text) !important;
}
[data-schematic-search] * {
border-color: transparent !important;
font-family: 'Undefined', monospace !important;
}
[data-schematic-search] input {
background: transparent !important;
color: var(--text) !important;
font-size: 11px !important;
}
[data-schematic-search] input::placeholder,
[data-schematic-search] svg {
color: var(--text-subtle) !important;
}
[data-schematic-search] kbd {
background: var(--surface-hover) !important;
color: var(--text-subtle) !important;
border-radius: 0 !important;
}
#schematic-container [data-schematic-search] button[aria-label="Clear search"] {
background: transparent !important;
box-shadow: none !important;
}
#schematic-container div[style*="pointer-events: none"] {
background: transparent !important;
box-shadow: none !important;
}
#schematic-container div[style*="rgba(255, 255, 255"] {
background-color: var(--glass) !important;
color: var(--text) !important;
border: none !important;
border-radius: 0 !important;
box-shadow: var(--shadow) !important;
font-family: 'Undefined', monospace !important;
}
#schematic-container dl,
#schematic-container dl * {
font-family: 'Undefined', monospace !important;
color: var(--text-body) !important;
}
#schematic-container dt {
color: var(--text-subtle) !important;
font-size: 10px !important;
}
#schematic-container dd {
color: var(--green) !important;
font-size: 11px !important;
}
.schematic-warning,
.schematic-warning * {
font-family: 'Undefined', monospace !important;
}
.schematic-warning [data-warning-reference="callout"] {
background: var(--surface) !important;
color: var(--text) !important;
border: none !important;
border-left: 2px solid var(--alert) !important;
border-radius: 0 !important;
box-shadow: var(--shadow) !important;
}
#three-cad div[style*="background-color: white"],
#three-cad div[style*="background-color: rgb(255, 255, 255)"] {
background-color: var(--glass) !important;
color: var(--text) !important;
border: none !important;
border-radius: 0 !important;
box-shadow: var(--shadow) !important;
font-family: 'Undefined', monospace !important;
font-size: 11px !important;
padding: 6px 10px !important;
}
#three-cad div[style*="background-color: white"] *,
#three-cad div[style*="background-color: rgb(255, 255, 255)"] * {
color: var(--text) !important;
}
</style>
</head>
<body>
<main>
<div id="tree-scrim"></div>
<nav id="project" hidden aria-label="Project">
<div class="project-card">
<div class="project-head">
<div class="project-title" id="project-title"></div>
<button class="btn icon-only" id="tree-close" title="Hide project ([)" aria-label="Hide project"><iconify-icon icon="pixelarticons:chevron-left"></iconify-icon></button>
</div>
<div id="project-tree"></div>
<div id="sync-view" hidden></div>
</div>
</nav>
<div id="viewport-container">
<div id="viewport-grid"></div>
<div id="schematic-container" class="view-host"></div>
<div id="pcb-container" class="view-host"></div>
<div id="three-cad" class="view-host"></div>
<div id="mechanical-container" class="view-host"></div>
<div id="assembly-container" class="view-host"></div>
<div id="studio-status" class="studio-status" hidden></div>
<section id="bom-view" aria-label="Parts and costs" hidden></section>
<nav class="floating floating-views" aria-label="Views">
<button class="tab-btn active" data-view="schematic" onclick="switchView('schematic')" title="Schematic (1)"><iconify-icon icon="pixelarticons:git-commit"></iconify-icon><span>SCHEMATIC</span></button>
<button class="tab-btn" data-view="pcb" onclick="switchView('pcb')" title="PCB layout (2)"><iconify-icon icon="pixelarticons:grid"></iconify-icon><span>PCB</span></button>
<button class="tab-btn" data-view="3d" onclick="switchView('3d')" title="3D view (3)"><iconify-icon icon="pixelarticons:globe"></iconify-icon><span>3D</span></button>
<button class="tab-btn" data-view="mechanical" onclick="switchView('mechanical')" title="Bodies (4)"><iconify-icon icon="pixelarticons:box"></iconify-icon><span>BODIES</span></button>
<button class="tab-btn" id="tab-assembly" data-view="assembly" onclick="switchView('assembly')" title="Assembly (5)" hidden><iconify-icon icon="pixelarticons:layers"></iconify-icon><span>ASSEMBLY</span></button>
<span class="dock-divider panel-open"></span>
<button class="btn panel-open" onclick="toggleSidebar(true)" title="Show panel (\)" aria-label="Show panel"><iconify-icon icon="pixelarticons:layout-sidebar-right"></iconify-icon> <span class="btn-label">PANEL</span></button>
</nav>
<div class="floating floating-dock" role="toolbar" aria-label="View controls">
<div class="controls" id="controls-2d">
<button class="btn" onclick="zoomBy(120)" title="Zoom out" aria-label="Zoom out"><iconify-icon icon="pixelarticons:zoom-out"></iconify-icon> <span class="btn-label">ZOOM OUT</span></button>
<button class="btn" onclick="zoomBy(-120)" title="Zoom in" aria-label="Zoom in"><iconify-icon icon="pixelarticons:zoom-in"></iconify-icon> <span class="btn-label">ZOOM IN</span></button>
<button class="btn" onclick="renderView()" title="Reset view" aria-label="Reset view"><iconify-icon icon="pixelarticons:undo"></iconify-icon> <span class="btn-label">FIT</span></button>
</div>
<div class="controls" id="controls-3d" style="display: none;">
<button class="btn" onclick="setCameraPreset('TOP')" title="Top view" aria-label="Top view"><iconify-icon icon="pixelarticons:arrow-up"></iconify-icon> <span class="btn-label">TOP</span></button>
<button class="btn" onclick="setCameraPreset('ISO')" title="Isometric view" aria-label="Isometric view"><iconify-icon icon="pixelarticons:corner-up-right"></iconify-icon> <span class="btn-label">ISO</span></button>
<button class="btn" onclick="setCameraPreset('FRONT')" title="Front view" aria-label="Front view"><iconify-icon icon="pixelarticons:arrow-down"></iconify-icon> <span class="btn-label">FRONT</span></button>
<button class="btn" onclick="setCameraPreset('RESET')" title="Reset camera" aria-label="Reset camera"><iconify-icon icon="pixelarticons:reload"></iconify-icon> <span class="btn-label">RESET</span></button>
<span class="dock-divider"></span>
<button class="btn" id="btn-mechanical-overlay" onclick="toggleMechanical('overlay')" title="Show enclosure"><iconify-icon icon="pixelarticons:box"></iconify-icon> <span class="btn-label">ENCLOSURE</span></button>
</div>
<div class="controls" id="controls-assembly" style="display: none;">
<button class="btn" onclick="setCameraPreset('TOP')" title="Top view" aria-label="Top view"><iconify-icon icon="pixelarticons:arrow-up"></iconify-icon> <span class="btn-label">TOP</span></button>
<button class="btn" onclick="setCameraPreset('ISO')" title="Isometric view" aria-label="Isometric view"><iconify-icon icon="pixelarticons:corner-up-right"></iconify-icon> <span class="btn-label">ISO</span></button>
<button class="btn" onclick="setCameraPreset('FRONT')" title="Front view" aria-label="Front view"><iconify-icon icon="pixelarticons:arrow-down"></iconify-icon> <span class="btn-label">FRONT</span></button>
<button class="btn" onclick="setCameraPreset('RESET')" title="Reset camera" aria-label="Reset camera"><iconify-icon icon="pixelarticons:reload"></iconify-icon> <span class="btn-label">RESET</span></button>
<span class="dock-divider"></span>
<button class="btn" onclick="buildAssembly()" title="Run fusible assembly" aria-label="Build assembly"><iconify-icon icon="pixelarticons:layers"></iconify-icon> <span class="btn-label">BUILD ASSEMBLY</span></button>
</div>
<div class="controls" id="controls-mechanical" style="display: none;">
<button class="btn" onclick="setCameraPreset('TOP')" title="Top view" aria-label="Top view"><iconify-icon icon="pixelarticons:arrow-up"></iconify-icon> <span class="btn-label">TOP</span></button>
<button class="btn" onclick="setCameraPreset('ISO')" title="Isometric view" aria-label="Isometric view"><iconify-icon icon="pixelarticons:corner-up-right"></iconify-icon> <span class="btn-label">ISO</span></button>
<button class="btn" onclick="setCameraPreset('FRONT')" title="Front view" aria-label="Front view"><iconify-icon icon="pixelarticons:arrow-down"></iconify-icon> <span class="btn-label">FRONT</span></button>
<button class="btn" onclick="setCameraPreset('RESET')" title="Reset camera" aria-label="Reset camera"><iconify-icon icon="pixelarticons:reload"></iconify-icon> <span class="btn-label">RESET</span></button>
<span class="dock-divider"></span>
<button class="btn" id="btn-mechanical-lid" onclick="toggleMechanical('lid')" title="Show lid"><iconify-icon icon="pixelarticons:eye"></iconify-icon> <span class="btn-label">LID</span></button>
<button class="btn" id="btn-mechanical-explode" onclick="toggleMechanical('explode')" title="Explode enclosure"><iconify-icon icon="pixelarticons:expand"></iconify-icon> <span class="btn-label">EXPLODE</span></button>
</div>
</div>
<div class="floating floating-status">
<button class="btn tree-open" id="tree-open" title="Show project ([)" aria-label="Show project" aria-expanded="false"><iconify-icon icon="pixelarticons:layout-sidebar-left"></iconify-icon> <span class="btn-label">PROJECT</span></button>
<iconify-icon icon="pixelarticons:cpu-sharp" class="brand-logo"></iconify-icon>
<span class="board-name" id="board-name">BOARD PREVIEW</span>
<span class="live" title="Live reload connected" id="live-indicator"><span class="status-dot" id="live-dot"></span></span>
<button id="fuse-status" class="fuse-badge fuse-intact" onclick="openSidebar('checks')" title="Show checks">
<span id="fuse-icon"><iconify-icon icon="pixelarticons:zap"></iconify-icon></span>
<span id="fuse-text">FUSE INTACT</span>
</button>
</div>
<div id="hover-tooltip" class="hover-tooltip"></div>
</div>
<aside id="sidebar">
<div class="sidebar-card">
<div class="sidebar-tabs">
<button class="sidebar-tab-btn active" data-tab="inspector" onclick="switchSidebarTab('inspector')">INSPECT</button>
<button class="sidebar-tab-btn" data-tab="components" onclick="switchSidebarTab('components')">PARTS</button>
<button class="sidebar-tab-btn" data-tab="nets" onclick="switchSidebarTab('nets')">NETS</button>
<button class="sidebar-tab-btn" data-tab="checks" onclick="switchSidebarTab('checks')">CHECKS</button>
<button class="sidebar-tab-btn" data-tab="runs" onclick="switchSidebarTab('runs')">RUNS</button>
<button class="sidebar-tab-btn" data-tab="pins" onclick="switchSidebarTab('pins')">PINS</button>
<button class="btn icon-only" onclick="toggleSidebar(false)" title="Hide panel (\)" aria-label="Hide panel"><iconify-icon icon="pixelarticons:chevron-right"></iconify-icon></button>
</div>
<div class="sidebar-pane active" id="pane-inspector">
<div id="inspector-content" class="inspector-card">
<div class="empty-state">
<span>SELECT OR HOVER A COMPONENT OR PAD TO INSPECT</span>
</div>
</div>
</div>
<div class="sidebar-pane" id="pane-components">
<div id="components-list">
<div class="empty-state"><span>NO COMPONENTS PARSED</span></div>
</div>
</div>
<div class="sidebar-pane" id="pane-nets">
<div id="nets-list">
<div class="empty-state"><span>NO NETS DETECTED</span></div>
</div>
</div>
<div class="sidebar-pane" id="pane-pins">
<div id="firmware-view">
<div class="empty-state"><span>SELECT A FIRMWARE IN THE PROJECT TREE.</span></div>
</div>
</div>
<div class="sidebar-pane" id="pane-runs">
<div id="runs-toolbar">
<button class="btn" data-action="check">CHECK ALL</button>
<button class="btn" data-action="build">BUILD ALL</button>
<button class="btn" data-action="body">BODIES</button>
</div>
<div id="run-error" role="alert" hidden></div>
<div id="run-list"></div>
<pre id="run-log" aria-label="Run log"></pre>
</div>
<div class="sidebar-pane" id="pane-checks">
<div id="diagnostics-panel">
<div class="empty-state">
<span>NO ELECTRICAL VIOLATIONS DETECTED.</span>
</div>
</div>
</div>
</div>
</aside>
</main>
<script src="/project.js"></script>
<script src="/git.js"></script>
<script src="/firmware.js"></script>
<script src="/bom.js"></script>
<script src="/drawer.js"></script>
<script src="/kicad.js"></script>
<script>
let currentView = 'schematic';
let lastCircuitJson = null;
let lastRevision = null;
let lastBoard = '';
const tooltip = document.getElementById('hover-tooltip');
const hosts = {
schematic: document.getElementById('schematic-container'),
pcb: document.getElementById('pcb-container'),
'3d': document.getElementById('three-cad'),
mechanical: document.getElementById('mechanical-container'),
assembly: document.getElementById('assembly-container'),
};
const renderers = {
schematic: 'renderSchematicViewer',
pcb: 'renderPcbViewer',
'3d': 'render3dViewer',
};
let componentsMap = {};
let netsMap = {};
let selectedComponent = null;
function zoomBy(deltaY) {
const host = hosts[currentView];
const rect = host.getBoundingClientRect();
const target = host.querySelector('canvas') || host.querySelector('svg') || host;
target.dispatchEvent(new WheelEvent('wheel', {
deltaY,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
bubbles: true,
}));
}
let mechanicalOptions = { lid: true, explode: false, overlay: false };
function syncMechanicalButtons() {
['lid', 'explode', 'overlay'].forEach((option) => {
const on = !!mechanicalOptions[option];
const btn = document.getElementById(`btn-mechanical-${option}`);
if (!btn) return;
btn.classList.toggle('is-on', on);
btn.setAttribute('aria-pressed', String(on));
});
}
function syncMechanicalOverlay() {
if (!window.Tscircuit) return;
window.Tscircuit.setMechanicalOverlay(mechanicalOptions.overlay, currentView === '3d', hosts['3d']);
}
function toggleMechanical(option) {
if (!window.Tscircuit) return;
if (option === 'overlay') {
mechanicalOptions.overlay = !mechanicalOptions.overlay;
syncMechanicalOverlay();
} else {
mechanicalOptions[option] = window.Tscircuit.toggleMechanicalOption(option);
}
syncMechanicalButtons();
}
function initMechanical() {
mechanicalOptions = window.Tscircuit.getMechanicalOptions();
syncMechanicalButtons();
syncMechanicalOverlay();
}
let webglAvailable = null;
function hasWebgl() {
if (webglAvailable === null) {
const gl = document.createElement('canvas').getContext('webgl2') || document.createElement('canvas').getContext('webgl');
webglAvailable = !!gl;
gl?.getExtension('WEBGL_lose_context')?.loseContext();
}
return webglAvailable;
}
function showStatus(message) {
const status = document.getElementById('studio-status');
status.textContent = message;
status.hidden = !message;
}
function renderView() {
const ts = window.Tscircuit;
if (!ts) return;
const blocked = ['3d', 'mechanical', 'assembly'].includes(currentView) && !hasWebgl();
showStatus(blocked ? 'WEBGL IS UNAVAILABLE. TURN ON HARDWARE ACCELERATION IN YOUR BROWSER TO USE THE 3D VIEWS.' : '');
if (blocked) return;
if (currentView === 'mechanical') {
ts.renderMechanicalViewer(hosts.mechanical);
} else if (currentView === 'assembly') {
ts.renderAssemblyViewer(hosts.assembly);
} else if (lastCircuitJson) {
ts[renderers[currentView]](hosts[currentView], lastCircuitJson, (ref) => selectComponent(ref));
}
}
function switchView(view) {
currentView = view;
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.view === view);
});
document.getElementById('controls-2d').style.display = view === 'schematic' || view === 'pcb' ? 'flex' : 'none';
document.getElementById('controls-3d').style.display = view === '3d' ? 'flex' : 'none';
document.getElementById('controls-mechanical').style.display = view === 'mechanical' ? 'flex' : 'none';
document.getElementById('controls-assembly').style.display = view === 'assembly' ? 'flex' : 'none';
Object.entries(hosts).forEach(([name, host]) => {
host.style.display = name === view ? 'block' : 'none';
});
hideTooltip();
syncMechanicalOverlay();
if (view !== 'mechanical' && window.Tscircuit) window.Tscircuit.hideMechanicalViewer();
if (view !== 'assembly' && window.Tscircuit) window.Tscircuit.hideAssemblyViewer();
renderView();
if (view === '3d') setCameraPreset('ISO');
if (view === 'schematic') {
window.dispatchEvent(new Event('resize'));
setTimeout(() => window.dispatchEvent(new Event('resize')), 10);
}
}
function buildAssembly() {
window.Project.startRun('assembly');
}
async function syncAssemblyTab() {
try {
const response = await fetch('/api/project');
const project = await response.json();
const tab = document.getElementById('tab-assembly');
tab.hidden = !project.assembly;
if (tab.hidden && currentView === 'assembly') switchView('schematic');
} catch (err) {
console.warn('cannot read the project', err);
}
}
function onAssemblyRun(data) {
if (data.event !== 'run' || data.status === 'running' || !String(data.label || '').startsWith('assembly')) return;
syncAssemblyTab();
if (window.Tscircuit) window.Tscircuit.refreshAssembly();
}
syncAssemblyTab();
function setCameraPreset(preset) {
const ts = window.Tscircuit;
if (!ts) return;
if (currentView === 'mechanical') {
ts.mechanicalCameraPreset(preset);
} else if (currentView === 'assembly') {
ts.assemblyCameraPreset(preset);
} else if (window.cadCameraController) {
ts.animateCadCameraPreset(hosts['3d'], window.cadCameraController, preset);
}
}
function switchSidebarTab(tabId) {
document.querySelectorAll('.sidebar-tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tabId);
});
document.querySelectorAll('.sidebar-pane').forEach(p => p.classList.remove('active'));
const activePane = document.getElementById(`pane-${tabId}`);
if (activePane) activePane.classList.add('active');
}
function toggleSidebar(open, persist = true) {
const collapsed = !open;
document.body.classList.toggle('sidebar-collapsed', collapsed);
document.querySelector('.sidebar-card').inert = collapsed;
if (!persist) return;
try {
localStorage.setItem('fusible.sidebarCollapsed', collapsed ? '1' : '0');
} catch (err) {
console.warn('cannot remember the panel state', err);
}
}
function openSidebar(tabId) {
switchSidebarTab(tabId);
toggleSidebar(true);
}
try {
if (localStorage.getItem('fusible.sidebarCollapsed') === '1') toggleSidebar(false);
} catch (err) {}
document.getElementById('sidebar').addEventListener('transitionend', (e) => {
if (e.target.id === 'sidebar' && e.propertyName === 'width') {
window.dispatchEvent(new Event('resize'));
}
});
window.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.metaKey || e.altKey || e.target.closest('input, textarea, select, [contenteditable]')) return;
const views = { '1': 'schematic', '2': 'pcb', '3': '3d', '4': 'mechanical', '5': 'assembly' };
if (views[e.key] === 'assembly' && document.getElementById('tab-assembly').hidden) return;
if (views[e.key]) {
if (views[e.key] !== currentView) switchView(views[e.key]);
} else if (e.key === '\\') {
toggleSidebar(document.body.classList.contains('sidebar-collapsed'));
}
});
function showTooltip(x, y, data) {
if (!data) return;
let html = `<div class="tooltip-title">${escapeHtml(data.name || data.ref || 'ELEMENT')}</div>`;
if (data.type) html += `<div class="tooltip-meta">${escapeHtml(data.type.toUpperCase())}</div>`;
if (data.part || data.mpn) html += `<div class="tooltip-row"><span>MPN/PART:</span><span class="tooltip-val">${escapeHtml(data.part || data.mpn)}</span></div>`;
if (data.net) html += `<div class="tooltip-row"><span>NET:</span><span class="tooltip-val" style="color:var(--blue)">${escapeHtml(data.net)}</span></div>`;
if (data.pos) html += `<div class="tooltip-row"><span>POS:</span><span class="tooltip-val">${data.pos}</span></div>`;
if (data.layer) html += `<div class="tooltip-row"><span>LAYER:</span><span class="tooltip-val">${escapeHtml(data.layer)}</span></div>`;
if (data.pins !== undefined && data.pins !== null) html += `<div class="tooltip-row"><span>PINS:</span><span class="tooltip-val">${data.pins}</span></div>`;
if (data.extra) html += `<div class="tooltip-row"><span>INFO:</span><span class="tooltip-val">${escapeHtml(String(data.extra))}</span></div>`;
tooltip.innerHTML = html;
tooltip.style.left = `${x + 14}px`;
tooltip.style.top = `${y + 14}px`;
tooltip.style.display = 'block';
}
function hideTooltip() {
tooltip.style.display = 'none';
}
function getElementsArray() {
if (!lastCircuitJson) return [];
if (Array.isArray(lastCircuitJson)) return lastCircuitJson;
return Array.isArray(lastCircuitJson.elements) ? lastCircuitJson.elements : [];
}
function parseCircuitGraph() {
componentsMap = {};
netsMap = {};
const elements = getElementsArray();
const sourceCompMap = {};
elements.filter(e => e.type === 'source_component').forEach(sc => {
sourceCompMap[sc.source_component_id || sc.component_id] = {
name: sc.name || sc.component_id || 'U',
mpn: sc.manufacturer_part_number || '',
ftype: sc.ftype || sc.kind || ''
};
});
const portNetMap = {};
elements.filter(e => e.type === 'source_port').forEach(sp => {
portNetMap[sp.source_port_id || sp.port_id] = {
name: sp.name || '',
pin: sp.pin_number || ''
};
});
elements.filter(e => e.type === 'source_net').forEach(sn => {
const netName = sn.name || sn.net_id || 'NET';
netsMap[netName] = {
name: netName,
count: 0
};
});
elements.filter(e => e.type === 'pcb_trace').forEach(t => {
const netName = t.connection_name;
if (netName) {
if (!netsMap[netName]) netsMap[netName] = { name: netName, count: 0 };
netsMap[netName].count++;
}
});
elements.filter(e => e.type === 'pcb_component').forEach(pc => {
const src = sourceCompMap[pc.source_component_id] || {};
const name = src.name || pc.pcb_component_id || 'COMP';
const cx = pc.center?.x ?? pc.x ?? 0;
const cy = pc.center?.y ?? pc.y ?? 0;
const cw = pc.width || 4;
const ch = pc.height || 4;
componentsMap[name] = {
name,
mpn: src.mpn,
ftype: src.ftype,
x: cx,
y: cy,
w: cw,
h: ch,
layer: pc.layer || 'top',
id: pc.pcb_component_id,
sourceComponentId: pc.source_component_id,
pcbComponentId: pc.pcb_component_id,
schematicComponentId: null,
pins: []
};
});
if (Object.keys(componentsMap).length === 0) {
Object.entries(sourceCompMap).forEach(([id, src]) => {
const name = src.name || id;
componentsMap[name] = {
name,
mpn: src.mpn,
ftype: src.ftype,
x: 0,
y: 0,
w: 4,
h: 4,
layer: 'top',
id,
sourceComponentId: id,
pcbComponentId: id,
schematicComponentId: null,
pins: []
};
});
}
elements.filter(e => e.type === 'schematic_component').forEach(sc => {
const src = sourceCompMap[sc.source_component_id] || {};
const name = src.name;
if (name && componentsMap[name]) {
componentsMap[name].schematicComponentId = sc.schematic_component_id;
if (sc.port_labels) {
for (const pin in sc.port_labels) {
componentsMap[name].pins.push({
pin,
net: sc.port_labels[pin]
});
}
}
}
});
renderComponentsList();
renderNetsList();
}
function renderComponentsList() {
const container = document.getElementById('components-list');
const names = Object.keys(componentsMap).sort();
if (names.length === 0) {
container.innerHTML = '<div class="empty-state"><span>NO COMPONENTS PARSED</span></div>';
return;
}
container.innerHTML = names.map(name => {
const c = componentsMap[name];
return `
<div class="comp-item ${selectedComponent === name ? 'selected' : ''}" data-name="${escapeHtml(name)}">
<div>
<div>${escapeHtml(name)}</div>
<div class="comp-sub">${escapeHtml(c.mpn || c.ftype || '')}</div>
</div>
<div class="comp-sub">(${c.x.toFixed(1)}, ${c.y.toFixed(1)})</div>
</div>
`;
}).join('');
}
function renderNetsList() {
const container = document.getElementById('nets-list');
const names = Object.keys(netsMap).sort();
if (names.length === 0) {
container.innerHTML = '<div class="empty-state"><span>NO NETS DETECTED</span></div>';
return;
}
container.innerHTML = names.map(name => `
<div class="net-item" data-name="${escapeHtml(name)}">
<span>${escapeHtml(name)}</span>
<span style="color:var(--text-subtle);font-size:10px">${netsMap[name].count} segments</span>
</div>
`).join('');
}
let pinnedComponent = null;
function findComponent(key) {
if (!key) return null;
if (componentsMap[key]) return componentsMap[key];
const normalized = String(key).trim();
if (componentsMap[normalized]) return componentsMap[normalized];
for (const c of Object.values(componentsMap)) {
if (
c.name === normalized ||
c.id === normalized ||
c.pcbComponentId === normalized ||
c.sourceComponentId === normalized ||
c.schematicComponentId === normalized
) {
return c;
}
}
return null;
}
function resetInspector() {
if (pinnedComponent) return;
const panel = document.getElementById('inspector-content');
if (panel) {
panel.innerHTML = '<div class="empty-state"><span>SELECT OR HOVER A COMPONENT OR PAD TO INSPECT</span></div>';
}
}
function inspectComponent(compOrName, isPinned = true) {
const comp = typeof compOrName === 'string' ? findComponent(compOrName) : compOrName;
if (!comp) {
if (!isPinned && !pinnedComponent) resetInspector();
return;
}
if (isPinned) {
pinnedComponent = comp.name;
selectedComponent = comp.name;
renderComponentsList();
}
const panel = document.getElementById('inspector-content');
if (!panel) return;
let pinsHtml = '';
if (comp.pins && comp.pins.length > 0) {
pinsHtml = `
<div class="pins-list">
<div class="pins-title">CONNECTED PINS & SIGNALS</div>
${comp.pins.map(p => `
<div class="pin-item" data-net="${escapeHtml(p.net)}">
<span>${escapeHtml(p.pin)}</span>
<span class="pin-net">${escapeHtml(p.net)}</span>
</div>
`).join('')}
</div>
`;
}
panel.innerHTML = `
<div class="inspector-header">
<div>
<span class="inspector-name">${escapeHtml(comp.name)}</span>
${!isPinned ? '<span style="font-size:9px;color:var(--olive);margin-left:6px;vertical-align:middle;">[PREVIEW]</span>' : ''}
</div>
<span class="inspector-type">${escapeHtml(comp.ftype || 'COMPONENT')}</span>
</div>
${comp.mpn ? `<div class="prop-row"><span class="prop-key">MPN</span><span class="prop-val">${escapeHtml(comp.mpn)}</span></div>` : ''}
<div class="prop-row"><span class="prop-key">CENTER</span><span class="prop-val">(${comp.x.toFixed(2)}, ${comp.y.toFixed(2)}) mm</span></div>
${comp.w && comp.h ? `<div class="prop-row"><span class="prop-key">FOOTPRINT</span><span class="prop-val">${comp.w.toFixed(1)} x ${comp.h.toFixed(1)} mm</span></div>` : ''}
<div class="prop-row"><span class="prop-key">LAYER</span><span class="prop-val">${escapeHtml((comp.layer || 'top').toUpperCase())}</span></div>
${pinsHtml}
`;
}
function selectComponent(idOrName) {
const comp = findComponent(idOrName);
if (!comp) return;
switchSidebarTab('inspector');
inspectComponent(comp, true);
}
function selectNet(netName) {
document.querySelectorAll('.net-item').forEach(el => {
el.classList.toggle('selected', el.dataset.name === netName);
});
showTooltip(window.innerWidth / 2, 72, {
name: `NET: ${netName}`,
type: 'SIGNAL TRACE HIGHLIGHT',
net: netName
});
setTimeout(hideTooltip, 2000);
}
function updateDiagnostics(fuseBlown, diagnostics) {
const statusEl = document.getElementById('fuse-status');
const textEl = document.getElementById('fuse-text');
const iconEl = document.getElementById('fuse-icon');
const diagPanel = document.getElementById('diagnostics-panel');
if (fuseBlown) {
statusEl.className = 'fuse-badge fuse-blown';
textEl.textContent = `FUSE BLOWN · ${diagnostics.length}`;
if (iconEl) iconEl.innerHTML = '<iconify-icon icon="pixelarticons:alert"></iconify-icon>';
diagPanel.innerHTML = diagnostics.map(d => `
<div class="diag-card">
<div class="diag-title">[!] ${escapeHtml((d.rule || 'VIOLATION').toUpperCase())}</div>
<div class="diag-body">${escapeHtml(d.message || '')}</div>
${d.help ? `<div class="diag-help">> ${escapeHtml(d.help)}</div>` : ''}
</div>
`).join('');
} else {
statusEl.className = 'fuse-badge fuse-intact';
textEl.textContent = 'FUSE INTACT';
if (iconEl) iconEl.innerHTML = '<iconify-icon icon="pixelarticons:zap"></iconify-icon>';
diagPanel.innerHTML = '<div class="empty-state"><span>NO ELECTRICAL VIOLATIONS DETECTED.</span></div>';
}
}
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function delegateClick(containerId, selector, handler) {
document.getElementById(containerId).addEventListener('click', (event) => {
const item = event.target.closest(selector);
if (item) handler(item);
});
}
function updateStudio(data) {
if (!data) return;
if (data.board_name) {
document.getElementById('board-name').textContent = data.board_name.toUpperCase();
}
if (typeof data.revision === 'string') {
lastRevision = data.revision;
lastBoard = data.board || '';
if (window.Tscircuit) window.Tscircuit.setMechanicalRevision(lastRevision, lastBoard);
}
if (data.circuit_json) {
lastCircuitJson = data.circuit_json;
parseCircuitGraph();
}
updateDiagnostics(data.fuse_blown, data.diagnostics || []);
if (currentView !== 'mechanical') renderView();
}
window.addEventListener('tscircuit-ready', () => {
if (lastRevision !== null) window.Tscircuit.setMechanicalRevision(lastRevision, lastBoard);
initMechanical();
switchView(currentView);
});
setTimeout(() => {
if (!window.Tscircuit) showStatus('STUDIO BUNDLE NOT LOADED. RUN `bun run build` IN studio/');
}, 2000);
function connectSSE() {
const eventSource = new EventSource('/events');
const liveDot = document.getElementById('live-dot');
const liveIndicator = document.getElementById('live-indicator');
eventSource.onopen = () => {
liveDot.style.background = 'var(--green)';
liveIndicator.title = 'Live reload connected';
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
onAssemblyRun(data);
if (!window.Project.handle(data)) updateStudio(data);
} catch (err) {
console.error(err);
}
};
eventSource.onerror = () => {
liveDot.style.background = 'var(--alert)';
liveIndicator.title = 'Reconnecting to dev server';
};
}
const schEl = document.getElementById('schematic-container');
if (schEl) {
schEl.addEventListener('mousemove', (e) => {
const compEl = e.target.closest('[data-schematic-component-id]');
if (compEl) {
const id = compEl.getAttribute('data-schematic-component-id');
const comp = findComponent(id);
if (comp) {
inspectComponent(comp, false);
showTooltip(e.clientX, e.clientY, {
name: comp.name,
type: comp.ftype || 'component',
part: comp.mpn,
pos: `(${comp.x.toFixed(2)}, ${comp.y.toFixed(2)}) mm`,
layer: comp.layer || 'top',
extra: `${comp.pins.length} pins`
});
return;
}
}
const portEl = e.target.closest('[data-schematic-port-id]');
if (portEl) {
const portId = portEl.getAttribute('data-schematic-port-id');
showTooltip(e.clientX, e.clientY, {
name: `PORT: ${portId}`,
type: 'PORT'
});
return;
}
hideTooltip();
if (!pinnedComponent) resetInspector();
});
schEl.addEventListener('mouseleave', () => {
hideTooltip();
if (!pinnedComponent) resetInspector();
});
schEl.addEventListener('click', (e) => {
const compEl = e.target.closest('[data-schematic-component-id]');
if (compEl) {
const id = compEl.getAttribute('data-schematic-component-id');
const comp = findComponent(id);
if (comp) selectComponent(comp.name);
}
});
}
const pcbEl = document.getElementById('pcb-container');
if (pcbEl) {
pcbEl.addEventListener('mousemove', (e) => {
const compEl = e.target.closest('[data-pcb-component-id]') || e.target.closest('[data-component-id]');
if (compEl) {
const id = compEl.getAttribute('data-pcb-component-id') || compEl.getAttribute('data-component-id');
const comp = findComponent(id);
if (comp) {
inspectComponent(comp, false);
showTooltip(e.clientX, e.clientY, {
name: comp.name,
type: comp.ftype || 'component',
part: comp.mpn,
pos: `(${comp.x.toFixed(2)}, ${comp.y.toFixed(2)}) mm`,
layer: comp.layer || 'top',
extra: `${comp.pins.length} pins`
});
return;
}
}
});
pcbEl.addEventListener('mouseleave', () => {
hideTooltip();
if (!pinnedComponent) resetInspector();
});
}
delegateClick('components-list', '.comp-item', (item) => selectComponent(item.dataset.name));
delegateClick('nets-list', '.net-item', (item) => selectNet(item.dataset.name));
delegateClick('inspector-content', '.pin-item', (item) => selectNet(item.dataset.net));
window.Project.init().finally(connectSSE);
</script>
</body>
</html>