diff --git a/extras/board_spec/dist/board_spec_editor.html b/extras/board_spec/dist/board_spec_editor.html index 49111528..63353fbb 100644 --- a/extras/board_spec/dist/board_spec_editor.html +++ b/extras/board_spec/dist/board_spec_editor.html @@ -53,9 +53,9 @@ .onboarding-heading .eyebrow { color: var(--accent); } .onboarding-heading button { flex: none; padding: .32rem .55rem; } .onboarding-purpose { margin: .35rem 0 .65rem; color: #38525c; font-size: .78rem; } -.onboarding > .motion-region-inner > ol { display: grid; grid-template-columns: repeat(4, minmax(8rem, 1fr)); gap: .55rem; margin: 0; padding: 0; list-style: none; } -.onboarding > .motion-region-inner > ol > li { display: flex; align-items: start; gap: .4rem; color: #38525c; font-size: .72rem; } -.onboarding > .motion-region-inner > ol > li > strong { display: grid; flex: none; place-items: center; inline-size: 1.45rem; block-size: 1.45rem; border-radius: 50%; background: var(--accent); color: #fff; } +.onboarding > ol { display: grid; grid-template-columns: repeat(4, minmax(8rem, 1fr)); gap: .55rem; margin: 0; padding: 0; list-style: none; } +.onboarding > ol > li { display: flex; align-items: start; gap: .4rem; color: #38525c; font-size: .72rem; } +.onboarding > ol > li > strong { display: grid; flex: none; place-items: center; inline-size: 1.45rem; block-size: 1.45rem; border-radius: 50%; background: var(--accent); color: #fff; } .onboarding-export details { margin-top: .25rem; } .onboarding-export summary { cursor: pointer; color: var(--accent); font-weight: 750; } .onboarding-export ol { display: grid; gap: .15rem; margin: .25rem 0; padding-left: 1.2rem; } @@ -144,7 +144,6 @@ .gpio-row:focus { outline: 2px solid #005fcc; outline-offset: -2px; } .gpio-row.is-expanded td { background-color: #eaf6f5; border-bottom-color: #b8d9d7; } .gpio-row.is-readonly td { color: #53656d; } -.gpio-row.diagram-highlight td { background-color: #fff1b8; } tr.gpio-reserved td:first-child { background: var(--reserved); } tr.gpio-input td:first-child { box-shadow: inset 4px 0 #6699cc; } tr.gpio-strapping td:first-child { border-right: 5px solid #e1b12c; } @@ -245,56 +244,34 @@ .error-list .warning button { background: #fff8df; color: var(--warning); border-color: #e7c86e; } .error-list code { display: block; margin-top: .2rem; color: #7d3b34; font-size: .72rem; white-space: normal; } .validation-action { display: block; margin-top: .3rem; color: inherit; font-size: .7rem; line-height: 1.35; } -.connector-tabs { display: flex; gap: .3rem; padding: .55rem .65rem; overflow-x: auto; border-bottom: 1px solid var(--line); } +.connector-tabs { display: flex; gap: .3rem; padding: .55rem .65rem; flex-wrap: wrap; border-bottom: 1px solid var(--line); } .connector-card { position: relative; } -.connector-actions, .connector-view-toggle { display: flex; align-items: center; gap: .3rem; } -.connector-actions { justify-content: flex-end; padding: 0 .65rem .55rem; } -.connector-view-toggle { padding: .18rem; border: 1px solid #afbec7; border-radius: 7px; background: #edf2f3; } -.connector-view-toggle button { padding: .2rem .42rem; border: 0; background: transparent; color: var(--muted); font-size: .7rem; } -.connector-view-toggle button.active { background: #fff; color: var(--accent); box-shadow: 0 1px 3px rgb(19 43 53 / 18%); } .connector-tabs button, .position-editor-tabs button { padding: .3rem .5rem; background: #fff; color: var(--muted); border-color: #afbec7; font-size: .72rem; white-space: nowrap; } .connector-tabs button.active, .position-editor-tabs button.active { background: var(--accent); color: #fff; border-color: var(--accent); } .connector-tabs button.connector-object-tab { border-left-color: var(--role-bg); border-left-width: 5px; } -.connector-diagram { padding: .5rem; overflow: auto; background: #f8fafb; } -.connector-diagram svg { display: block; width: 100%; min-width: 14rem; height: auto; margin: auto; } +.connector-pinout { padding: .5rem; overflow: auto; background: #f8fafb; } .connector-meta { display: flex; justify-content: space-between; gap: .5rem; margin: 0 .25rem .35rem; color: var(--muted); font-size: .68rem; } -.connector-position { cursor: pointer; outline: none; } -.connector-position circle { fill: var(--position-color, #fff); stroke: #405c67; stroke-width: 2; } -.connector-position.default { opacity: .58; } -.connector-position.default circle { stroke-dasharray: 3 3; stroke-width: 1.5; } -.connector-position.fixed circle { fill: var(--position-color, #aab5bb); stroke: #66747b; } -.connector-position.missing circle { fill: #fff; stroke: #c35347; stroke-dasharray: 4 3; } -.connector-position.selected circle { stroke: #006d77; stroke-width: 5; } -.connector-position.gpio-highlight circle { filter: drop-shadow(0 0 5px #e6a700); stroke: #e6a700; stroke-width: 5; } -.connector-position:focus circle { stroke: #005fcc; stroke-width: 5; } -.connector-position text { pointer-events: none; text-anchor: middle; fill: var(--role-fg, #17212b); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } -.connector-key { fill: var(--wash); stroke: #607080; stroke-width: 2; } -.connector-position.fixed text { fill: #fff; } -.position-id { font-size: 9px; font-weight: 800; } -.position-label { font-size: 8px; } -.position-endpoint { font-size: 9px; font-weight: 800; } -.connector-pin-table { width: 100%; border-collapse: separate; border-spacing: 0; table-layout: fixed; font: .7rem ui-monospace, SFMono-Regular, Menlo, monospace; } +.connector-pin-table { width: 100%; border-collapse: separate; border-spacing: 0; table-layout: auto; font: .7rem ui-monospace, SFMono-Regular, Menlo, monospace; } .connector-pin-table tr { height: 1.8rem; } .connector-pin-table td { height: 1.8rem; padding: .28rem .35rem; overflow: hidden; border-bottom: 1px solid #dfe6e9; background: #fff; cursor: pointer; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; } .connector-pin-table td:focus { position: relative; z-index: 1; outline: 2px solid #005fcc; outline-offset: -2px; } -.connector-pin-table .pin-number { width: 2.8rem; color: var(--muted); font-weight: 800; text-align: center; white-space: nowrap; } +.connector-pin-table .pin-number { width: 1%; color: var(--muted); font-weight: 800; text-align: center; white-space: nowrap; } .connector-pin-table .pin-function { color: #506771; font-size: .64rem; } -.connector-pin-table .pin-endpoint { font-weight: 800; } +.connector-pin-table .pin-endpoint { width: 1%; white-space: nowrap; font-weight: 800; } .connector-pin-table .pin-left { text-align: right; } .connector-pin-table .pin-right { text-align: left; } .connector-pin-table .pin-divider { border-left: 2px solid #91a5b1; } .connector-pin-table td.default { background: #f5f7f8; color: #829098; } .connector-pin-table td.fixed { background: #d9dfe2; color: #465860; } .connector-pin-table td.missing { border-top: 1px dashed #c35347; border-bottom: 1px dashed #c35347; background: #fffafa; } -.connector-pin-table td.missing.pin-endpoint.pin-left, .connector-pin-table td.missing.pin-number:not(.pin-left, .pin-right) { border-left: 1px dashed #c35347; } -.connector-pin-table td.missing.pin-number.pin-left, .connector-pin-table td.missing.pin-endpoint:not(.pin-left, .pin-right) { border-right: 1px dashed #c35347; } +.connector-pin-table td.missing.pin-function.pin-left, .connector-pin-table td.missing.pin-number:not(.pin-left, .pin-right) { border-left: 1px dashed #c35347; } +.connector-pin-table td.missing.pin-number.pin-left, .connector-pin-table td.missing.pin-function:not(.pin-left, .pin-right) { border-right: 1px dashed #c35347; } .connector-pin-table td.missing.pin-number.pin-right { border-left: 1px dashed #c35347; } -.connector-pin-table td.missing.pin-endpoint.pin-right { border-right: 1px dashed #c35347; } +.connector-pin-table td.missing.pin-function.pin-right { border-right: 1px dashed #c35347; } .connector-pin-table td.selected { background: #d7efed; box-shadow: inset 0 2px #006d77, inset 0 -2px #006d77; } .connector-pin-table td.gpio-highlight { background: #fff1b8; box-shadow: inset 0 2px #e6a700, inset 0 -2px #e6a700; } .connector-color { display: inline-block; width: .65rem; height: .65rem; margin-right: .3rem; border: 1px solid #73828a; border-radius: 50%; vertical-align: -.08rem; background: var(--position-color, #fff); } .connector-editor { padding: .65rem; border-top: 1px solid var(--line); } -.connector-editor[hidden] { display: none; } .connector-editor.open { background: #fff; } .connector-pin-table tr.connector-inline-row { height: auto; } .connector-pin-table td.connector-inline-cell { height: auto; padding: 0; overflow: visible; border-bottom: 1px solid #91a5b1; cursor: default; white-space: normal; } @@ -396,7 +373,7 @@ .motion-details[open]:not(.motion-opening, .motion-closing) > .motion-collapse { grid-template-rows: 1fr; opacity: 1; visibility: visible; transition-delay: 0s; } .motion-details.motion-opening > .motion-collapse, .motion-details.motion-closing > .motion-collapse { grid-template-rows: 0fr; opacity: 0; visibility: hidden; } .motion-details > summary { cursor: pointer; } -.role-chip, .role-owner-label, .related-object-link, .owner-object-tab, .connector-object-tab, .connector-position circle { transition: color var(--motion-duration) var(--motion-ease), background-color var(--motion-duration) var(--motion-ease), fill var(--motion-duration) var(--motion-ease), stroke var(--motion-duration) var(--motion-ease), border-color var(--motion-duration) var(--motion-ease); } +.role-chip, .role-owner-label, .related-object-link, .owner-object-tab, .connector-object-tab { transition: color var(--motion-duration) var(--motion-ease), background-color var(--motion-duration) var(--motion-ease), fill var(--motion-duration) var(--motion-ease), stroke var(--motion-duration) var(--motion-ease), border-color var(--motion-duration) var(--motion-ease); } @keyframes state-change { from { opacity: .35; } to { opacity: 1; } } ::view-transition-old(root), ::view-transition-new(root) { animation-duration: var(--motion-duration); animation-timing-function: var(--motion-ease); } @@ -415,7 +392,7 @@ .dialog-error.warning { color: var(--warning); } @media (max-width: 980px) { - .onboarding > .motion-region-inner > ol { grid-template-columns: repeat(2, minmax(8rem, 1fr)); } + .onboarding > ol { grid-template-columns: repeat(2, minmax(8rem, 1fr)); } .repository-next { margin-inline: 1rem; } .repository-next .motion-region-inner { align-items: start; flex-direction: column; } .repository-next ol { display: grid; } @@ -445,34 +422,187 @@ .save-state { font-size: .75rem; } .language-picker select { width: 7.25rem; } .onboarding { margin: .5rem 1rem 0; } - .onboarding > .motion-region-inner > ol { grid-template-columns: 1fr; } + .onboarding > ol { grid-template-columns: 1fr; } +} + +.connector-meta { display: grid; justify-content: initial; gap: .3rem; font-size: .75rem; overflow-wrap: anywhere; } +.connector-note { margin: 0; white-space: normal; line-height: 1.45; } +.reference-links { display: flex; flex-wrap: wrap; gap: .3rem; font-size: .7rem; } +.reference-links a { color: var(--accent); } +.connector-pin-table th { padding: .35rem; background: var(--wash); color: var(--muted); text-align: center; } +.connector-pin-table .pin-function { white-space: normal; overflow-wrap: anywhere; text-overflow: clip; } + +/* Compact controls leave the existing workspace its full remaining height. */ +.app-header.toolbar { position: relative; display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; min-height: 3.5rem; padding: .4rem .75rem; background: var(--paper); color: var(--ink); z-index: 20; } +.app-header.toolbar .board-picker { display: block; min-width: 13rem; flex: 1 1 15rem; max-width: 19rem; } +.app-header.toolbar .board-combobox { width: 100%; } +.app-header.toolbar .board-combobox input { padding-block: .4rem; font-size: .78rem; } +.app-header.toolbar button, .header-menu > summary { padding: .4rem .5rem; font-size: .75rem; white-space: nowrap; } +.app-header.toolbar .header-controls { margin-left: auto; flex-direction: row; gap: .4rem; } +.app-header.toolbar .help-button { color: var(--accent); background: var(--wash); border-color: var(--line); } +.app-header.toolbar .language-picker { display: flex; color: var(--muted); font-size: .7rem; } +.app-header.toolbar .language-picker select { min-width: 0; width: 6.5rem; padding: .3rem; font-size: .7rem; color: var(--ink); background: #fff; border-color: var(--line); } +.app-header.toolbar .save-state { font-size: .72rem; color: var(--muted); } +.app-header.toolbar .save-state.dirty { color: var(--warning); } +.option-panel { align-items: center; gap: .35rem; min-height: 0; padding: 0; overflow: visible; } +.option-panel .revision-control { display: block; } +.option-panel select { min-width: 0; max-width: 8rem; padding: .35rem; font-size: .75rem; } +.runtime-control, .header-menu { position: relative; } +.runtime-toggle { display: flex; align-items: center; gap: .3rem; } +.runtime-toggle span { display: inline-block; max-width: 7rem; overflow: hidden; text-overflow: ellipsis; } +.header-menu > summary { list-style: none; cursor: pointer; border: 1px solid #aebbc5; border-radius: 6px; } +.header-menu > summary::-webkit-details-marker { display: none; } +.header-menu-panel, .runtime-panel { position: absolute; top: calc(100% + .35rem); left: 0; z-index: 30; display: grid; gap: .4rem; width: max-content; min-width: 12rem; max-width: min(24rem, calc(100vw - 1.5rem)); max-height: 65dvh; overflow: auto; padding: .65rem; border: 1px solid var(--line); border-radius: 8px; background: #fff; box-shadow: 0 8px 24px rgb(19 43 53 / 18%); } +.runtime-panel[hidden] { display: none; } +.header-menu-panel button, .header-menu-panel .file-button { width: 100%; text-align: left; } +.runtime-panel label { display: grid; gap: .25rem; } +.runtime-panel select { width: 100%; max-width: none; } +.view-tabs { gap: .25rem; padding: .2rem .75rem .3rem; flex-wrap: wrap; overflow: visible; } +.view-tabs button { padding: .2rem .5rem; font-size: .72rem; } +.board-info > summary { min-height: 1.8rem; padding: .2rem .65rem; font-size: .75rem; } +.onboarding { width: min(54rem, calc(100vw - 2rem)); max-height: 85dvh; overflow: auto; margin: auto; padding: 1rem; border: 1px solid var(--line); border-radius: 10px; } +.onboarding::backdrop { background: rgb(3 20 25 / 50%); } +.onboarding h1 { margin: 0 0 .4rem; font-size: 1.2rem; } +.repository-next { position: fixed; right: .75rem; bottom: .75rem; z-index: 40; width: min(25rem, calc(100vw - 1.5rem)); max-height: 50dvh; overflow: auto; margin: 0; } +.repository-next.is-open { margin: 0; padding: .65rem; border-width: 1px; } +.repository-next .motion-region-inner { display: block; } +.repository-next ol { display: grid; margin-top: .5rem; } +.repository-next [data-dismiss-export] { float: right; padding: 0 .35rem; margin-left: .5rem; } +.global-drop-hint { display: none; position: fixed; inset: 0; z-index: 100; align-items: center; justify-content: center; padding: 1rem; background: rgb(0 109 119 / 18%); border: 4px dashed var(--accent); color: var(--accent); font-size: 1.3rem; font-weight: 800; pointer-events: none; } +body.dragging .global-drop-hint { display: flex; } +@media (max-width: 760px) { + .app-header.toolbar .board-picker { flex: 1 1 14rem; max-width: none; } + .app-header.toolbar .header-controls { margin-left: auto; } + .runtime-toggle span { max-width: 5rem; } +} + +/* All page content remains mounted under one delegated editing root. */ +.workspace { display: grid; grid-template-columns: 13rem minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); gap: .6rem; } +.workspace.nav-collapsed { grid-template-columns: 2.8rem minmax(0, 1fr); } +.page-nav { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 8px; background: var(--paper); } +.nav-heading { display: flex; align-items: center; justify-content: space-between; gap: .35rem; padding: .4rem; flex: none; font-size: .75rem; } +.nav-heading button { padding: .25rem .4rem; } +#page-nav-items { flex: 1 1 auto; min-height: 0; overflow: auto; padding: .25rem; } +#page-nav-items[hidden] { display: none; } +.page-nav .nav-item, .nav-validation { display: flex; align-items: center; justify-content: space-between; gap: .4rem; width: 100%; padding: .5rem .45rem; background: transparent; color: var(--ink); border: 0; border-radius: 5px; text-align: left; font-size: .76rem; } +.nav-item span { min-width: 0; overflow-wrap: anywhere; } +.nav-item small { flex: none; color: var(--muted); } +.page-nav [aria-current="page"] { background: var(--accent-soft); color: var(--accent); font-weight: 800; } +.nav-connector-heading { display: flex; align-items: center; } +.nav-connector-heading > .nav-item { flex: 1; } +.nav-connector-heading > button.secondary { flex: none; padding: .25rem; } +.nav-children { margin-left: .55rem; padding-left: .25rem; border-left: 2px solid var(--line); } +.nav-children[hidden] { display: none; } +.nav-children .nav-item { font-family: ui-monospace, monospace; font-size: .72rem; } +.nav-validation { flex: none; border-top: 1px solid var(--line); border-radius: 0; } +.nav-collapsed .nav-heading strong, .nav-collapsed .nav-validation > span { display: none; } +.nav-collapsed .nav-heading, .nav-collapsed .nav-validation { justify-content: center; } +.page-area { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; } +.page-heading { display: flex; flex: none; align-items: center; gap: .5rem; min-height: 2rem; margin-bottom: .35rem; } +.page-heading h2 { margin: 0; font-size: .9rem; overflow-wrap: anywhere; } +.page-heading button { padding: .25rem .45rem; font-size: .72rem; } +.page-host { flex: 1 1 auto; min-width: 0; min-height: 0; overflow: auto; } +.configuration-pages, .related-pages, #related-list { display: contents; } +[data-page][hidden] { display: none !important; } +.page-host > .gpio-card { height: 100%; min-height: 0; } +.page-host > .board-info > strong { display: block; padding: .6rem; font-size: .8rem; } +.page-host > .connector-card { display: flex; flex-direction: column; height: 100%; min-height: 0; overflow: hidden; } +.connector-heading { flex: none; display: flex; align-items: center; flex-wrap: wrap; gap: .45rem; } +.page-host > .connector-card > .sidebar-section-body { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; overflow: hidden; } +.connector-card .section-description { flex: none; } +.connector-card .connector-pinout { flex: 1 1 auto; min-height: 0; overflow: auto; } +.page-host .revision-table-wrap, .page-host .owner-table-wrap { max-height: none; } +@media (max-width: 980px) { + .workspace, .workspace.nav-collapsed { display: flex; flex-direction: column; overflow: hidden; } + .page-nav { flex: none; } + #page-nav-items { max-height: 30dvh; } + .page-area { flex: 1 1 auto; min-height: 0; } + .workspace.nav-collapsed .nav-heading strong, .workspace.nav-collapsed .nav-validation > span { display: inline; } + .workspace.nav-collapsed .nav-heading, .workspace.nav-collapsed .nav-validation { justify-content: space-between; } + .page-host > .gpio-card { flex: none; } +} + +/* The page heading supplies the title; card headings retain only tools and references. */ +.page-host .config-group > .section-heading { display: none; } +.page-host .gpio-card > .section-heading { padding-block: .35rem; } +.page-host .connector-heading { padding-block: .35rem; } +.page-host .connector-heading:not(:has(a)) { display: none; } + +.connector-set { display: flex; align-items: flex-start; gap: 1rem; min-width: min-content; } +.connector-socket { flex: 1 0 18rem; min-width: 0; } +.connector-set:not(.grouped) .connector-socket { flex-basis: 100%; } +.connector-socket h3 { margin: .3rem 0; display: flex; flex-wrap: wrap; gap: .6rem; align-items: center; } +.first-pin-mark { border-left: .3rem solid var(--accent); padding-left: .4rem; font-size: .75rem; } + + +/* Paired sockets share the heading, evidence and pinout rows. */ +.connector-set.grouped { + display: grid; + grid-template-rows: auto auto auto; + grid-auto-flow: column; + grid-auto-columns: minmax(18rem, 1fr); + align-items: start; +} +.connector-set.grouped > .connector-socket { + display: grid; + grid-row: 1 / 4; + grid-template-rows: subgrid; + align-self: stretch; +} +.connector-set.grouped > .connector-socket > table { align-self: start; } +@media (max-width: 980px) { + .connector-set.grouped { display: flex; flex-direction: column; min-width: 0; } + .connector-set.grouped > .connector-socket { display: block; flex: none; width: 100%; } } +.connector-function-roles { white-space: normal; } +#connector-role-color-legend { flex: none; } + +/* Compact columns take their unwrapped content width; function columns share the rest. */ +.connector-pin-table col.pin-col-terminal, .connector-pin-table col.pin-col-endpoint { width: 1%; } +.connector-pin-table col.pin-col-function { width: 50%; } +.connector-pin-table.single-column col.pin-col-function { width: 100%; } +.connector-pin-table th { white-space: nowrap; } + +.connector-pin-table .pin-left .connector-function-roles { justify-content: flex-end; text-align: right; } + +.connector-pin-table .pin-endpoint { text-align: center; } + +.connector-pin-table col.pin-col-wire, .connector-pin-table .pin-wire { width: 1%; white-space: nowrap; text-align: center; } +.connector-pin-table .pin-wire .connector-color { margin-right: 0; } + +.page-heading #connector-undo { margin-left: auto; flex: none; } + - -
-
-

M5 board specification catalog

-

GPIO specification editor

-
+ +
+ +
+ + +
+ Open ▾ +
+ + + + +
+
+ + +
-
- - + +
- - - -
- - - - -
Drop JSON to open
- - -
+ - -
-
-

- - ? -
- - - +
-
- Board information -
-
-
+ +
+

+
+ + - - - - -
- -
-

choices

Revisions

Record which device choice belongs to each hardware revision.

-
- -
-

device pins

Pin owners

Inspect terminals owned by devices such as PMICs and I/O expanders.

+ +
+ + + + + +
@@ -622,6 +693,22 @@

"app.catalog": { "en": "M5 board specification catalog", "zh": "M5 板卡规格目录", "ja": "M5 ボード仕様カタログ" }, "app.heading": { "en": "GPIO specification editor", "zh": "GPIO 规格编辑器", "ja": "GPIO 仕様エディタ" }, "toolbar.label": { "en": "Files and board", "zh": "文件与板卡", "ja": "ファイルと機種" }, + "toolbar.openMenu": {"en": "Open ▾", "zh": "打开 ▾", "ja": "開く ▾"}, + "toolbar.exportShort": {"en": "Export", "zh": "导出", "ja": "書き出し"}, + "toolbar.discardShort": {"en": "Discard", "zh": "丢弃", "ja": "破棄"}, + "options.conditions": {"en": "Conditions", "zh": "条件", "ja": "条件"}, + "mode.returnHint": {"en": "Clear revision and runtime selections to return to base data.", "zh": "清除版本和运行时选择,返回基本数据。", "ja": "版と実行時構成の選択を解除して基本データに戻します。"}, + "repository.dismiss": {"en": "Close export instructions", "zh": "关闭导出说明", "ja": "書き出し案内を閉じる"}, + "nav.label": {"en": "Catalog pages", "zh": "目录页面", "ja": "項目一覧"}, + "nav.collapse": {"en": "Collapse list", "zh": "收起列表", "ja": "一覧を畳む"}, + "nav.expand": {"en": "Expand list", "zh": "展开列表", "ja": "一覧を開く"}, + "nav.back": {"en": "← {page}", "zh": "← {page}", "ja": "← {page}"}, + "nav.gpioCount": {"en": "GPIO with assigned roles / available GPIO", "zh": "有角色的 GPIO / 可用 GPIO", "ja": "役割のあるGPIO / 実在するGPIO"}, + "nav.objectCount": {"en": "Declared objects in this category", "zh": "此类别的对象数", "ja": "この種類で宣言された項目数"}, + "nav.terminalCount": {"en": "Connector positions declared by its type", "zh": "类型定义的端子数", "ja": "型で定義された端子数"}, + "nav.validationCount": {"en": "All validation errors and warnings", "zh": "所有验证错误和警告", "ja": "検証のエラーと警告の合計"}, + "nav.connectorChildren": {"en": "Show connector entries", "zh": "显示连接器条目", "ja": "コネクタ項目を表示"}, + "nav.connector": {"en": "Connector", "zh": "连接器", "ja": "コネクタ"}, "toolbar.board": { "en": "Board", "zh": "板卡", "ja": "機種" }, "toolbar.boardSearch": { "en": "Search board name, ID, or alias", "zh": "搜索板卡名称、ID 或别名", "ja": "機種名・ID・別名で検索" }, "toolbar.language": { "en": "Language", "zh": "语言", "ja": "言語" }, @@ -699,7 +786,7 @@

"options.runtime": { "en": "Detected variant preview", "zh": "实机判别变体预览", "ja": "起動時判別バリエーション(表示のみ)" }, "mode.base": { "en": "Editing the shared definition. All GPIO rows are editable; changes are auto-saved only in this browser.", "zh": "正在编辑共通定义。所有 GPIO 行均可编辑;更改仅自动保存在此浏览器。", "ja": "共通定義を編集中。すべての GPIO 行を編集でき、変更はこのブラウザだけに自動下書き保存されます。" }, "mode.resolved": { "en": "Viewing {board} {revision} ({choices}). Values supplied by this selection are read-only.", "zh": "正在查看 {board} {revision}({choices})。由此选择提供的值为只读。", "ja": "{board} {revision} ({choices}) を表示中。この選択で決まる値は読み取り専用です。" }, - "mode.editBase": { "en": "Clear preview and edit shared definition", "zh": "清除预览并编辑共通定义", "ja": "表示条件を解除して共通定義を編集" }, + "mode.editBase": {"en": "Return to base data", "zh": "返回基本数据", "ja": "基本データに戻す"}, "readonly.configuration": { "en": "Supplied by the displayed variant; clear the preview to edit", "zh": "由当前显示变体提供;清除预览后可编辑", "ja": "表示中のバリエーション由来です。表示条件を解除すると編集できます" }, "readonly.revision": { "en": "Changed by revision {revision}", "zh": "由版本 {revision} 的差分更改", "ja": "版 {revision} の差分で変わる値です" }, "readonly.runtime": { "en": "Changed by detected-hardware choice {choice}", "zh": "来自实机判别选项 {choice}", "ja": "実機判別 {choice} で変わる値です" }, @@ -840,12 +927,21 @@

"validation.count": { "en": "{count}", "zh": "{count} 项", "ja": "{count} 件" }, "validation.ok": { "en": "✓ No errors", "zh": "✓ 没有错误", "ja": "✓ エラーはありません" }, "connector.eyebrow": { "en": "positions and endpoints", "zh": "位置与连接目标", "ja": "位置と接続先" }, + "connector.groupLabel": { "en": "Set {group}", "zh": "连接器组 {group}", "ja": "セット {group}" }, + "connector.firstPin": { "en": "First pin: {pin}", "zh": "首引脚: {pin}", "ja": "1番端子: {pin}" }, "connector.heading": { "en": "Connectors", "zh": "connector", "ja": "コネクタ" }, + "connector.unverified": {"en": "unverified", "zh": "未验证", "ja": "未確認"}, + "connector.typeView": {"en": "Type view", "zh": "类型视角", "ja": "型の視点"}, + "connector.layoutStatus": {"en": "Physical layout", "zh": "物理布局", "ja": "物理配置"}, + "connector.pitch": {"en": "Pitch", "zh": "间距", "ja": "ピッチ"}, + "connector.typeNote": {"en": "Type note", "zh": "类型备注", "ja": "型の注記"}, + "connector.wire": {"en": "Wire", "zh": "线色", "ja": "線色"}, + "connector.terminal": {"en": "Terminal", "zh": "端子", "ja": "端子"}, + "connector.function": {"en": "Function", "zh": "功能", "ja": "機能"}, + "connector.endpoint": {"en": "Endpoint", "zh": "连接目标", "ja": "接続先"}, "connector.pinoutHeading": { "en": "Connector diagrams & pinouts", "zh": "连接器图和引脚排列", "ja": "コネクタ図・ピン配置" }, "connector.description": { "en": "Select a connector position to review or change its endpoint.", "zh": "选择连接器位置以查看或更改其端点。", "ja": "コネクタ位置を選び、接続先を確認・変更します。" }, - "connector.view": { "en": "View", "zh": "显示方式", "ja": "表示形式" }, - "connector.table": { "en": "Table", "zh": "表格", "ja": "表" }, - "connector.diagram": { "en": "Diagram", "zh": "图", "ja": "図" }, + "connector.undoTarget": { "en": "Undo assignment of {id} {position}", "zh": "撤销 {id} {position} 的分配", "ja": "{id} {position} の割り当てを戻す" }, "connector.undo": { "en": "Undo connector change", "zh": "撤销连接器更改", "ja": "直前のコネクタ変更を戻す" }, "connector.noType": { "en": "Type data is unavailable", "zh": "没有类型数据", "ja": "型データがありません" }, "connector.pinoutAria": { "en": "{id} connector pinout", "zh": "{id} connector 引脚排列", "ja": "{id} コネクタのピン配置" }, @@ -3212,6 +3308,83 @@

.join(" · ") || "—"; } +function externalLinkHtml(url, label) { + try { + const parsed = new URL(url); + if (!["https:", "http:"].includes(parsed.protocol)) return ""; + return `${escapeHtml(label)}`; + } catch { return ""; } +} + +function gpioConnectorTargets(board, gpio) { + const targets = new Map(); + for (const role of board.pins?.[gpio]?.roles ?? []) { + const match = /(?:^|\/)conn:([^.]+)\.(.+)$/.exec(role); + if (match && board.connectors?.[match[1]]) targets.set(`${match[1]}.${match[2]}`, { connector: match[1], position: match[2] }); + } + return [...targets.values()]; +} + +function resolveEditorRoute(target, board, context = {}) { + const kinds = ["board", "gpio", "connectors", "connector", "buses", "devices", "accessories", "revisions", "owners", "validation"]; + const route = { kind: kinds.includes(target?.kind) ? target.kind : "gpio" }; + let id = target?.id == null ? null : String(target.id); + if (target?.kind === "connector-group") { + id = Object.keys(board.connectors ?? {}).find((key) => board.connectors[key].group === id); + route.kind = "connector"; + } + if (route.kind === "connector") { + if (!id || !board.connectors?.[id]) return { kind: "connectors" }; + route.id = id; + if (board.connectors[id].group) route.group = board.connectors[id].group; + const type = context.connectorTypes?.[board.connectors[id].type]; + if (target.position != null && type?.positions?.some((position) => position.id === String(target.position))) route.position = String(target.position); + } else if (["buses", "devices"].includes(route.kind) && id && board[route.kind]?.[id]) route.id = id; + else if (route.kind === "revisions" && id && board.revisions?.some((revision) => revision.id === id)) route.id = id; + else if (route.kind === "owners" && id && (context.ownerKeys ?? Object.keys(board.devices ?? {})).includes(id)) route.id = id; + if (route.kind === "gpio" && target?.gpio != null) { + const gpio = Number(target.gpio); + const count = context.chip?.gpio_count; + if (Number.isInteger(gpio) && gpio >= 0 && (count === undefined || gpio < count) && !context.chip?.absent?.includes(gpio)) route.gpio = String(gpio); + } + return route; +} + +function editorErrorRoute(item, board) { + const path = String(item.path ?? "").split("/").slice(1).map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~")); + if (path[0] === "pins") return { kind: "gpio", gpio: path[1] }; + if (path[0] === "connectors") return path[1] ? { kind: "connector", id: path[1], ...(path[2] === "positions" ? { position: path[3] } : {}) } : { kind: "connectors" }; + if (["buses", "devices"].includes(path[0])) return { kind: path[0], id: path[1] }; + if (path[0] === "revisions") return { kind: "revisions", id: board.revisions?.[Number(path[1])]?.id }; + return { kind: "board" }; +} + + +function connectorDisplaySets(board) { + const sets = new Map(); + for (const [id, connector] of Object.entries(board.connectors ?? {})) { + const key = connector.group ? `group:${connector.group}` : `connector:${id}`; + if (!sets.has(key)) sets.set(key, { group: connector.group ?? null, ids: [] }); + sets.get(key).ids.push(id); + } + return [...sets.values()]; +} + +function editorPageKey(route) { + return `${route.kind}:${route.group ? `group:${route.group}` : route.kind === "owners" ? "" : route.id ?? ""}`; +} + +function firstConnectorPosition(type) { + return type?.positions?.find((position) => position.id === "1")?.id ?? type?.positions?.[0]?.id ?? "1"; +} + +function connectorTableColumns(type) { + const wire = type.positions.some((position) => position.color) ? ["wire"] : []; + return type.layout.cols === 2 + ? ["function", "endpoint", ...wire, "terminal", "terminal", ...wire, "endpoint", "function"] + : ["terminal", ...wire, "endpoint", "function"]; +} + function samePin(left, right) { return JSON.stringify(left ?? null) === JSON.stringify(right ?? null); @@ -3646,7 +3819,8 @@

const plain = String(role ?? "").split("/").at(-1); const object = /^(bus|dev|conn):([a-z][a-z0-9_]*)\./.exec(plain); if (object) return objectRoleOwner(object[1], object[2]); - const power = /^pwr:([a-z][a-z0-9_]*)$/.exec(plain); + // Voltage rail names such as 3v3 and 5v begin with a digit. + const power = /^pwr:([a-z0-9_]+)$/.exec(plain); return power ? objectRoleOwner("pwr", power[1]) : null; } @@ -3722,6 +3896,20 @@

} +function connectorEndpointRoles(board, endpoint, connectorId) { + const roles = []; + for (const value of Array.isArray(endpoint) ? endpoint : [endpoint]) { + const gpio = /^gpio:(\d+)$/.exec(value ?? "")?.[1]; + if (gpio !== undefined) roles.push(...(board.pins?.[gpio]?.roles ?? [])); + if (/^pwr:/.test(value ?? "")) roles.push(value); + } + return [...new Set(roles)].filter((role) => { + const owner = roleOwner(role); + return owner?.kind !== "conn" || owner.id !== connectorId; + }); +} + + @@ -3745,9 +3933,8 @@

const gpioFilterStorageKey = "m5-board-spec-gpio-filter:v1"; const roleColorStorageKey = "m5-board-spec-role-colors:v1"; const onboardingStorageKey = "m5-board-spec-onboarding:v1"; -const layoutStorageKey = "m5-board-spec-layout:v1"; +const layoutStorageKey = "m5-board-spec-layout:v3"; const motionDuration = 200; -const sidebarWidthLimits = { min: 360, max: 620, default: 460 }; let comboboxSerial = 0; const chipForBoard = (board) => effectiveChip(chips[board.chip], board); @@ -3777,13 +3964,16 @@

dropZone: document.querySelector("#drop-zone"), repositoryNext: document.querySelector("#repository-next"), optionPanel: document.querySelector("#option-panel"), - viewTabs: document.querySelector("#view-tabs"), + pageNav: document.querySelector("#page-nav"), + navItems: document.querySelector("#page-nav-items"), + navToggle: document.querySelector("#nav-toggle"), + pageBack: document.querySelector("#page-back"), + pageTitle: document.querySelector("#page-title"), + pageHost: document.querySelector("#page-host"), configurationView: document.querySelector("#configuration-view"), boardInfo: document.querySelector("#board-info"), boardInfoSummary: document.querySelector("#board-info-summary"), boardInfoBody: document.querySelector("#board-info-body"), - sideResizer: document.querySelector("#side-resizer"), - sideColumn: document.querySelector(".side-column"), revisionsView: document.querySelector("#revisions-view"), ownersView: document.querySelector("#owners-view"), configurationList: document.querySelector("#configuration-list"), @@ -3798,13 +3988,11 @@

gpioFilter: document.querySelector("#gpio-filter"), gpioAssignedOnly: document.querySelector("#gpio-assigned-only"), roleColorLegend: document.querySelector("#role-color-legend"), + connectorRoleColorLegend: document.querySelector("#connector-role-color-legend"), validationCount: document.querySelector("#validation-count"), validationSection: document.querySelector("#validation-section"), validationList: document.querySelector("#validation-list"), - connectorTabs: document.querySelector("#connector-tabs"), - connectorViewToggle: document.querySelector("#connector-view-toggle"), - connectorDiagram: document.querySelector("#connector-diagram"), - connectorEditor: document.querySelector("#connector-editor"), + connectorPinout: document.querySelector("#connector-pinout"), connectorUndo: document.querySelector("#connector-undo"), connectorSection: document.querySelector("#connector-section"), connectorSectionTitle: document.querySelector("#connector-section-title"), @@ -3820,12 +4008,15 @@

selectedOptions: new Set(), runtimeSelections: new Map(), compositionEntries: new Map(), - activeView: "configuration", + page: null, + previousPage: null, + routeBoardId: null, + pageScroll: new Map(), + navMarkup: null, ownerKey: null, compareRevisions: ["", ""], connectorId: null, connectorPosition: null, - connectorView: "table", connectorEditorTab: "gpio", language: initialLanguage(), undo: null, @@ -3834,7 +4025,9 @@

...initialGPIOFilterState(), roleColors: initialRoleColors(), roleColorsOpen: false, + connectorRoleColorsOpen: false, onboardingOpen: initialOnboardingOpen(), + runtimePanelOpen: false, layout: initialLayoutState(), lastExported: null, }; @@ -4014,16 +4207,16 @@

} function initialLayoutState() { - try { - const stored = JSON.parse(localStorage.getItem(layoutStorageKey) ?? "{}"); - const width = Number(stored.sidebarWidth); - return { - sidebarWidth: Number.isFinite(width) ? Math.max(sidebarWidthLimits.min, Math.min(sidebarWidthLimits.max, width)) : sidebarWidthLimits.default, - sections: stored.sections && typeof stored.sections === "object" ? stored.sections : {}, - }; - } catch { - return { sidebarWidth: sidebarWidthLimits.default, sections: {} }; - } + let stored = {}; + try { stored = JSON.parse(localStorage.getItem(layoutStorageKey) ?? "{}") ?? {}; } catch { /* storage is optional */ } + // Legacy connectorView preferences are deliberately ignored; pinouts use tables only. + return { + page: stored.page && typeof stored.page === "object" ? stored.page : { kind: "gpio" }, + boardId: typeof stored.boardId === "string" ? stored.boardId : null, + navCollapsed: typeof stored.navCollapsed === "boolean" ? stored.navCollapsed : window.matchMedia("(max-width: 980px)").matches, + navGroups: { connectors: stored.navGroups?.connectors !== false }, + sections: stored.sections && typeof stored.sections === "object" ? stored.sections : {}, + }; } function saveLayoutState() { @@ -4034,10 +4227,107 @@

return Object.hasOwn(state.layout.sections, id) ? Boolean(state.layout.sections[id]) : fallback; } -function setSidebarWidth(width) { - state.layout.sidebarWidth = Math.max(sidebarWidthLimits.min, Math.min(sidebarWidthLimits.max, Math.round(width))); - elements.configurationView.style.setProperty("--side-width", `${state.layout.sidebarWidth}px`); - elements.sideResizer.setAttribute("aria-valuenow", String(state.layout.sidebarWidth)); +function routeContext(board) { + return { chip: chipForBoard(board), connectorTypes: Object.fromEntries(Object.values(board.connectors ?? {}).map((connector) => [connector.type, resolveConnectorType(connectorTypes, connector.type)])), ownerKeys: ownerDefinitions(currentBase()).map((owner) => owner.key) }; +} + +function pageLabel(route) { + const keys = { board: "layout.boardInfo", gpio: "gpio.heading", connectors: "configuration.connectors", connector: "nav.connector", buses: "configuration.buses", devices: "configuration.devices", accessories: "configuration.accessories", revisions: "views.revisions", owners: "views.owners", validation: "validation.heading" }; + return `${t(keys[route.kind] ?? "gpio.heading")}${route.group ? ` · ${t("connector.groupLabel", { group: route.group })}` : route.id ? ` · ${route.id}` : ""}${route.position ? ` · ${route.position}` : ""}${route.gpio ? ` ${route.gpio}` : ""}`; +} + +function currentPageScroll() { + return { top: elements.pageHost.scrollTop, left: elements.pageHost.scrollLeft, gpioTop: elements.tableBody.closest(".table-wrap").scrollTop, connectorTop: elements.connectorPinout.scrollTop, connectorLeft: elements.connectorPinout.scrollLeft }; +} + +function applyPageVisibility() { + elements.configurationView.querySelectorAll("[data-page]").forEach((page) => { page.hidden = page.dataset.page !== state.page.kind; }); + elements.configurationView.classList.toggle("nav-collapsed", state.layout.navCollapsed); + elements.navItems.hidden = state.layout.navCollapsed; + elements.navToggle.setAttribute("aria-expanded", String(!state.layout.navCollapsed)); + elements.navToggle.textContent = state.layout.navCollapsed ? "»" : "«"; + elements.navToggle.title = t(state.layout.navCollapsed ? "nav.expand" : "nav.collapse"); + elements.navToggle.setAttribute("aria-label", elements.navToggle.title); + elements.pageTitle.textContent = pageLabel(state.page); + elements.pageTitle.title = state.page.kind === "gpio" ? t("gpio.description") : state.page.kind === "connector" ? t("connector.description") : ""; + renderConnectorUndo(); + elements.pageBack.hidden = !state.previousPage; + if (state.previousPage) elements.pageBack.textContent = t("nav.back", { page: pageLabel(state.previousPage.route) }); + elements.pageNav.querySelectorAll("[data-route-kind]").forEach((button) => { + const active = button.dataset.routeKind === "connector-group" ? state.page.kind === "connector" && button.dataset.routeId === state.page.group : button.dataset.routeKind === state.page.kind && (!button.dataset.routeId || (!state.page.group && button.dataset.routeId === state.page.id)); + if (active) button.setAttribute("aria-current", "page"); else button.removeAttribute("aria-current"); + }); +} + +function navigateTo(target, options = {}) { + const board = shownBoard(); + const boardId = state.currentId; + const changedBoard = state.routeBoardId !== null && state.routeBoardId !== state.currentId; + const reset = options.reset || changedBoard; + const requested = reset && ["connector", "connector-group"].includes(target?.kind) ? { kind: "connectors" } : target; + const route = resolveEditorRoute(requested, board, routeContext(board)); + const different = JSON.stringify(route) !== JSON.stringify(state.page); + const pageChanged = editorPageKey(route) !== (state.page ? editorPageKey(state.page) : null); + if (reset) { state.previousPage = null; state.pageScroll.clear(); } + else if (options.remember !== false && pageChanged && state.page) { + const previous = { ...state.page }; + if (previous.kind === "gpio" && currentEntry().expandedGPIO != null) previous.gpio = String(currentEntry().expandedGPIO); + state.previousPage = { route: previous, scroll: currentPageScroll(), boardId: state.currentId }; + } + if (different && state.page && !reset) state.pageScroll.set(editorPageKey(state.page), currentPageScroll()); + const oldConnector = state.connectorId; + const oldPosition = state.connectorPosition; + const ids = Object.keys(board.connectors ?? {}); + state.page = route; + state.routeBoardId = state.currentId; + state.connectorId = route.kind === "connector" ? route.id : (!reset && ids.includes(state.connectorId) ? state.connectorId : ids[0] ?? null); + state.connectorPosition = route.kind === "connector" ? route.position ?? null : (state.connectorId === oldConnector ? state.connectorPosition : null); + if (route.kind === "owners" && route.id) state.ownerKey = route.id; + state.layout.page = route; + state.layout.boardId = state.currentId; + saveLayoutState(); + if (options.render === false) return; + if (route.gpio != null && (currentEntry().expandedGPIO !== route.gpio || !document.getElementById(`gpio-${route.gpio}`))) { + currentEntry().expandedGPIO = route.gpio; + writeDraft(currentEntry()); + renderTable(board, validationFor(currentBase())); + } + if (route.kind === "connector" && (oldConnector !== state.connectorId || oldPosition !== state.connectorPosition)) renderConnectors(board); + if (route.kind === "owners" && route.id) renderOwnerEditor(board); + renderNavigation(board); + applyPageVisibility(); + requestAnimationFrame(() => { + if (state.currentId !== boardId || JSON.stringify(state.page) !== JSON.stringify(route)) return; + if (route.kind === "gpio") sizeConnectorLanes(assignConnectorLanes(board.pins).lanes.length); + const saved = options.restoreScroll ?? state.pageScroll.get(editorPageKey(route)); + if (saved) { + elements.pageHost.scrollTop = saved.top; + elements.pageHost.scrollLeft = saved.left; + if (route.kind === "gpio") elements.tableBody.closest(".table-wrap").scrollTop = saved.gpioTop; + if (route.kind === "connector") { elements.connectorPinout.scrollTop = saved.connectorTop ?? 0; elements.connectorPinout.scrollLeft = saved.connectorLeft ?? 0; } + } else if (different) elements.pageHost.scrollTop = 0; + if (options.restoreScroll) return; + let node; + if (route.gpio != null) node = document.getElementById(`gpio-${route.gpio}`); + else if (route.kind === "connector" && route.position) node = [...elements.connectorPinout.querySelectorAll("[data-connector-position][tabindex]")].find((item) => item.dataset.connectorPosition === route.position && item.closest("[data-connector-id]")?.dataset.connectorId === route.id); + else if (route.id && ["buses", "devices"].includes(route.kind)) { + node = [...elements.configurationList.querySelectorAll("[data-config-object]")].find((item) => item.dataset.configObject === `${route.kind}:${route.id}`); + if (node?.tagName === "DETAILS") setDisclosureOpen(node, true); + } + if (node) { node.scrollIntoView({ behavior: prefersReducedMotion() ? "auto" : "smooth", block: "center" }); node.focus?.({ preventScroll: true }); } + options.after?.(); + }); +} + +function renderNavigation(board) { + const chip = chipForBoard(board); + const available = Array.from({ length: chip.gpio_count }, (_, gpio) => gpio).filter((gpio) => !chip.absent.includes(gpio)); + const assigned = available.filter((gpio) => board.pins?.[gpio]?.roles?.length).length; + const navButton = (kind, label, count = "", title = "nav.objectCount", id = null) => ``; + const children = connectorDisplaySets(board).map((set) => navButton(set.group ? "connector-group" : "connector", set.group ?? set.ids[0], set.ids.reduce((count, id) => count + (resolveConnectorType(connectorTypes, board.connectors[id].type)?.positions?.length ?? 0), 0), "nav.terminalCount", set.group ?? set.ids[0])).join(""); + const markup = navButton("board", t("layout.boardInfo"), "", "layout.boardInfo") + navButton("gpio", t("gpio.heading"), `${assigned}/${available.length}`, "nav.gpioCount") + `` + navButton("buses", t("configuration.buses"), Object.keys(board.buses ?? {}).length) + navButton("devices", t("configuration.devices"), Object.keys(board.devices ?? {}).length) + navButton("accessories", t("configuration.accessories")) + navButton("revisions", t("views.revisions"), currentBase().revisions?.length ?? 0) + navButton("owners", t("views.owners")); + if (state.navMarkup !== markup) { elements.navItems.innerHTML = markup; state.navMarkup = markup; } + elements.validationCount.title = t("nav.validationCount"); } function dismissOnboarding() { @@ -4064,7 +4354,7 @@

function initialRoleColors() { try { const stored = JSON.parse(localStorage.getItem(roleColorStorageKey) ?? "{}"); - return Object.fromEntries(Object.entries(stored).filter(([key, value]) => /^(?:bus|dev|conn|pwr):[a-z][a-z0-9_]*$/.test(key) && /^#[0-9a-f]{6}$/i.test(value))); + return Object.fromEntries(Object.entries(stored).filter(([key, value]) => /^(?:(?:bus|dev|conn):[a-z][a-z0-9_]*|pwr:[a-z0-9_]+)$/.test(key) && /^#[0-9a-f]{6}$/i.test(value))); } catch { return {}; } @@ -4092,10 +4382,9 @@

} function renderOnboarding() { - prepareMotionRegion(elements.onboarding); - elements.onboarding.classList.toggle("is-open", state.onboardingOpen); - elements.onboarding.setAttribute("aria-hidden", String(!state.onboardingOpen)); - elements.onboarding.inert = !state.onboardingOpen; + elements.onboarding.hidden = false; + if (state.onboardingOpen && !elements.onboarding.open) elements.onboarding.showModal(); + else if (!state.onboardingOpen && elements.onboarding.open) elements.onboarding.close(); elements.onboardingHelp.setAttribute("aria-expanded", String(state.onboardingOpen)); } @@ -4111,7 +4400,7 @@

} function prepareDisclosureTransitions(root = document) { - for (const details of root.querySelectorAll("details:not(.motion-details)")) { + for (const details of root.querySelectorAll("details:not(.motion-details, .header-menu)")) { const summary = details.querySelector(":scope > summary"); if (!summary) continue; const collapse = document.createElement("div"); @@ -4298,7 +4587,7 @@

const board = state.documents.get(id).base; state.runtimeSelections.set(id, Object.fromEntries(choiceSlots(board).filter(([, device]) => device.selected_by === "runtime").map(([slot, device]) => [slot, device.default]))); } - state.currentId = state.documents.has("m5stack_core2") ? "m5stack_core2" : [...state.documents.keys()][0]; + state.currentId = state.documents.has(state.layout.boardId) ? state.layout.boardId : state.documents.has("m5stack_core2") ? "m5stack_core2" : [...state.documents.keys()][0]; } function initializeDocument(board, { original = board, dirty = true, source = "editor" } = {}) { @@ -4308,8 +4597,7 @@

state.currentId = board.id; state.lastExported = null; state.selectedOptions.clear(); - state.connectorId = null; - state.connectorPosition = null; + navigateTo({ kind: state.page?.kind ?? "gpio" }, { reset: true, render: false }); state.undo = null; if (dirty) writeDraft(state.documents.get(board.id)); } @@ -4364,7 +4652,7 @@

state.currentId = state.documents.keys().next().value; state.lastExported = null; state.selectedOptions.clear(); - state.connectorId = null; + navigateTo({ kind: state.page?.kind ?? "gpio" }, { reset: true, render: false }); renderAll(); } @@ -4419,7 +4707,8 @@

} else { control.removeAttribute("aria-disabled"); delete control.dataset.readonlyReason; - control.removeAttribute("title"); + if (control.dataset.i18nTitle) control.title = t(control.dataset.i18nTitle); + else control.removeAttribute("title"); } } @@ -4559,6 +4848,13 @@

} }; addRoles(board.pins); + for (const id of Object.keys(board.connectors ?? {})) { + const display = resolveConnectorPositions(board, id, connectorTypes); + for (const endpoint of Object.values(display.positions)) for (const role of connectorEndpointRoles(board, endpoint, id)) { + const owner = roleOwner(role); + if (owner) owners.set(owner.key, owner); + } + } for (const device of Object.values(board.devices ?? {})) { addRoles(device.pins); for (const choice of Object.values(device.choices ?? {})) addRoles(choice.pins); @@ -4574,7 +4870,9 @@

return ``; }).join(""); const prefixes = `
${escapeHtml(t("rolePrefixes.heading"))}bus: ${escapeHtml(t("rolePrefixes.bus").replace(/^bus:\s*/, ""))}dev: ${escapeHtml(t("rolePrefixes.dev").replace(/^dev:\s*/, ""))}conn: ${escapeHtml(t("rolePrefixes.conn").replace(/^conn:\s*/, ""))}| ${escapeHtml(t("rolePrefixes.source").replace(/^\|\s*/, ""))}
`; - elements.roleColorLegend.innerHTML = `${prefixes}${escapeHtml(t("roleColors.heading"))}
${items || `${escapeHtml(t("roleColors.empty"))}`}
`; + const detailsMarkup = (open) => `${escapeHtml(t("roleColors.heading"))}
${items || `${escapeHtml(t("roleColors.empty"))}`}
`; + elements.roleColorLegend.innerHTML = prefixes + detailsMarkup(state.roleColorsOpen); + elements.connectorRoleColorLegend.innerHTML = detailsMarkup(state.connectorRoleColorsOpen); } function verificationSummary(pin) { @@ -4648,8 +4946,7 @@

state.currentId = id; state.lastExported = null; state.selectedOptions.clear(); - state.connectorId = null; - state.connectorPosition = null; + navigateTo({ kind: state.page?.kind ?? "gpio" }, { reset: true, render: false }); renderAll(); }, "", { preserveInput: true, selectOnFocus: true, showAllOnFocus: true, filterOptions: filterBoardOptions }); } else boardCombobox.setOptions(options); @@ -4660,17 +4957,22 @@

function renderOptions() { const revisions = currentBase().revisions ?? []; const selectedRevision = [...state.selectedOptions][0] ?? ""; - const revisionHtml = ``; + const revisionHtml = ``; const runtime = state.runtimeSelections.get(state.currentId) ?? {}; const runtimeHtml = choiceSlots(currentBase()).filter(([, device]) => device.selected_by === "runtime").map(([slot, device]) => ( `` )).join(""); - elements.optionPanel.innerHTML = `
${escapeHtml(t("options.legend"))} ${helpMark("help.displayConfiguration")}${revisionHtml}${runtimeHtml}
`; + const runtimeSummary = Object.values(runtime).filter(Boolean).join(" / ") || t("options.none"); + elements.optionPanel.innerHTML = `${revisionHtml}${runtimeHtml ? `
${runtimeHtml}
` : ""}`; + elements.optionPanel.querySelector(".revision-control").after(elements.editBase); + elements.editBase.hidden = !isResolvedMode(); const revision = revisions.find((item) => item.id === selectedRevision); const choices = Object.entries({ ...revisionSelection(currentBase(), revision), ...runtime }).map(([slot, choice]) => `${slot}: ${choice}`).join(", ") || t("options.none"); elements.modeNote.textContent = isResolvedMode() ? t("mode.resolved", { board: currentBase().name, revision: revision?.name ?? t("options.none"), choices }) : t("mode.base"); + elements.optionPanel.title = elements.modeNote.textContent; + elements.optionPanel.querySelector("[data-display-revision]").title = elements.modeNote.textContent; } function renderTable(board, errors) { @@ -4701,7 +5003,7 @@

if (chip.absent.includes(gpio)) continue; const key = String(gpio); const pin = board.pins?.[key] ?? { roles: [] }; - if (!gpioMatchesFilter(key, pin, state.gpioFilter, state.gpioAssignedOnly)) continue; + if (state.page?.gpio !== key && !gpioMatchesFilter(key, pin, state.gpioFilter, state.gpioAssignedOnly)) continue; const conditionalReserved = (chip.reserved_conditional?.[board.spec?.storage?.psram_mode] ?? []).includes(gpio); const readonlyReason = readonlyReasons.get(key) ?? (chip.reserved.includes(gpio) ? t("reason.chipReserved") : conditionalReserved ? t("reason.chipReservedConditional") : ""); const readonly = readonlyAttributes(readonlyReason); @@ -4782,6 +5084,7 @@

function sizeConnectorLanes(laneCount) { const table = elements.tableHead.closest("table"); + if (!table.getClientRects().length || !table.getBoundingClientRect().width) return; if (!laneCount) { table.style.removeProperty("--connector-lane-columns"); table.style.removeProperty("--connector-column-width"); @@ -4795,16 +5098,14 @@

function renderValidation(board, errors) { const errorCount = errors.filter((item) => item.severity !== "warning").length; - elements.validationCount.textContent = t("validation.count", { count: errors.length }); + elements.validationCount.textContent = errors.length ? t("validation.count", { count: errors.length }) : "✓"; elements.validationCount.classList.toggle("has-errors", errorCount > 0); - elements.validationSection.open = disclosureOpen("validation", errorCount > 0); if (!errors.length) { elements.validationList.innerHTML = `
${escapeHtml(t("validation.ok"))}
`; return; } elements.validationList.innerHTML = `
    ${errors.map((item, index) => { - const gpio = gpioForError(item, board); - const reason = gpio === null ? t("unavailable.noGPIO") : ""; + const reason = ""; const action = validationAction(item); return `
  • `; }).join("")}
`; @@ -4832,12 +5133,17 @@

const links = []; for (const [field, prefix] of groups) { for (const [id, object] of Object.entries(board[field] ?? {})) { + if (field === "connectors") continue; if (objectAssignments(board, prefix, id, object).includes(gpio)) { const owner = objectRoleOwner(prefix, id); links.push(``); } } } + for (const target of gpioConnectorTargets(board, gpio)) { + const owner = objectRoleOwner("conn", target.connector); + links.push(``); + } return links.join(""); } @@ -4879,24 +5185,6 @@

return values.map((value) => /^gpio:(\d+)$/.exec(value ?? "")?.[1]).filter((value) => value !== undefined); } -function connectorFunctionLabel(board, endpoint) { - const signalNames = { sclk: "SCK", data_in: "DATA IN", data_out: "DATA OUT" }; - const labels = []; - for (const gpio of endpointGPIOs(endpoint)) { - for (const role of board.pins?.[gpio]?.roles ?? []) { - const bus = /^bus:([a-z][a-z0-9_]*)\.([a-z0-9_]+)$/.exec(role); - if (bus) { - const signal = signalNames[bus[2]] ?? bus[2].replaceAll("_", " ").toUpperCase(); - labels.push(/^main_/.test(bus[1]) ? signal : `${signal} (${bus[1]})`); - continue; - } - const device = /^dev:([a-z][a-z0-9_]*)\.([a-z0-9_]+)$/.exec(role); - if (device) labels.push(`${device[1].replaceAll("_", " ").toUpperCase()} ${device[2].replaceAll("_", " ").toUpperCase()}`); - } - } - return [...new Set(labels)].join(" / "); -} - function connectorTableEndpointLabel(endpoint, position) { const label = endpointLabel(endpoint); const standardName = position?.name?.toUpperCase(); @@ -4904,78 +5192,51 @@

return label; } -function connectorPositionClasses(source, endpoint, positionId) { +function connectorPositionClasses(source, endpoint, positionId, connectorId) { const sourceClass = source.fixed ? "fixed" : source.explicit || source.gpios?.length ? "explicit" : source.default ? "default" : "missing"; const highlighted = currentEntry().expandedGPIO && endpointGPIOs(endpoint).includes(currentEntry().expandedGPIO) ? " gpio-highlight" : ""; - const selected = state.connectorPosition === positionId ? " selected" : ""; + const selected = state.connectorId === connectorId && state.connectorPosition === positionId ? " selected" : ""; return `${sourceClass}${highlighted}${selected}`; } -function ensureConnectorSelection(board) { - const ids = Object.keys(board.connectors ?? {}); - if (!ids.includes(state.connectorId)) { - state.connectorId = ids[0] ?? null; - state.connectorPosition = null; - } +function boardReferenceLinks(board) { + return ["product", "schematic"].map((key) => externalLinkHtml(board.spec?.links?.[key], t(`field.${key}Link`))).filter(Boolean).join(" · "); } -function connectorKeyMarkup(layout, width, height) { - const length = 28; - const depth = 10; - if (layout.key === "top") return ``; - if (layout.key === "bottom") return ``; - if (layout.key === "left") return ``; - if (layout.key === "right") return ``; - return ""; +function connectorMeta(board, connector, type) { + const provenance = (values) => Object.entries(values ?? {}).map(([key, value]) => `${escapeHtml(key)}: ${escapeHtml(value ?? t("connector.unverified"))}`).join(" · ") || escapeHtml(t("connector.unverified")); + const layout = type.verified?.layout ?? t("connector.unverified"); + const pitch = type.layout.pitch_mm == null ? t("connector.unverified") : `${type.layout.pitch_mm} mm`; + return `
${escapeHtml(type.name)} · ${escapeHtml(connector.standard ?? "")}
${escapeHtml(t("connector.typeView"))}: ${escapeHtml(type.layout.view)} · ${escapeHtml(t("connector.layoutStatus"))}: ${escapeHtml(layout)} · ${escapeHtml(t("connector.pitch"))}: ${escapeHtml(pitch)}
${escapeHtml(t("column.verified"))}: ${provenance(connector.verified)}
${connector.note ? `

${escapeHtml(connector.note)}

` : ""}${type.note ? `

${escapeHtml(t("connector.typeNote"))}: ${escapeHtml(type.note)}

` : ""}
`; } -function renderConnectorDiagram(board, connectorId) { - const connector = board.connectors?.[connectorId]; - const type = resolveConnectorType(connectorTypes, connector?.type); - if (!connector || !type) return `
${escapeHtml(t("connector.noType"))}
`; - const display = resolveConnectorPositions(board, connectorId, connectorTypes); - const source = resolveConnectorPositions(currentBase(), connectorId, connectorTypes); - const cellWidth = 92; - const cellHeight = 62; - const width = type.layout.cols * cellWidth + 24; - const height = type.layout.rows * cellHeight + 24; - const positions = type.positions.map((position) => { - const column = type.layout.mirror ? type.layout.cols - 1 - position.col : position.col; - const x = 12 + column * cellWidth + cellWidth / 2; - const y = 12 + position.row * cellHeight + cellHeight / 2; - const endpoint = display.positions[position.id]; - const sources = source.sources[position.id] ?? {}; - const classes = connectorPositionClasses(sources, endpoint, position.id); - const label = position.label ?? (position.name ? position.name.toUpperCase() : ""); - const fill = endpointGPIOs(endpoint).length - ? ` style="${roleColorInlineStyle(objectRoleOwner("conn", connectorId), true)}"` - : position.color ? ` style="--position-color:${escapeHtml(position.color)}"` : ""; - return ` - ${escapeHtml(`${position.id} ${label} ${endpointLabel(endpoint)}`)} - - ${escapeHtml(position.id)} - ${escapeHtml(label)} - ${escapeHtml(endpointLabel(endpoint))} - `; - }).join(""); - return `
${escapeHtml(type.name)}${escapeHtml(type.layout.view)}
${connectorKeyMarkup(type.layout, width, height)}${positions}`; -} - -function renderConnectorTableCells(board, display, source, position, side = "single") { - if (!position) return ''; +function renderConnectorTableCells(board, display, source, position, connectorId, side = "single") { + const type = resolveConnectorType(connectorTypes, board.connectors[connectorId].type); + const wire = type.positions.some((item) => item.color); + if (!position) return ``; const endpoint = display.positions[position.id]; - const classes = connectorPositionClasses(source.sources[position.id] ?? {}, endpoint, position.id); + const classes = connectorPositionClasses(source.sources[position.id] ?? {}, endpoint, position.id, connectorId); const data = `data-connector-position="${escapeHtml(position.id)}"`; const endpointText = escapeHtml(connectorTableEndpointLabel(endpoint, position)); - const functionText = escapeHtml(connectorFunctionLabel(board, endpoint)); - const positionText = escapeHtml(position.id); - const connectorOwner = objectRoleOwner("conn", state.connectorId); - const color = endpointGPIOs(endpoint).length - ? `` - : position.color ? `` : ""; - if (side === "left") return `${endpointText}${functionText}${color}${positionText}`; - if (side === "right") return `${color}${positionText}${functionText}${endpointText}`; - return `${color}${positionText}${functionText}${endpointText}`; + const endpoints = Array.isArray(endpoint) ? endpoint : [endpoint]; + const standardName = position?.name?.toUpperCase(); + const standardSuffix = !endpointGPIOs(endpoint).length && standardName && endpointLabel(endpoint) !== standardName && endpointLabel(endpoint) !== "—" ? ` (${escapeHtml(standardName)})` : ""; + const endpointHtml = endpoints.some((value) => /^pwr:/.test(value ?? "")) ? endpoints.map((value) => /^pwr:/.test(value ?? "") ? roleChip(board, value, true) : escapeHtml(endpointLabel(value))).join(" / ") + standardSuffix : endpointText; + const roles = connectorEndpointRoles(board, endpoint, connectorId).filter((role) => roleOwner(role)?.kind !== "pwr"); + const functionText = escapeHtml(roles.map((role) => roleDisplay(board, role).plain).join(" / ")); + const functionHtml = `
${roles.map((role) => roleChip(board, role, true)).join("")}
`; + const positionText = `${position.id === firstConnectorPosition(type) ? "▸ " : ""}${escapeHtml(position.id)}`; + + const sideClass = side === "single" ? "" : ` pin-${side}`; + const cell = (kind, content, title = "", button = false) => `${content}`; + const cells = { + terminal: cell("number", positionText, "", true), + endpoint: cell("endpoint", endpointHtml, endpointText), + function: cell("function", functionHtml, functionText), + wire: cell("wire", position.color ? `` : "", escapeHtml(position.color ?? "")), + }; + const columns = side === "left" ? ["function", "endpoint", ...(wire ? ["wire"] : []), "terminal"] : ["terminal", ...(wire ? ["wire"] : []), "endpoint", "function"]; + return columns.map((key) => cells[key]).join(""); } function renderConnectorTable(board, connectorId) { @@ -4984,7 +5245,8 @@

if (!connector || !type) return `
${escapeHtml(t("connector.noType"))}
`; const display = resolveConnectorPositions(board, connectorId, connectorTypes); const source = resolveConnectorPositions(currentBase(), connectorId, connectorTypes); - const editorRow = (positions, colspan) => positions.some((position) => position?.id === state.connectorPosition) + const columns = connectorTableColumns(type); + const editorRow = (positions, colspan) => state.connectorId === connectorId && positions.some((position) => position?.id === state.connectorPosition) ? `
${connectorEditorMarkup(board)}
` : ""; let rows; @@ -4995,14 +5257,15 @@

})); rows = Array.from({ length: type.layout.rows }, (_, row) => { const rowPositions = [positions.get(`${row}:0`), positions.get(`${row}:1`)]; - return `${renderConnectorTableCells(board, display, source, rowPositions[0], "left")}${renderConnectorTableCells(board, display, source, rowPositions[1], "right")}${editorRow(rowPositions, 6)}`; + return `${renderConnectorTableCells(board, display, source, rowPositions[0], connectorId, "left")}${renderConnectorTableCells(board, display, source, rowPositions[1], connectorId, "right")}${editorRow(rowPositions, columns.length)}`; }).join(""); } else { + // Extend this table layout when connector types with three or more columns are used. const direction = type.layout.mirror ? -1 : 1; const positions = [...type.positions].sort((a, b) => a.row - b.row || direction * (a.col - b.col)); - rows = positions.map((position) => `${renderConnectorTableCells(board, display, source, position)}${editorRow([position], 3)}`).join(""); + rows = positions.map((position) => `${renderConnectorTableCells(board, display, source, position, connectorId)}${editorRow([position], columns.length)}`).join(""); } - return `
${escapeHtml(type.name)}${escapeHtml(type.layout.view)}
${rows}
`; + return `${connectorMeta(board, connector, type)}${columns.map((key) => ``).join("")}${columns.map((key) => ``).join("")}${rows}
${escapeHtml(t(`connector.${key}`))}
`; } function gpioChoices(board) { @@ -5048,7 +5311,7 @@

const readonly = readonlyAttributes(readonlyReason); const status = source.explicit || source.gpios.length ? t("connector.boardStatus") : source.default ? t("connector.defaultStatus") : t("connector.unsetStatus"); return `
${escapeHtml(positionId)} · ${escapeHtml(label)}${escapeHtml(status)} · ${escapeHtml(endpointLabel(resolved.positions[positionId]))}
-
+
    ${readonlyNote(readonlyReason)}`; } @@ -5068,44 +5331,30 @@

    }, isResolvedMode() ? t("readonly.configuration") : ""); } -function renderConnectorEditor(board) { - if (state.connectorView === "table") { - elements.connectorEditor.hidden = true; - elements.connectorEditor.innerHTML = ""; - hydrateConnectorEditor(elements.connectorDiagram.querySelector("[data-connector-editor]")); - return; - } - elements.connectorEditor.hidden = false; - elements.connectorEditor.classList.toggle("open", Boolean(state.connectorPosition)); - elements.connectorEditor.innerHTML = connectorEditorMarkup(board); - hydrateConnectorEditor(elements.connectorEditor); +function renderConnectorUndo() { + const undo = state.undo; + const reason = isResolvedMode() ? t("readonly.configuration") : !undo || undo.boardId !== state.currentId ? t("unavailable.noUndo") : ""; + elements.connectorUndo.hidden = state.page?.kind !== "connector" || Boolean(reason); + applyControlLock(elements.connectorUndo, reason); + elements.connectorUndo.title = reason ? "" : t("connector.undoTarget", { id: undo.connectorId, position: undo.positionId }); } function renderConnectors(board) { - ensureConnectorSelection(board); const ids = Object.keys(board.connectors ?? {}); + document.getElementById("connector-reference-links").innerHTML = boardReferenceLinks(board); elements.connectorSectionTitle.textContent = `${t("connector.pinoutHeading")} (${ids.length})`; - elements.connectorSection.open = disclosureOpen("connectorMap"); - elements.connectorTabs.innerHTML = ids.map((id) => ``).join(""); - elements.connectorViewToggle.querySelectorAll("[data-connector-view]").forEach((button) => { - const active = button.dataset.connectorView === state.connectorView; - button.classList.toggle("active", active); - button.setAttribute("aria-pressed", String(active)); - }); - const renderer = state.connectorView === "svg" ? renderConnectorDiagram : renderConnectorTable; - elements.connectorDiagram.innerHTML = state.connectorId ? renderer(board, state.connectorId) : `
    ${escapeHtml(t("connector.none"))}
    `; - const undoReason = isResolvedMode() - ? t("readonly.configuration") - : (!state.undo || state.undo.boardId !== state.currentId) ? t("unavailable.noUndo") : ""; - applyControlLock(elements.connectorUndo, undoReason); - renderConnectorEditor(board); + const set = connectorDisplaySets(board).find((item) => item.ids.includes(state.connectorId)); + elements.connectorPinout.innerHTML = set ? `
    ${set.ids.map((id) => { + const type = resolveConnectorType(connectorTypes, board.connectors[id].type); + return `

    ${escapeHtml(id)} ${escapeHtml(t("connector.firstPin", { pin: firstConnectorPosition(type) }))}

    ${renderConnectorTable(board, id)}
    `; + }).join("")}
    ` : `
    ${escapeHtml(t("connector.none"))}
    `; + hydrateConnectorEditor(elements.connectorPinout.querySelector("[data-connector-editor]")); } function renderRelated(board) { const groups = [["buses", "bus", t("related.buses")], ["devices", "dev", t("related.devices")]]; const count = groups.reduce((sum, [field]) => sum + Object.keys(board[field] ?? {}).length, 0); elements.relatedSectionTitle.textContent = `${t("related.heading")} (${count})`; - elements.relatedSection.open = disclosureOpen("related"); elements.relatedList.innerHTML = groups.map(([field, prefix, label]) => { const items = Object.entries(board[field] ?? {}).map(([id, object]) => { const pins = objectAssignments(board, prefix, id, object); @@ -5117,7 +5366,7 @@

    const links = gpioLinks + ownerLinks; return `
    ${roleOwnerLabel(objectRoleOwner(prefix, id), id, "object-owner-label")}${escapeHtml(object.kind ?? object.type ?? "")}
    ${objectSummary(field, object)}
    `; }).join(""); - return `

    ${escapeHtml(label)}

    ${items || `
    ${escapeHtml(t("related.empty"))}
    `}
    `; + return ``; }).join(""); } @@ -5168,7 +5417,7 @@

    ["power.battery_mah", "number"], ["power.usb", "string"], ["links.schematic", "string"], ["links.product", "string"], ]; const resolution = valueAtPath(board.spec, "display.resolution") ?? []; - return `
    ${fields.map(([path, type]) => ``).join("")}
    `; + return `
    ${fields.map(([path, type]) => ``).join("")}
    `; } function renderBoardInfo(shown) { @@ -5177,12 +5426,11 @@

    const display = Array.isArray(resolution) && resolution.length >= 2 ? `${resolution[0]}×${resolution[1]}` : "—"; const psram = shown.spec?.storage?.psram_mb ? `PSRAM ${shown.spec.storage.psram_mb}MB` : "PSRAM —"; elements.boardInfoSummary.textContent = `${shown.name} — ${shown.chip} — ${display} — ${psram}`; - elements.boardInfo.open = disclosureOpen("boardInfo"); elements.boardInfoBody.innerHTML = `
    ${boardSpecFields(board)}`; } function configurationDisclosure(id, label, count, description, body) { - return ``; + return ``; } function busRemovalReason(board, id) { @@ -5237,7 +5485,7 @@

    ? `
    ` : ""; const partChoices = Object.values(parts).filter((part) => part.kind === device.kind && part.id !== device.part).map((part) => ``).join(""); - return `
    ${roleOwnerLabel(objectRoleOwner("dev", id), id, "configuration-owner-label")}${escapeHtml(device.part ?? device.kind)}${readonlyNote(reason)}${choiceControls}${choices}
    ${device.choices ? "" : ``}
    ${missing.length ? `
    ${escapeHtml(t("configuration.unassigned"))}:${missing.map((signal) => ``).join("")}
    ` : ""}${availableOptional.length ? `
    ${escapeHtml(t("configuration.optional"))}:${availableOptional.map((signal) => ``).join("")}
    ` : ""}
    `; + return `
    ${roleOwnerLabel(objectRoleOwner("dev", id), id, "configuration-owner-label")}${escapeHtml(device.part ?? device.kind)}${readonlyNote(reason)}${choiceControls}${choices}
    ${device.choices ? "" : ``}
    ${missing.length ? `
    ${escapeHtml(t("configuration.unassigned"))}:${missing.map((signal) => ``).join("")}
    ` : ""}${availableOptional.length ? `
    ${escapeHtml(t("configuration.optional"))}:${availableOptional.map((signal) => ``).join("")}
    ` : ""}
    `; } function addField(label, control, className = "") { @@ -5251,12 +5499,12 @@

    const standards = Object.keys(type?.standards ?? {}); const reason = objectRemovalReason(board, "conn", id); const mark = reason ? `🔗` : ""; - return `
    ${mark}${roleOwnerLabel(objectRoleOwner("conn", id), id, "configuration-owner-label")}${escapeHtml(connector.type)}
    `; + return `
    ${mark}${roleOwnerLabel(objectRoleOwner("conn", id), id, "configuration-owner-label")}${escapeHtml(connector.type)}
    `; }).join(""); const busRows = Object.entries(board.buses ?? {}).map(([id, bus]) => { const reason = busRemovalReason(board, id); const mark = reason ? `🔗` : ""; - return `
    ${mark}${roleOwnerLabel(objectRoleOwner("bus", id), id, "configuration-owner-label")}${escapeHtml(bus.kind)}${escapeHtml((bus.signals ?? []).join(", "))}
    `; + return `
    ${mark}${roleOwnerLabel(objectRoleOwner("bus", id), id, "configuration-owner-label")}${escapeHtml(bus.kind)}${escapeHtml((bus.signals ?? []).join(", "))}
    `; }).join(""); const deviceRows = Object.entries(board.devices ?? {}).map(([id, device]) => deviceRow(board, id, device)).join(""); const entries = state.compositionEntries.get(state.currentId) ?? []; @@ -5393,10 +5641,6 @@

    } function renderViews(board) { - for (const button of elements.viewTabs.querySelectorAll("[data-view]")) button.classList.toggle("active", button.dataset.view === state.activeView); - elements.configurationView.hidden = state.activeView !== "configuration"; - elements.revisionsView.hidden = state.activeView !== "revisions"; - elements.ownersView.hidden = state.activeView !== "owners"; renderBoardInfo(board); renderConfiguration(); renderRevisionEditor(); @@ -5416,7 +5660,7 @@

    function renderRepositoryNext() { const file = state.lastExported; prepareMotionRegion(elements.repositoryNext); - if (file) elements.repositoryNext.querySelector(".motion-region-inner").innerHTML = `
    ${escapeHtml(t("repository.downloaded", { file }))}

    ${escapeHtml(t("repository.notAutomatic"))}

    1. ${escapeHtml(t("repository.replace", { file }))}
    2. node cli/spec.js check
    3. ${escapeHtml(t("repository.commit"))}
    `; + if (file) elements.repositoryNext.querySelector(".motion-region-inner").innerHTML = `
    ${escapeHtml(t("repository.downloaded", { file }))}

    ${escapeHtml(t("repository.notAutomatic"))}

    1. ${escapeHtml(t("repository.replace", { file }))}
    2. node cli/spec.js check
    3. ${escapeHtml(t("repository.commit"))}
    `; elements.repositoryNext.classList.toggle("is-open", Boolean(file)); elements.repositoryNext.setAttribute("aria-hidden", String(!file)); elements.repositoryNext.inert = !file; @@ -5425,10 +5669,10 @@

    function renderAll() { try { applyStaticTranslations(); - setSidebarWidth(state.layout.sidebarWidth); renderOnboarding(); const board = shownBoard(); const errors = validationFor(currentBase()); + navigateTo(state.page ?? state.layout.page, { remember: false, render: false }); renderBoardSelect(); renderOptions(); renderViews(board); @@ -5439,6 +5683,9 @@

    renderRelated(board); renderSaveState(); renderRepositoryNext(); + renderNavigation(board); + applyPageVisibility(); + requestAnimationFrame(() => { if (state.page.kind === "gpio") sizeConnectorLanes(assignConnectorLanes(board.pins).lanes.length); }); prepareDisclosureTransitions(); } catch (failure) { showToast(failure.message); @@ -5483,11 +5730,13 @@

    } catch (failure) { operationFailure(failure); } } -function rememberConnectorUndo(board) { +function rememberConnectorUndo(board, connectorId, positionId) { const entry = currentEntry(); state.undo = { boardId: state.currentId, base: clone(board), + connectorId, + positionId, dirty: entry.dirty, expandedGPIO: entry.expandedGPIO, }; @@ -5522,9 +5771,8 @@

    if (!window.confirm(t("connector.multiConfirm"))) return; } - rememberConnectorUndo(board); - state.connectorId = connectorId; - state.connectorPosition = positionId; + rememberConnectorUndo(board, connectorId, positionId); + if (state.page?.kind === "connector") navigateTo({ kind: "connector", id: connectorId, position: positionId }, { remember: false, render: false }); if (options.removeGPIO !== undefined) { removeConnectorRole(board, connectorId, positionId, options.removeGPIO); @@ -5565,50 +5813,21 @@

    renderAll(); } -function connectorForGPIO(board, gpio) { - for (const [id, connector] of Object.entries(board.connectors ?? {})) { - const positions = connectorRoleGPIOs(board, id); - if ([...positions.values()].some((gpios) => gpios.includes(Number(gpio)))) return id; - } - return null; -} - -function revealConnectorForGPIO(gpio) { - const connectorId = connectorForGPIO(currentBase(), gpio); - if (connectorId && connectorId !== state.connectorId) { - state.connectorId = connectorId; - state.connectorPosition = null; - } -} - function focusGPIO(gpio) { if (gpio === null || gpio === undefined) return; - const entry = currentEntry(); - entry.expandedGPIO = String(gpio); - revealConnectorForGPIO(gpio); - writeDraft(entry); - renderAll(); - const row = document.getElementById(`gpio-${gpio}`); - if (!row) return; - row.scrollIntoView({ behavior: "smooth", block: "center" }); - row.classList.remove("flash"); - requestAnimationFrame(() => row.classList.add("flash")); + navigateTo({ kind: "gpio", gpio }); } function openRoleChooser(role) { - state.activeView = "configuration"; clearResolvedMode(); const chip = chipForBoard(currentBase()); const conditional = chip.reserved_conditional?.[currentBase().spec?.storage?.psram_mode] ?? []; const gpio = Array.from({ length: chip.gpio_count }, (_, index) => String(index)).find((id) => !chip.absent.includes(Number(id)) && !chip.reserved.includes(Number(id)) && !conditional.includes(Number(id)) && allRoleOptions(currentBase(), id).includes(role) && !(currentBase().pins[id]?.roles?.length)) ?? "0"; - currentEntry().expandedGPIO = gpio; renderAll(); - const input = elements.tableBody.querySelector(`[data-role-combobox="${gpio}"] input`); - if (input) { - input.value = role; - input.focus(); - input.dispatchEvent(new Event("input", { bubbles: true })); - } + navigateTo({ kind: "gpio", gpio }, { after: () => { + const input = elements.tableBody.querySelector(`[data-role-combobox="${gpio}"] input`); + if (input) { input.value = role; input.focus(); input.dispatchEvent(new Event("input", { bubbles: true })); } + } }); } async function openFile(file) { @@ -5624,8 +5843,7 @@

    state.selectedOptions.clear(); state.runtimeSelections.set(value.id, Object.fromEntries(choiceSlots(value).filter(([, device]) => device.selected_by === "runtime").map(([slot, device]) => [slot, device.default]))); state.compositionEntries.set(value.id, clone(targets.m5unified_pin_table?.compositions?.[value.id]?.default?.accessories ?? [])); - state.connectorId = null; - state.connectorPosition = null; + navigateTo({ kind: state.page?.kind ?? "gpio" }, { reset: true, render: false }); state.undo = null; renderAll(); showToast(t("file.opened", { file: file.name })); @@ -5734,8 +5952,40 @@

    renderOnboarding(); }); elements.onboardingClose.addEventListener("click", dismissOnboarding); +elements.onboarding.addEventListener("cancel", (event) => { event.preventDefault(); dismissOnboarding(); }); +elements.repositoryNext.addEventListener("click", (event) => { + if (event.target.closest("[data-dismiss-export]")) { state.lastExported = null; renderRepositoryNext(); } +}); +const fileMenu = document.getElementById("file-menu"); +fileMenu.addEventListener("click", (event) => { if (event.target.closest("button")) fileMenu.open = false; }); +fileMenu.addEventListener("keydown", (event) => { + if (event.key === "Escape") { fileMenu.open = false; fileMenu.querySelector("summary").focus(); } +}); +function closeRuntimePanel(returnFocus = false) { + state.runtimePanelOpen = false; + const panel = document.getElementById("runtime-panel"); + if (panel) panel.hidden = true; + const button = elements.optionPanel.querySelector("[data-runtime-toggle]"); + button?.setAttribute("aria-expanded", "false"); + if (returnFocus) button?.focus(); +} +elements.optionPanel.addEventListener("click", (event) => { + const button = event.target.closest("[data-runtime-toggle]"); + if (!button) return; + state.runtimePanelOpen = !state.runtimePanelOpen; + document.getElementById("runtime-panel").hidden = !state.runtimePanelOpen; + button.setAttribute("aria-expanded", String(state.runtimePanelOpen)); +}); +elements.optionPanel.addEventListener("keydown", (event) => { + if (event.key === "Escape" && state.runtimePanelOpen) { event.preventDefault(); closeRuntimePanel(true); } +}); +document.addEventListener("click", (event) => { + if (!event.target.closest(".runtime-control")) closeRuntimePanel(); + if (!fileMenu.contains(event.target)) fileMenu.open = false; +}); elements.fileInput.addEventListener("change", () => { + fileMenu.open = false; const [file] = elements.fileInput.files; if (file) openFile(file); }); @@ -5750,33 +6000,38 @@

    elements.gpioFilter.value = state.gpioFilter; elements.gpioAssignedOnly.checked = state.gpioAssignedOnly; elements.gpioFilter.addEventListener("input", () => { + navigateTo({ kind: "gpio" }, { remember: false, render: false }); state.gpioFilter = elements.gpioFilter.value; saveGPIOFilterState(); renderWithFade(() => renderTable(shownBoard(), validationFor(currentBase()))); }); elements.gpioAssignedOnly.addEventListener("change", () => { + navigateTo({ kind: "gpio" }, { remember: false, render: false }); state.gpioAssignedOnly = elements.gpioAssignedOnly.checked; saveGPIOFilterState(); renderWithFade(() => renderTable(shownBoard(), validationFor(currentBase()))); }); -elements.roleColorLegend.addEventListener("change", (event) => { +for (const legend of [elements.roleColorLegend, elements.connectorRoleColorLegend]) { +legend.addEventListener("change", (event) => { const owner = event.target.dataset.roleColor; if (!owner || !/^#[0-9a-f]{6}$/i.test(event.target.value)) return; state.roleColors = { ...state.roleColors, [owner]: event.target.value.toLowerCase() }; saveRoleColors(); renderWithFade(renderAll); }); -elements.roleColorLegend.addEventListener("toggle", (event) => { - if (event.target.matches("details")) state.roleColorsOpen = event.target.open; +legend.addEventListener("toggle", (event) => { + if (event.target.matches("details")) state[legend === elements.roleColorLegend ? "roleColorsOpen" : "connectorRoleColorsOpen"] = event.target.open; }, true); -elements.roleColorLegend.addEventListener("click", (event) => { +legend.addEventListener("click", (event) => { if (!event.target.closest("[data-reset-role-colors]")) return; state.roleColors = {}; saveRoleColors(); renderWithFade(renderAll); }); +} + elements.optionPanel.addEventListener("change", (event) => { if (event.target.matches("[data-display-revision]")) { state.selectedOptions.clear(); @@ -5792,11 +6047,17 @@

    } }); -elements.viewTabs.addEventListener("click", (event) => { - const button = event.target.closest("[data-view]"); - if (!button) return; - state.activeView = button.dataset.view; - renderAll(); +elements.pageNav.addEventListener("click", (event) => { + const group = event.target.closest("[data-nav-group]"); + if (group) { const focused = document.activeElement === group; state.layout.navGroups.connectors = !state.layout.navGroups.connectors; renderNavigation(shownBoard()); applyPageVisibility(); saveLayoutState(); if (focused) elements.pageNav.querySelector("[data-nav-group]")?.focus(); return; } + const button = event.target.closest("[data-route-kind]"); + if (button) navigateTo({ kind: button.dataset.routeKind, id: button.dataset.routeId }); +}); +elements.navToggle.addEventListener("click", () => { state.layout.navCollapsed = !state.layout.navCollapsed; applyPageVisibility(); saveLayoutState(); requestAnimationFrame(() => { if (state.page.kind === "gpio") sizeConnectorLanes(assignConnectorLanes(shownBoard().pins).lanes.length); }); }); +elements.pageBack.addEventListener("click", () => { + const previous = state.previousPage; + state.previousPage = null; + if (previous?.boardId === state.currentId) navigateTo(previous.route, { remember: false, restoreScroll: previous.scroll }); }); elements.configurationView.addEventListener("focusout", (event) => { @@ -5866,34 +6127,6 @@

    saveLayoutState(); }, true); -elements.sideResizer.addEventListener("pointerdown", (event) => { - if (event.button !== 0 || window.matchMedia("(max-width: 980px)").matches) return; - event.preventDefault(); - const startX = event.clientX; - const startWidth = state.layout.sidebarWidth; - elements.sideResizer.classList.add("is-dragging"); - elements.sideResizer.setPointerCapture?.(event.pointerId); - const move = (moveEvent) => setSidebarWidth(startWidth - (moveEvent.clientX - startX)); - const finish = () => { - elements.sideResizer.classList.remove("is-dragging"); - window.removeEventListener("pointermove", move); - window.removeEventListener("pointerup", finish); - window.removeEventListener("pointercancel", finish); - saveLayoutState(); - }; - window.addEventListener("pointermove", move); - window.addEventListener("pointerup", finish); - window.addEventListener("pointercancel", finish); -}); - -elements.sideResizer.addEventListener("keydown", (event) => { - if (!["ArrowLeft", "ArrowRight"].includes(event.key)) return; - event.preventDefault(); - const step = event.shiftKey ? 32 : 16; - setSidebarWidth(state.layout.sidebarWidth + (event.key === "ArrowLeft" ? step : -step)); - saveLayoutState(); -}); - elements.configurationList.addEventListener("click", (event) => { const roleButton = event.target.closest("[data-unassigned-role]"); if (roleButton) return openRoleChooser(roleButton.dataset.unassignedRole); @@ -5982,8 +6215,7 @@

    elements.ownerEditor.addEventListener("click", (event) => { const tab = event.target.closest("[data-owner-key]"); if (!tab) return; - state.ownerKey = tab.dataset.ownerKey; - renderAll(); + navigateTo({ kind: "owners", id: tab.dataset.ownerKey }); }); function toggleGPIORow(row) { @@ -5992,7 +6224,7 @@

    const next = previous === row.dataset.gpioRow ? null : row.dataset.gpioRow; if (prefersReducedMotion()) { entry.expandedGPIO = next; - if (next !== null) revealConnectorForGPIO(next); + navigateTo({ kind: "gpio", gpio: next }, { remember: false, render: false }); writeDraft(entry); renderAll(); return; @@ -6017,7 +6249,7 @@

    openingGPIOs.add(next); } entry.expandedGPIO = next; - if (entry.expandedGPIO !== null) revealConnectorForGPIO(entry.expandedGPIO); + navigateTo({ kind: "gpio", gpio: next }, { remember: false, render: false }); writeDraft(entry); renderAll(); } @@ -6030,16 +6262,8 @@

    } const related = event.target.closest("[data-target-object]"); if (related) { - if (related.dataset.targetObject.startsWith("connectors:")) { - state.connectorId = related.dataset.targetObject.slice("connectors:".length); - state.connectorPosition = null; - renderAll(); - return; - } - const object = elements.relatedList.querySelector(`[data-object-key="${related.dataset.targetObject}"]`); - object?.scrollIntoView({ behavior: "smooth", block: "center" }); - object?.classList.add("flash"); - setTimeout(() => object?.classList.remove("flash"), 1200); + const [kind, ...id] = related.dataset.targetObject.split(":"); + navigateTo({ kind: kind === "connectors" ? "connector" : kind, id: id.join(":"), position: related.dataset.targetPosition }); return; } const row = event.target.closest("[data-gpio-row]"); @@ -6067,7 +6291,9 @@

    if (!button) return; const board = shownBoard(); const errors = validationFor(currentBase()); - focusGPIO(gpioForError(errors[Number(button.dataset.errorIndex)], board)); + const item = errors[Number(button.dataset.errorIndex)]; + const gpio = gpioForError(item, board); + navigateTo(gpio != null ? { kind: "gpio", gpio } : editorErrorRoute(item, board)); }); elements.relatedList.addEventListener("click", (event) => { @@ -6077,39 +6303,24 @@

    if (owner) { const [ownerId, pin] = owner.dataset.ownerTarget.split(":"); const shown = shownBoard(); - state.ownerKey = ownerDefinitions(currentBase()).find((item) => item.id === ownerId && item.part === shown.devices?.[ownerId]?.part)?.key ?? ownerId; - state.activeView = "owners"; - renderAll(); - requestAnimationFrame(() => document.getElementById(`owner-${state.ownerKey}-${pin}`)?.scrollIntoView({ behavior: "smooth", block: "center" })); + const key = ownerDefinitions(currentBase()).find((item) => item.id === ownerId && item.part === shown.devices?.[ownerId]?.part)?.key ?? ownerId; + navigateTo({ kind: "owners", id: key }, { after: () => document.getElementById(`owner-${key}-${pin}`)?.scrollIntoView({ behavior: "smooth", block: "center" }) }); } }); -elements.connectorTabs.addEventListener("click", (event) => { - const tab = event.target.closest("[data-connector-tab]"); - if (!tab) return; - state.connectorId = tab.dataset.connectorTab; - state.connectorPosition = null; - renderAll(); -}); - -elements.connectorViewToggle.addEventListener("click", (event) => { - const button = event.target.closest("[data-connector-view]"); - if (!button) return; - state.connectorView = button.dataset.connectorView; - renderAll(); -}); - function selectConnectorPosition(target) { - state.connectorPosition = state.connectorPosition === target.dataset.connectorPosition ? null : target.dataset.connectorPosition; - renderAll(); + const id = target.closest("[data-connector-id]")?.dataset.connectorId ?? state.connectorId; + navigateTo({ kind: "connector", id, position: state.connectorId === id && state.connectorPosition === target.dataset.connectorPosition ? null : target.dataset.connectorPosition }); } -elements.connectorDiagram.addEventListener("click", (event) => { +elements.connectorPinout.addEventListener("click", (event) => { + const gpio = event.target.closest("[data-target-gpio]"); + if (gpio) return focusGPIO(gpio.dataset.targetGpio); const position = event.target.closest("[data-connector-position]"); if (position) selectConnectorPosition(position); }); -elements.connectorDiagram.addEventListener("keydown", (event) => { +elements.connectorPinout.addEventListener("keydown", (event) => { if (event.key !== "Enter" && event.key !== " ") return; const position = event.target.closest("[data-connector-position]"); if (!position) return; @@ -6117,27 +6328,9 @@

    selectConnectorPosition(position); }); -elements.connectorDiagram.addEventListener("mouseover", (event) => { - const position = event.target.closest("[data-connector-position]"); - const related = event.relatedTarget?.closest?.("[data-connector-position]"); - if (!position || related?.dataset.connectorPosition === position.dataset.connectorPosition) return; - const endpoint = resolveConnectorPositions(shownBoard(), state.connectorId, connectorTypes).positions[position.dataset.connectorPosition]; - const rows = endpointGPIOs(endpoint).map((gpio) => document.getElementById(`gpio-${gpio}`)).filter(Boolean); - rows.forEach((row) => row.classList.add("diagram-highlight")); - rows[0]?.scrollIntoView({ behavior: "smooth", block: "nearest" }); -}); - -elements.connectorDiagram.addEventListener("mouseout", (event) => { - const position = event.target.closest("[data-connector-position]"); - const related = event.relatedTarget?.closest?.("[data-connector-position]"); - if (!position || related?.dataset.connectorPosition === position.dataset.connectorPosition) return; - elements.tableBody.querySelectorAll(".diagram-highlight").forEach((row) => row.classList.remove("diagram-highlight")); -}); - function handleConnectorEditorClick(event) { if (event.target.closest("[data-connector-close]")) { - state.connectorPosition = null; - renderAll(); + navigateTo({ kind: "connector", id: state.connectorId }); return; } const tab = event.target.closest("[data-connector-editor-tab]"); @@ -6151,8 +6344,7 @@

    } } -elements.connectorEditor.addEventListener("click", handleConnectorEditorClick); -elements.connectorDiagram.addEventListener("click", (event) => { +elements.connectorPinout.addEventListener("click", (event) => { if (event.target.closest("[data-connector-editor]")) handleConnectorEditorClick(event); }); @@ -6165,8 +6357,7 @@

    return; } if (event.key !== "Escape" || !state.connectorPosition) return; - state.connectorPosition = null; - renderAll(); + navigateTo({ kind: "connector", id: state.connectorId }); }); elements.connectorUndo.addEventListener("click", () => { @@ -6181,25 +6372,30 @@

    renderAll(); }); +let fileDragDepth = 0; +const isFileDrag = (event) => [...(event.dataTransfer?.types ?? [])].includes("Files"); for (const eventName of ["dragenter", "dragover"]) { elements.dropZone.addEventListener(eventName, (event) => { + if (!isFileDrag(event)) return; event.preventDefault(); + if (eventName === "dragenter") fileDragDepth += 1; + if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; elements.dropZone.classList.add("dragging"); }); } -for (const eventName of ["dragleave", "drop"]) { - elements.dropZone.addEventListener(eventName, (event) => { - event.preventDefault(); - elements.dropZone.classList.remove("dragging"); - }); -} +elements.dropZone.addEventListener("dragleave", (event) => { + if (!isFileDrag(event)) return; + fileDragDepth = Math.max(0, fileDragDepth - 1); + if (!fileDragDepth) elements.dropZone.classList.remove("dragging"); +}); elements.dropZone.addEventListener("drop", (event) => { + if (!isFileDrag(event) && !event.dataTransfer?.files?.length) return; + event.preventDefault(); + fileDragDepth = 0; + elements.dropZone.classList.remove("dragging"); const [file] = event.dataTransfer.files; if (file) openFile(file); }); -elements.dropZone.addEventListener("keydown", (event) => { - if (event.key === "Enter" || event.key === " ") elements.fileInput.click(); -}); window.addEventListener("beforeunload", (event) => { if (![...state.documents.values()].some((entry) => entry.dirty)) return; diff --git a/extras/board_spec/editor/editor.css b/extras/board_spec/editor/editor.css index be7a97b0..8c213cd2 100644 --- a/extras/board_spec/editor/editor.css +++ b/extras/board_spec/editor/editor.css @@ -46,9 +46,9 @@ button:disabled, select:disabled, input:disabled, [aria-disabled="true"] { opaci .onboarding-heading .eyebrow { color: var(--accent); } .onboarding-heading button { flex: none; padding: .32rem .55rem; } .onboarding-purpose { margin: .35rem 0 .65rem; color: #38525c; font-size: .78rem; } -.onboarding > .motion-region-inner > ol { display: grid; grid-template-columns: repeat(4, minmax(8rem, 1fr)); gap: .55rem; margin: 0; padding: 0; list-style: none; } -.onboarding > .motion-region-inner > ol > li { display: flex; align-items: start; gap: .4rem; color: #38525c; font-size: .72rem; } -.onboarding > .motion-region-inner > ol > li > strong { display: grid; flex: none; place-items: center; inline-size: 1.45rem; block-size: 1.45rem; border-radius: 50%; background: var(--accent); color: #fff; } +.onboarding > ol { display: grid; grid-template-columns: repeat(4, minmax(8rem, 1fr)); gap: .55rem; margin: 0; padding: 0; list-style: none; } +.onboarding > ol > li { display: flex; align-items: start; gap: .4rem; color: #38525c; font-size: .72rem; } +.onboarding > ol > li > strong { display: grid; flex: none; place-items: center; inline-size: 1.45rem; block-size: 1.45rem; border-radius: 50%; background: var(--accent); color: #fff; } .onboarding-export details { margin-top: .25rem; } .onboarding-export summary { cursor: pointer; color: var(--accent); font-weight: 750; } .onboarding-export ol { display: grid; gap: .15rem; margin: .25rem 0; padding-left: 1.2rem; } @@ -137,7 +137,6 @@ td { padding: .42rem .5rem; vertical-align: top; border-bottom: 1px solid #edf0f .gpio-row:focus { outline: 2px solid #005fcc; outline-offset: -2px; } .gpio-row.is-expanded td { background-color: #eaf6f5; border-bottom-color: #b8d9d7; } .gpio-row.is-readonly td { color: #53656d; } -.gpio-row.diagram-highlight td { background-color: #fff1b8; } tr.gpio-reserved td:first-child { background: var(--reserved); } tr.gpio-input td:first-child { box-shadow: inset 4px 0 #6699cc; } tr.gpio-strapping td:first-child { border-right: 5px solid #e1b12c; } @@ -238,56 +237,34 @@ tr.flash { animation: flash 1.2s ease; } .error-list .warning button { background: #fff8df; color: var(--warning); border-color: #e7c86e; } .error-list code { display: block; margin-top: .2rem; color: #7d3b34; font-size: .72rem; white-space: normal; } .validation-action { display: block; margin-top: .3rem; color: inherit; font-size: .7rem; line-height: 1.35; } -.connector-tabs { display: flex; gap: .3rem; padding: .55rem .65rem; overflow-x: auto; border-bottom: 1px solid var(--line); } +.connector-tabs { display: flex; gap: .3rem; padding: .55rem .65rem; flex-wrap: wrap; border-bottom: 1px solid var(--line); } .connector-card { position: relative; } -.connector-actions, .connector-view-toggle { display: flex; align-items: center; gap: .3rem; } -.connector-actions { justify-content: flex-end; padding: 0 .65rem .55rem; } -.connector-view-toggle { padding: .18rem; border: 1px solid #afbec7; border-radius: 7px; background: #edf2f3; } -.connector-view-toggle button { padding: .2rem .42rem; border: 0; background: transparent; color: var(--muted); font-size: .7rem; } -.connector-view-toggle button.active { background: #fff; color: var(--accent); box-shadow: 0 1px 3px rgb(19 43 53 / 18%); } .connector-tabs button, .position-editor-tabs button { padding: .3rem .5rem; background: #fff; color: var(--muted); border-color: #afbec7; font-size: .72rem; white-space: nowrap; } .connector-tabs button.active, .position-editor-tabs button.active { background: var(--accent); color: #fff; border-color: var(--accent); } .connector-tabs button.connector-object-tab { border-left-color: var(--role-bg); border-left-width: 5px; } -.connector-diagram { padding: .5rem; overflow: auto; background: #f8fafb; } -.connector-diagram svg { display: block; width: 100%; min-width: 14rem; height: auto; margin: auto; } +.connector-pinout { padding: .5rem; overflow: auto; background: #f8fafb; } .connector-meta { display: flex; justify-content: space-between; gap: .5rem; margin: 0 .25rem .35rem; color: var(--muted); font-size: .68rem; } -.connector-position { cursor: pointer; outline: none; } -.connector-position circle { fill: var(--position-color, #fff); stroke: #405c67; stroke-width: 2; } -.connector-position.default { opacity: .58; } -.connector-position.default circle { stroke-dasharray: 3 3; stroke-width: 1.5; } -.connector-position.fixed circle { fill: var(--position-color, #aab5bb); stroke: #66747b; } -.connector-position.missing circle { fill: #fff; stroke: #c35347; stroke-dasharray: 4 3; } -.connector-position.selected circle { stroke: #006d77; stroke-width: 5; } -.connector-position.gpio-highlight circle { filter: drop-shadow(0 0 5px #e6a700); stroke: #e6a700; stroke-width: 5; } -.connector-position:focus circle { stroke: #005fcc; stroke-width: 5; } -.connector-position text { pointer-events: none; text-anchor: middle; fill: var(--role-fg, #17212b); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } -.connector-key { fill: var(--wash); stroke: #607080; stroke-width: 2; } -.connector-position.fixed text { fill: #fff; } -.position-id { font-size: 9px; font-weight: 800; } -.position-label { font-size: 8px; } -.position-endpoint { font-size: 9px; font-weight: 800; } -.connector-pin-table { width: 100%; border-collapse: separate; border-spacing: 0; table-layout: fixed; font: .7rem ui-monospace, SFMono-Regular, Menlo, monospace; } +.connector-pin-table { width: 100%; border-collapse: separate; border-spacing: 0; table-layout: auto; font: .7rem ui-monospace, SFMono-Regular, Menlo, monospace; } .connector-pin-table tr { height: 1.8rem; } .connector-pin-table td { height: 1.8rem; padding: .28rem .35rem; overflow: hidden; border-bottom: 1px solid #dfe6e9; background: #fff; cursor: pointer; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; } .connector-pin-table td:focus { position: relative; z-index: 1; outline: 2px solid #005fcc; outline-offset: -2px; } -.connector-pin-table .pin-number { width: 2.8rem; color: var(--muted); font-weight: 800; text-align: center; white-space: nowrap; } +.connector-pin-table .pin-number { width: 1%; color: var(--muted); font-weight: 800; text-align: center; white-space: nowrap; } .connector-pin-table .pin-function { color: #506771; font-size: .64rem; } -.connector-pin-table .pin-endpoint { font-weight: 800; } +.connector-pin-table .pin-endpoint { width: 1%; white-space: nowrap; font-weight: 800; } .connector-pin-table .pin-left { text-align: right; } .connector-pin-table .pin-right { text-align: left; } .connector-pin-table .pin-divider { border-left: 2px solid #91a5b1; } .connector-pin-table td.default { background: #f5f7f8; color: #829098; } .connector-pin-table td.fixed { background: #d9dfe2; color: #465860; } .connector-pin-table td.missing { border-top: 1px dashed #c35347; border-bottom: 1px dashed #c35347; background: #fffafa; } -.connector-pin-table td.missing.pin-endpoint.pin-left, .connector-pin-table td.missing.pin-number:not(.pin-left, .pin-right) { border-left: 1px dashed #c35347; } -.connector-pin-table td.missing.pin-number.pin-left, .connector-pin-table td.missing.pin-endpoint:not(.pin-left, .pin-right) { border-right: 1px dashed #c35347; } +.connector-pin-table td.missing.pin-function.pin-left, .connector-pin-table td.missing.pin-number:not(.pin-left, .pin-right) { border-left: 1px dashed #c35347; } +.connector-pin-table td.missing.pin-number.pin-left, .connector-pin-table td.missing.pin-function:not(.pin-left, .pin-right) { border-right: 1px dashed #c35347; } .connector-pin-table td.missing.pin-number.pin-right { border-left: 1px dashed #c35347; } -.connector-pin-table td.missing.pin-endpoint.pin-right { border-right: 1px dashed #c35347; } +.connector-pin-table td.missing.pin-function.pin-right { border-right: 1px dashed #c35347; } .connector-pin-table td.selected { background: #d7efed; box-shadow: inset 0 2px #006d77, inset 0 -2px #006d77; } .connector-pin-table td.gpio-highlight { background: #fff1b8; box-shadow: inset 0 2px #e6a700, inset 0 -2px #e6a700; } .connector-color { display: inline-block; width: .65rem; height: .65rem; margin-right: .3rem; border: 1px solid #73828a; border-radius: 50%; vertical-align: -.08rem; background: var(--position-color, #fff); } .connector-editor { padding: .65rem; border-top: 1px solid var(--line); } -.connector-editor[hidden] { display: none; } .connector-editor.open { background: #fff; } .connector-pin-table tr.connector-inline-row { height: auto; } .connector-pin-table td.connector-inline-cell { height: auto; padding: 0; overflow: visible; border-bottom: 1px solid #91a5b1; cursor: default; white-space: normal; } @@ -389,7 +366,7 @@ tr.flash { animation: flash 1.2s ease; } .motion-details[open]:not(.motion-opening, .motion-closing) > .motion-collapse { grid-template-rows: 1fr; opacity: 1; visibility: visible; transition-delay: 0s; } .motion-details.motion-opening > .motion-collapse, .motion-details.motion-closing > .motion-collapse { grid-template-rows: 0fr; opacity: 0; visibility: hidden; } .motion-details > summary { cursor: pointer; } -.role-chip, .role-owner-label, .related-object-link, .owner-object-tab, .connector-object-tab, .connector-position circle { transition: color var(--motion-duration) var(--motion-ease), background-color var(--motion-duration) var(--motion-ease), fill var(--motion-duration) var(--motion-ease), stroke var(--motion-duration) var(--motion-ease), border-color var(--motion-duration) var(--motion-ease); } +.role-chip, .role-owner-label, .related-object-link, .owner-object-tab, .connector-object-tab { transition: color var(--motion-duration) var(--motion-ease), background-color var(--motion-duration) var(--motion-ease), fill var(--motion-duration) var(--motion-ease), stroke var(--motion-duration) var(--motion-ease), border-color var(--motion-duration) var(--motion-ease); } @keyframes state-change { from { opacity: .35; } to { opacity: 1; } } ::view-transition-old(root), ::view-transition-new(root) { animation-duration: var(--motion-duration); animation-timing-function: var(--motion-ease); } @@ -408,7 +385,7 @@ tr.flash { animation: flash 1.2s ease; } .dialog-error.warning { color: var(--warning); } @media (max-width: 980px) { - .onboarding > .motion-region-inner > ol { grid-template-columns: repeat(2, minmax(8rem, 1fr)); } + .onboarding > ol { grid-template-columns: repeat(2, minmax(8rem, 1fr)); } .repository-next { margin-inline: 1rem; } .repository-next .motion-region-inner { align-items: start; flex-direction: column; } .repository-next ol { display: grid; } @@ -438,5 +415,153 @@ tr.flash { animation: flash 1.2s ease; } .save-state { font-size: .75rem; } .language-picker select { width: 7.25rem; } .onboarding { margin: .5rem 1rem 0; } - .onboarding > .motion-region-inner > ol { grid-template-columns: 1fr; } + .onboarding > ol { grid-template-columns: 1fr; } } + +.connector-meta { display: grid; justify-content: initial; gap: .3rem; font-size: .75rem; overflow-wrap: anywhere; } +.connector-note { margin: 0; white-space: normal; line-height: 1.45; } +.reference-links { display: flex; flex-wrap: wrap; gap: .3rem; font-size: .7rem; } +.reference-links a { color: var(--accent); } +.connector-pin-table th { padding: .35rem; background: var(--wash); color: var(--muted); text-align: center; } +.connector-pin-table .pin-function { white-space: normal; overflow-wrap: anywhere; text-overflow: clip; } + +/* Compact controls leave the existing workspace its full remaining height. */ +.app-header.toolbar { position: relative; display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; min-height: 3.5rem; padding: .4rem .75rem; background: var(--paper); color: var(--ink); z-index: 20; } +.app-header.toolbar .board-picker { display: block; min-width: 13rem; flex: 1 1 15rem; max-width: 19rem; } +.app-header.toolbar .board-combobox { width: 100%; } +.app-header.toolbar .board-combobox input { padding-block: .4rem; font-size: .78rem; } +.app-header.toolbar button, .header-menu > summary { padding: .4rem .5rem; font-size: .75rem; white-space: nowrap; } +.app-header.toolbar .header-controls { margin-left: auto; flex-direction: row; gap: .4rem; } +.app-header.toolbar .help-button { color: var(--accent); background: var(--wash); border-color: var(--line); } +.app-header.toolbar .language-picker { display: flex; color: var(--muted); font-size: .7rem; } +.app-header.toolbar .language-picker select { min-width: 0; width: 6.5rem; padding: .3rem; font-size: .7rem; color: var(--ink); background: #fff; border-color: var(--line); } +.app-header.toolbar .save-state { font-size: .72rem; color: var(--muted); } +.app-header.toolbar .save-state.dirty { color: var(--warning); } +.option-panel { align-items: center; gap: .35rem; min-height: 0; padding: 0; overflow: visible; } +.option-panel .revision-control { display: block; } +.option-panel select { min-width: 0; max-width: 8rem; padding: .35rem; font-size: .75rem; } +.runtime-control, .header-menu { position: relative; } +.runtime-toggle { display: flex; align-items: center; gap: .3rem; } +.runtime-toggle span { display: inline-block; max-width: 7rem; overflow: hidden; text-overflow: ellipsis; } +.header-menu > summary { list-style: none; cursor: pointer; border: 1px solid #aebbc5; border-radius: 6px; } +.header-menu > summary::-webkit-details-marker { display: none; } +.header-menu-panel, .runtime-panel { position: absolute; top: calc(100% + .35rem); left: 0; z-index: 30; display: grid; gap: .4rem; width: max-content; min-width: 12rem; max-width: min(24rem, calc(100vw - 1.5rem)); max-height: 65dvh; overflow: auto; padding: .65rem; border: 1px solid var(--line); border-radius: 8px; background: #fff; box-shadow: 0 8px 24px rgb(19 43 53 / 18%); } +.runtime-panel[hidden] { display: none; } +.header-menu-panel button, .header-menu-panel .file-button { width: 100%; text-align: left; } +.runtime-panel label { display: grid; gap: .25rem; } +.runtime-panel select { width: 100%; max-width: none; } +.view-tabs { gap: .25rem; padding: .2rem .75rem .3rem; flex-wrap: wrap; overflow: visible; } +.view-tabs button { padding: .2rem .5rem; font-size: .72rem; } +.board-info > summary { min-height: 1.8rem; padding: .2rem .65rem; font-size: .75rem; } +.onboarding { width: min(54rem, calc(100vw - 2rem)); max-height: 85dvh; overflow: auto; margin: auto; padding: 1rem; border: 1px solid var(--line); border-radius: 10px; } +.onboarding::backdrop { background: rgb(3 20 25 / 50%); } +.onboarding h1 { margin: 0 0 .4rem; font-size: 1.2rem; } +.repository-next { position: fixed; right: .75rem; bottom: .75rem; z-index: 40; width: min(25rem, calc(100vw - 1.5rem)); max-height: 50dvh; overflow: auto; margin: 0; } +.repository-next.is-open { margin: 0; padding: .65rem; border-width: 1px; } +.repository-next .motion-region-inner { display: block; } +.repository-next ol { display: grid; margin-top: .5rem; } +.repository-next [data-dismiss-export] { float: right; padding: 0 .35rem; margin-left: .5rem; } +.global-drop-hint { display: none; position: fixed; inset: 0; z-index: 100; align-items: center; justify-content: center; padding: 1rem; background: rgb(0 109 119 / 18%); border: 4px dashed var(--accent); color: var(--accent); font-size: 1.3rem; font-weight: 800; pointer-events: none; } +body.dragging .global-drop-hint { display: flex; } +@media (max-width: 760px) { + .app-header.toolbar .board-picker { flex: 1 1 14rem; max-width: none; } + .app-header.toolbar .header-controls { margin-left: auto; } + .runtime-toggle span { max-width: 5rem; } +} + +/* All page content remains mounted under one delegated editing root. */ +.workspace { display: grid; grid-template-columns: 13rem minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); gap: .6rem; } +.workspace.nav-collapsed { grid-template-columns: 2.8rem minmax(0, 1fr); } +.page-nav { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 8px; background: var(--paper); } +.nav-heading { display: flex; align-items: center; justify-content: space-between; gap: .35rem; padding: .4rem; flex: none; font-size: .75rem; } +.nav-heading button { padding: .25rem .4rem; } +#page-nav-items { flex: 1 1 auto; min-height: 0; overflow: auto; padding: .25rem; } +#page-nav-items[hidden] { display: none; } +.page-nav .nav-item, .nav-validation { display: flex; align-items: center; justify-content: space-between; gap: .4rem; width: 100%; padding: .5rem .45rem; background: transparent; color: var(--ink); border: 0; border-radius: 5px; text-align: left; font-size: .76rem; } +.nav-item span { min-width: 0; overflow-wrap: anywhere; } +.nav-item small { flex: none; color: var(--muted); } +.page-nav [aria-current="page"] { background: var(--accent-soft); color: var(--accent); font-weight: 800; } +.nav-connector-heading { display: flex; align-items: center; } +.nav-connector-heading > .nav-item { flex: 1; } +.nav-connector-heading > button.secondary { flex: none; padding: .25rem; } +.nav-children { margin-left: .55rem; padding-left: .25rem; border-left: 2px solid var(--line); } +.nav-children[hidden] { display: none; } +.nav-children .nav-item { font-family: ui-monospace, monospace; font-size: .72rem; } +.nav-validation { flex: none; border-top: 1px solid var(--line); border-radius: 0; } +.nav-collapsed .nav-heading strong, .nav-collapsed .nav-validation > span { display: none; } +.nav-collapsed .nav-heading, .nav-collapsed .nav-validation { justify-content: center; } +.page-area { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; } +.page-heading { display: flex; flex: none; align-items: center; gap: .5rem; min-height: 2rem; margin-bottom: .35rem; } +.page-heading h2 { margin: 0; font-size: .9rem; overflow-wrap: anywhere; } +.page-heading button { padding: .25rem .45rem; font-size: .72rem; } +.page-host { flex: 1 1 auto; min-width: 0; min-height: 0; overflow: auto; } +.configuration-pages, .related-pages, #related-list { display: contents; } +[data-page][hidden] { display: none !important; } +.page-host > .gpio-card { height: 100%; min-height: 0; } +.page-host > .board-info > strong { display: block; padding: .6rem; font-size: .8rem; } +.page-host > .connector-card { display: flex; flex-direction: column; height: 100%; min-height: 0; overflow: hidden; } +.connector-heading { flex: none; display: flex; align-items: center; flex-wrap: wrap; gap: .45rem; } +.page-host > .connector-card > .sidebar-section-body { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; overflow: hidden; } +.connector-card .section-description { flex: none; } +.connector-card .connector-pinout { flex: 1 1 auto; min-height: 0; overflow: auto; } +.page-host .revision-table-wrap, .page-host .owner-table-wrap { max-height: none; } +@media (max-width: 980px) { + .workspace, .workspace.nav-collapsed { display: flex; flex-direction: column; overflow: hidden; } + .page-nav { flex: none; } + #page-nav-items { max-height: 30dvh; } + .page-area { flex: 1 1 auto; min-height: 0; } + .workspace.nav-collapsed .nav-heading strong, .workspace.nav-collapsed .nav-validation > span { display: inline; } + .workspace.nav-collapsed .nav-heading, .workspace.nav-collapsed .nav-validation { justify-content: space-between; } + .page-host > .gpio-card { flex: none; } +} + +/* The page heading supplies the title; card headings retain only tools and references. */ +.page-host .config-group > .section-heading { display: none; } +.page-host .gpio-card > .section-heading { padding-block: .35rem; } +.page-host .connector-heading { padding-block: .35rem; } +.page-host .connector-heading:not(:has(a)) { display: none; } + +.connector-set { display: flex; align-items: flex-start; gap: 1rem; min-width: min-content; } +.connector-socket { flex: 1 0 18rem; min-width: 0; } +.connector-set:not(.grouped) .connector-socket { flex-basis: 100%; } +.connector-socket h3 { margin: .3rem 0; display: flex; flex-wrap: wrap; gap: .6rem; align-items: center; } +.first-pin-mark { border-left: .3rem solid var(--accent); padding-left: .4rem; font-size: .75rem; } + + +/* Paired sockets share the heading, evidence and pinout rows. */ +.connector-set.grouped { + display: grid; + grid-template-rows: auto auto auto; + grid-auto-flow: column; + grid-auto-columns: minmax(18rem, 1fr); + align-items: start; +} +.connector-set.grouped > .connector-socket { + display: grid; + grid-row: 1 / 4; + grid-template-rows: subgrid; + align-self: stretch; +} +.connector-set.grouped > .connector-socket > table { align-self: start; } +@media (max-width: 980px) { + .connector-set.grouped { display: flex; flex-direction: column; min-width: 0; } + .connector-set.grouped > .connector-socket { display: block; flex: none; width: 100%; } +} + +.connector-function-roles { white-space: normal; } +#connector-role-color-legend { flex: none; } + +/* Compact columns take their unwrapped content width; function columns share the rest. */ +.connector-pin-table col.pin-col-terminal, .connector-pin-table col.pin-col-endpoint { width: 1%; } +.connector-pin-table col.pin-col-function { width: 50%; } +.connector-pin-table.single-column col.pin-col-function { width: 100%; } +.connector-pin-table th { white-space: nowrap; } + +.connector-pin-table .pin-left .connector-function-roles { justify-content: flex-end; text-align: right; } + +.connector-pin-table .pin-endpoint { text-align: center; } + +.connector-pin-table col.pin-col-wire, .connector-pin-table .pin-wire { width: 1%; white-space: nowrap; text-align: center; } +.connector-pin-table .pin-wire .connector-color { margin-right: 0; } + +.page-heading #connector-undo { margin-left: auto; flex: none; } diff --git a/extras/board_spec/editor/editor.js b/extras/board_spec/editor/editor.js index a4668f0f..377f0d6b 100644 --- a/extras/board_spec/editor/editor.js +++ b/extras/board_spec/editor/editor.js @@ -6,8 +6,8 @@ import { formatBoard } from "../lib/format.js"; import { pinChangeLayer } from "./provenance.js"; import { BoardOperationError, addBus, addChoice, addConnector, addDevice, addRole, boardRoleOptions, createBoard, duplicateBoard, removeBus, removeChoice, removeConnector, removeDevice, setBoardField, setChoiceDefaults, setDeviceSpec, setSpec } from "./board_ops.js"; import { filterBoardOptions, gpioMatchesFilter, matchesSearchText } from "./filters.js"; -import { assignConnectorLanes, connectorRolesByLane, objectRoleOwner, roleColor, roleOwner } from "./role_colors.js"; -import { escapeHtml, gpioTargetButton, ownerSignalsHtml } from "./html.js"; +import { connectorEndpointRoles, assignConnectorLanes, connectorRolesByLane, objectRoleOwner, roleColor, roleOwner } from "./role_colors.js"; +import { connectorTableColumns, firstConnectorPosition, connectorDisplaySets, editorPageKey, editorErrorRoute, resolveEditorRoute, escapeHtml, externalLinkHtml, gpioConnectorTargets, gpioTargetButton, ownerSignalsHtml } from "./html.js"; const schema = readEmbedded("board-spec-schema"); const chips = readEmbedded("board-spec-chips"); @@ -22,9 +22,8 @@ const draftIndexKey = "m5-board-spec-drafts:v1"; const gpioFilterStorageKey = "m5-board-spec-gpio-filter:v1"; const roleColorStorageKey = "m5-board-spec-role-colors:v1"; const onboardingStorageKey = "m5-board-spec-onboarding:v1"; -const layoutStorageKey = "m5-board-spec-layout:v1"; +const layoutStorageKey = "m5-board-spec-layout:v3"; const motionDuration = 200; -const sidebarWidthLimits = { min: 360, max: 620, default: 460 }; let comboboxSerial = 0; const chipForBoard = (board) => effectiveChip(chips[board.chip], board); @@ -54,13 +53,16 @@ const elements = { dropZone: document.querySelector("#drop-zone"), repositoryNext: document.querySelector("#repository-next"), optionPanel: document.querySelector("#option-panel"), - viewTabs: document.querySelector("#view-tabs"), + pageNav: document.querySelector("#page-nav"), + navItems: document.querySelector("#page-nav-items"), + navToggle: document.querySelector("#nav-toggle"), + pageBack: document.querySelector("#page-back"), + pageTitle: document.querySelector("#page-title"), + pageHost: document.querySelector("#page-host"), configurationView: document.querySelector("#configuration-view"), boardInfo: document.querySelector("#board-info"), boardInfoSummary: document.querySelector("#board-info-summary"), boardInfoBody: document.querySelector("#board-info-body"), - sideResizer: document.querySelector("#side-resizer"), - sideColumn: document.querySelector(".side-column"), revisionsView: document.querySelector("#revisions-view"), ownersView: document.querySelector("#owners-view"), configurationList: document.querySelector("#configuration-list"), @@ -75,13 +77,11 @@ const elements = { gpioFilter: document.querySelector("#gpio-filter"), gpioAssignedOnly: document.querySelector("#gpio-assigned-only"), roleColorLegend: document.querySelector("#role-color-legend"), + connectorRoleColorLegend: document.querySelector("#connector-role-color-legend"), validationCount: document.querySelector("#validation-count"), validationSection: document.querySelector("#validation-section"), validationList: document.querySelector("#validation-list"), - connectorTabs: document.querySelector("#connector-tabs"), - connectorViewToggle: document.querySelector("#connector-view-toggle"), - connectorDiagram: document.querySelector("#connector-diagram"), - connectorEditor: document.querySelector("#connector-editor"), + connectorPinout: document.querySelector("#connector-pinout"), connectorUndo: document.querySelector("#connector-undo"), connectorSection: document.querySelector("#connector-section"), connectorSectionTitle: document.querySelector("#connector-section-title"), @@ -97,12 +97,15 @@ const state = { selectedOptions: new Set(), runtimeSelections: new Map(), compositionEntries: new Map(), - activeView: "configuration", + page: null, + previousPage: null, + routeBoardId: null, + pageScroll: new Map(), + navMarkup: null, ownerKey: null, compareRevisions: ["", ""], connectorId: null, connectorPosition: null, - connectorView: "table", connectorEditorTab: "gpio", language: initialLanguage(), undo: null, @@ -111,7 +114,9 @@ const state = { ...initialGPIOFilterState(), roleColors: initialRoleColors(), roleColorsOpen: false, + connectorRoleColorsOpen: false, onboardingOpen: initialOnboardingOpen(), + runtimePanelOpen: false, layout: initialLayoutState(), lastExported: null, }; @@ -291,16 +296,16 @@ function initialOnboardingOpen() { } function initialLayoutState() { - try { - const stored = JSON.parse(localStorage.getItem(layoutStorageKey) ?? "{}"); - const width = Number(stored.sidebarWidth); - return { - sidebarWidth: Number.isFinite(width) ? Math.max(sidebarWidthLimits.min, Math.min(sidebarWidthLimits.max, width)) : sidebarWidthLimits.default, - sections: stored.sections && typeof stored.sections === "object" ? stored.sections : {}, - }; - } catch { - return { sidebarWidth: sidebarWidthLimits.default, sections: {} }; - } + let stored = {}; + try { stored = JSON.parse(localStorage.getItem(layoutStorageKey) ?? "{}") ?? {}; } catch { /* storage is optional */ } + // Legacy connectorView preferences are deliberately ignored; pinouts use tables only. + return { + page: stored.page && typeof stored.page === "object" ? stored.page : { kind: "gpio" }, + boardId: typeof stored.boardId === "string" ? stored.boardId : null, + navCollapsed: typeof stored.navCollapsed === "boolean" ? stored.navCollapsed : window.matchMedia("(max-width: 980px)").matches, + navGroups: { connectors: stored.navGroups?.connectors !== false }, + sections: stored.sections && typeof stored.sections === "object" ? stored.sections : {}, + }; } function saveLayoutState() { @@ -311,10 +316,107 @@ function disclosureOpen(id, fallback = false) { return Object.hasOwn(state.layout.sections, id) ? Boolean(state.layout.sections[id]) : fallback; } -function setSidebarWidth(width) { - state.layout.sidebarWidth = Math.max(sidebarWidthLimits.min, Math.min(sidebarWidthLimits.max, Math.round(width))); - elements.configurationView.style.setProperty("--side-width", `${state.layout.sidebarWidth}px`); - elements.sideResizer.setAttribute("aria-valuenow", String(state.layout.sidebarWidth)); +function routeContext(board) { + return { chip: chipForBoard(board), connectorTypes: Object.fromEntries(Object.values(board.connectors ?? {}).map((connector) => [connector.type, resolveConnectorType(connectorTypes, connector.type)])), ownerKeys: ownerDefinitions(currentBase()).map((owner) => owner.key) }; +} + +function pageLabel(route) { + const keys = { board: "layout.boardInfo", gpio: "gpio.heading", connectors: "configuration.connectors", connector: "nav.connector", buses: "configuration.buses", devices: "configuration.devices", accessories: "configuration.accessories", revisions: "views.revisions", owners: "views.owners", validation: "validation.heading" }; + return `${t(keys[route.kind] ?? "gpio.heading")}${route.group ? ` · ${t("connector.groupLabel", { group: route.group })}` : route.id ? ` · ${route.id}` : ""}${route.position ? ` · ${route.position}` : ""}${route.gpio ? ` ${route.gpio}` : ""}`; +} + +function currentPageScroll() { + return { top: elements.pageHost.scrollTop, left: elements.pageHost.scrollLeft, gpioTop: elements.tableBody.closest(".table-wrap").scrollTop, connectorTop: elements.connectorPinout.scrollTop, connectorLeft: elements.connectorPinout.scrollLeft }; +} + +function applyPageVisibility() { + elements.configurationView.querySelectorAll("[data-page]").forEach((page) => { page.hidden = page.dataset.page !== state.page.kind; }); + elements.configurationView.classList.toggle("nav-collapsed", state.layout.navCollapsed); + elements.navItems.hidden = state.layout.navCollapsed; + elements.navToggle.setAttribute("aria-expanded", String(!state.layout.navCollapsed)); + elements.navToggle.textContent = state.layout.navCollapsed ? "»" : "«"; + elements.navToggle.title = t(state.layout.navCollapsed ? "nav.expand" : "nav.collapse"); + elements.navToggle.setAttribute("aria-label", elements.navToggle.title); + elements.pageTitle.textContent = pageLabel(state.page); + elements.pageTitle.title = state.page.kind === "gpio" ? t("gpio.description") : state.page.kind === "connector" ? t("connector.description") : ""; + renderConnectorUndo(); + elements.pageBack.hidden = !state.previousPage; + if (state.previousPage) elements.pageBack.textContent = t("nav.back", { page: pageLabel(state.previousPage.route) }); + elements.pageNav.querySelectorAll("[data-route-kind]").forEach((button) => { + const active = button.dataset.routeKind === "connector-group" ? state.page.kind === "connector" && button.dataset.routeId === state.page.group : button.dataset.routeKind === state.page.kind && (!button.dataset.routeId || (!state.page.group && button.dataset.routeId === state.page.id)); + if (active) button.setAttribute("aria-current", "page"); else button.removeAttribute("aria-current"); + }); +} + +function navigateTo(target, options = {}) { + const board = shownBoard(); + const boardId = state.currentId; + const changedBoard = state.routeBoardId !== null && state.routeBoardId !== state.currentId; + const reset = options.reset || changedBoard; + const requested = reset && ["connector", "connector-group"].includes(target?.kind) ? { kind: "connectors" } : target; + const route = resolveEditorRoute(requested, board, routeContext(board)); + const different = JSON.stringify(route) !== JSON.stringify(state.page); + const pageChanged = editorPageKey(route) !== (state.page ? editorPageKey(state.page) : null); + if (reset) { state.previousPage = null; state.pageScroll.clear(); } + else if (options.remember !== false && pageChanged && state.page) { + const previous = { ...state.page }; + if (previous.kind === "gpio" && currentEntry().expandedGPIO != null) previous.gpio = String(currentEntry().expandedGPIO); + state.previousPage = { route: previous, scroll: currentPageScroll(), boardId: state.currentId }; + } + if (different && state.page && !reset) state.pageScroll.set(editorPageKey(state.page), currentPageScroll()); + const oldConnector = state.connectorId; + const oldPosition = state.connectorPosition; + const ids = Object.keys(board.connectors ?? {}); + state.page = route; + state.routeBoardId = state.currentId; + state.connectorId = route.kind === "connector" ? route.id : (!reset && ids.includes(state.connectorId) ? state.connectorId : ids[0] ?? null); + state.connectorPosition = route.kind === "connector" ? route.position ?? null : (state.connectorId === oldConnector ? state.connectorPosition : null); + if (route.kind === "owners" && route.id) state.ownerKey = route.id; + state.layout.page = route; + state.layout.boardId = state.currentId; + saveLayoutState(); + if (options.render === false) return; + if (route.gpio != null && (currentEntry().expandedGPIO !== route.gpio || !document.getElementById(`gpio-${route.gpio}`))) { + currentEntry().expandedGPIO = route.gpio; + writeDraft(currentEntry()); + renderTable(board, validationFor(currentBase())); + } + if (route.kind === "connector" && (oldConnector !== state.connectorId || oldPosition !== state.connectorPosition)) renderConnectors(board); + if (route.kind === "owners" && route.id) renderOwnerEditor(board); + renderNavigation(board); + applyPageVisibility(); + requestAnimationFrame(() => { + if (state.currentId !== boardId || JSON.stringify(state.page) !== JSON.stringify(route)) return; + if (route.kind === "gpio") sizeConnectorLanes(assignConnectorLanes(board.pins).lanes.length); + const saved = options.restoreScroll ?? state.pageScroll.get(editorPageKey(route)); + if (saved) { + elements.pageHost.scrollTop = saved.top; + elements.pageHost.scrollLeft = saved.left; + if (route.kind === "gpio") elements.tableBody.closest(".table-wrap").scrollTop = saved.gpioTop; + if (route.kind === "connector") { elements.connectorPinout.scrollTop = saved.connectorTop ?? 0; elements.connectorPinout.scrollLeft = saved.connectorLeft ?? 0; } + } else if (different) elements.pageHost.scrollTop = 0; + if (options.restoreScroll) return; + let node; + if (route.gpio != null) node = document.getElementById(`gpio-${route.gpio}`); + else if (route.kind === "connector" && route.position) node = [...elements.connectorPinout.querySelectorAll("[data-connector-position][tabindex]")].find((item) => item.dataset.connectorPosition === route.position && item.closest("[data-connector-id]")?.dataset.connectorId === route.id); + else if (route.id && ["buses", "devices"].includes(route.kind)) { + node = [...elements.configurationList.querySelectorAll("[data-config-object]")].find((item) => item.dataset.configObject === `${route.kind}:${route.id}`); + if (node?.tagName === "DETAILS") setDisclosureOpen(node, true); + } + if (node) { node.scrollIntoView({ behavior: prefersReducedMotion() ? "auto" : "smooth", block: "center" }); node.focus?.({ preventScroll: true }); } + options.after?.(); + }); +} + +function renderNavigation(board) { + const chip = chipForBoard(board); + const available = Array.from({ length: chip.gpio_count }, (_, gpio) => gpio).filter((gpio) => !chip.absent.includes(gpio)); + const assigned = available.filter((gpio) => board.pins?.[gpio]?.roles?.length).length; + const navButton = (kind, label, count = "", title = "nav.objectCount", id = null) => ``; + const children = connectorDisplaySets(board).map((set) => navButton(set.group ? "connector-group" : "connector", set.group ?? set.ids[0], set.ids.reduce((count, id) => count + (resolveConnectorType(connectorTypes, board.connectors[id].type)?.positions?.length ?? 0), 0), "nav.terminalCount", set.group ?? set.ids[0])).join(""); + const markup = navButton("board", t("layout.boardInfo"), "", "layout.boardInfo") + navButton("gpio", t("gpio.heading"), `${assigned}/${available.length}`, "nav.gpioCount") + `` + navButton("buses", t("configuration.buses"), Object.keys(board.buses ?? {}).length) + navButton("devices", t("configuration.devices"), Object.keys(board.devices ?? {}).length) + navButton("accessories", t("configuration.accessories")) + navButton("revisions", t("views.revisions"), currentBase().revisions?.length ?? 0) + navButton("owners", t("views.owners")); + if (state.navMarkup !== markup) { elements.navItems.innerHTML = markup; state.navMarkup = markup; } + elements.validationCount.title = t("nav.validationCount"); } function dismissOnboarding() { @@ -341,7 +443,7 @@ function saveGPIOFilterState() { function initialRoleColors() { try { const stored = JSON.parse(localStorage.getItem(roleColorStorageKey) ?? "{}"); - return Object.fromEntries(Object.entries(stored).filter(([key, value]) => /^(?:bus|dev|conn|pwr):[a-z][a-z0-9_]*$/.test(key) && /^#[0-9a-f]{6}$/i.test(value))); + return Object.fromEntries(Object.entries(stored).filter(([key, value]) => /^(?:(?:bus|dev|conn):[a-z][a-z0-9_]*|pwr:[a-z0-9_]+)$/.test(key) && /^#[0-9a-f]{6}$/i.test(value))); } catch { return {}; } @@ -369,10 +471,9 @@ function applyStaticTranslations() { } function renderOnboarding() { - prepareMotionRegion(elements.onboarding); - elements.onboarding.classList.toggle("is-open", state.onboardingOpen); - elements.onboarding.setAttribute("aria-hidden", String(!state.onboardingOpen)); - elements.onboarding.inert = !state.onboardingOpen; + elements.onboarding.hidden = false; + if (state.onboardingOpen && !elements.onboarding.open) elements.onboarding.showModal(); + else if (!state.onboardingOpen && elements.onboarding.open) elements.onboarding.close(); elements.onboardingHelp.setAttribute("aria-expanded", String(state.onboardingOpen)); } @@ -388,7 +489,7 @@ function prepareMotionRegion(element) { } function prepareDisclosureTransitions(root = document) { - for (const details of root.querySelectorAll("details:not(.motion-details)")) { + for (const details of root.querySelectorAll("details:not(.motion-details, .header-menu)")) { const summary = details.querySelector(":scope > summary"); if (!summary) continue; const collapse = document.createElement("div"); @@ -575,7 +676,7 @@ function addEmbeddedBoards() { const board = state.documents.get(id).base; state.runtimeSelections.set(id, Object.fromEntries(choiceSlots(board).filter(([, device]) => device.selected_by === "runtime").map(([slot, device]) => [slot, device.default]))); } - state.currentId = state.documents.has("m5stack_core2") ? "m5stack_core2" : [...state.documents.keys()][0]; + state.currentId = state.documents.has(state.layout.boardId) ? state.layout.boardId : state.documents.has("m5stack_core2") ? "m5stack_core2" : [...state.documents.keys()][0]; } function initializeDocument(board, { original = board, dirty = true, source = "editor" } = {}) { @@ -585,8 +686,7 @@ function initializeDocument(board, { original = board, dirty = true, source = "e state.currentId = board.id; state.lastExported = null; state.selectedOptions.clear(); - state.connectorId = null; - state.connectorPosition = null; + navigateTo({ kind: state.page?.kind ?? "gpio" }, { reset: true, render: false }); state.undo = null; if (dirty) writeDraft(state.documents.get(board.id)); } @@ -641,7 +741,7 @@ function closeCurrentDocument() { state.currentId = state.documents.keys().next().value; state.lastExported = null; state.selectedOptions.clear(); - state.connectorId = null; + navigateTo({ kind: state.page?.kind ?? "gpio" }, { reset: true, render: false }); renderAll(); } @@ -696,7 +796,8 @@ function applyControlLock(control, reason = "") { } else { control.removeAttribute("aria-disabled"); delete control.dataset.readonlyReason; - control.removeAttribute("title"); + if (control.dataset.i18nTitle) control.title = t(control.dataset.i18nTitle); + else control.removeAttribute("title"); } } @@ -836,6 +937,13 @@ function roleOwnersForBoard(board) { } }; addRoles(board.pins); + for (const id of Object.keys(board.connectors ?? {})) { + const display = resolveConnectorPositions(board, id, connectorTypes); + for (const endpoint of Object.values(display.positions)) for (const role of connectorEndpointRoles(board, endpoint, id)) { + const owner = roleOwner(role); + if (owner) owners.set(owner.key, owner); + } + } for (const device of Object.values(board.devices ?? {})) { addRoles(device.pins); for (const choice of Object.values(device.choices ?? {})) addRoles(choice.pins); @@ -851,7 +959,9 @@ function renderRoleColorLegend(board) { return ``; }).join(""); const prefixes = `
    ${escapeHtml(t("rolePrefixes.heading"))}bus: ${escapeHtml(t("rolePrefixes.bus").replace(/^bus:\s*/, ""))}dev: ${escapeHtml(t("rolePrefixes.dev").replace(/^dev:\s*/, ""))}conn: ${escapeHtml(t("rolePrefixes.conn").replace(/^conn:\s*/, ""))}| ${escapeHtml(t("rolePrefixes.source").replace(/^\|\s*/, ""))}
    `; - elements.roleColorLegend.innerHTML = `${prefixes}${escapeHtml(t("roleColors.heading"))}
    ${items || `${escapeHtml(t("roleColors.empty"))}`}
    `; + const detailsMarkup = (open) => `${escapeHtml(t("roleColors.heading"))}
    ${items || `${escapeHtml(t("roleColors.empty"))}`}
    `; + elements.roleColorLegend.innerHTML = prefixes + detailsMarkup(state.roleColorsOpen); + elements.connectorRoleColorLegend.innerHTML = detailsMarkup(state.connectorRoleColorsOpen); } function verificationSummary(pin) { @@ -925,8 +1035,7 @@ function renderBoardSelect() { state.currentId = id; state.lastExported = null; state.selectedOptions.clear(); - state.connectorId = null; - state.connectorPosition = null; + navigateTo({ kind: state.page?.kind ?? "gpio" }, { reset: true, render: false }); renderAll(); }, "", { preserveInput: true, selectOnFocus: true, showAllOnFocus: true, filterOptions: filterBoardOptions }); } else boardCombobox.setOptions(options); @@ -937,17 +1046,22 @@ function renderBoardSelect() { function renderOptions() { const revisions = currentBase().revisions ?? []; const selectedRevision = [...state.selectedOptions][0] ?? ""; - const revisionHtml = ``; + const revisionHtml = ``; const runtime = state.runtimeSelections.get(state.currentId) ?? {}; const runtimeHtml = choiceSlots(currentBase()).filter(([, device]) => device.selected_by === "runtime").map(([slot, device]) => ( `` )).join(""); - elements.optionPanel.innerHTML = `
    ${escapeHtml(t("options.legend"))} ${helpMark("help.displayConfiguration")}${revisionHtml}${runtimeHtml}
    `; + const runtimeSummary = Object.values(runtime).filter(Boolean).join(" / ") || t("options.none"); + elements.optionPanel.innerHTML = `${revisionHtml}${runtimeHtml ? `
    ${runtimeHtml}
    ` : ""}`; + elements.optionPanel.querySelector(".revision-control").after(elements.editBase); + elements.editBase.hidden = !isResolvedMode(); const revision = revisions.find((item) => item.id === selectedRevision); const choices = Object.entries({ ...revisionSelection(currentBase(), revision), ...runtime }).map(([slot, choice]) => `${slot}: ${choice}`).join(", ") || t("options.none"); elements.modeNote.textContent = isResolvedMode() ? t("mode.resolved", { board: currentBase().name, revision: revision?.name ?? t("options.none"), choices }) : t("mode.base"); + elements.optionPanel.title = elements.modeNote.textContent; + elements.optionPanel.querySelector("[data-display-revision]").title = elements.modeNote.textContent; } function renderTable(board, errors) { @@ -978,7 +1092,7 @@ function renderTable(board, errors) { if (chip.absent.includes(gpio)) continue; const key = String(gpio); const pin = board.pins?.[key] ?? { roles: [] }; - if (!gpioMatchesFilter(key, pin, state.gpioFilter, state.gpioAssignedOnly)) continue; + if (state.page?.gpio !== key && !gpioMatchesFilter(key, pin, state.gpioFilter, state.gpioAssignedOnly)) continue; const conditionalReserved = (chip.reserved_conditional?.[board.spec?.storage?.psram_mode] ?? []).includes(gpio); const readonlyReason = readonlyReasons.get(key) ?? (chip.reserved.includes(gpio) ? t("reason.chipReserved") : conditionalReserved ? t("reason.chipReservedConditional") : ""); const readonly = readonlyAttributes(readonlyReason); @@ -1059,6 +1173,7 @@ function renderTable(board, errors) { function sizeConnectorLanes(laneCount) { const table = elements.tableHead.closest("table"); + if (!table.getClientRects().length || !table.getBoundingClientRect().width) return; if (!laneCount) { table.style.removeProperty("--connector-lane-columns"); table.style.removeProperty("--connector-column-width"); @@ -1072,16 +1187,14 @@ function sizeConnectorLanes(laneCount) { function renderValidation(board, errors) { const errorCount = errors.filter((item) => item.severity !== "warning").length; - elements.validationCount.textContent = t("validation.count", { count: errors.length }); + elements.validationCount.textContent = errors.length ? t("validation.count", { count: errors.length }) : "✓"; elements.validationCount.classList.toggle("has-errors", errorCount > 0); - elements.validationSection.open = disclosureOpen("validation", errorCount > 0); if (!errors.length) { elements.validationList.innerHTML = `
    ${escapeHtml(t("validation.ok"))}
    `; return; } elements.validationList.innerHTML = `
      ${errors.map((item, index) => { - const gpio = gpioForError(item, board); - const reason = gpio === null ? t("unavailable.noGPIO") : ""; + const reason = ""; const action = validationAction(item); return `
    • `; }).join("")}
    `; @@ -1109,12 +1222,17 @@ function relatedObjectsForGPIO(board, gpio) { const links = []; for (const [field, prefix] of groups) { for (const [id, object] of Object.entries(board[field] ?? {})) { + if (field === "connectors") continue; if (objectAssignments(board, prefix, id, object).includes(gpio)) { const owner = objectRoleOwner(prefix, id); links.push(``); } } } + for (const target of gpioConnectorTargets(board, gpio)) { + const owner = objectRoleOwner("conn", target.connector); + links.push(``); + } return links.join(""); } @@ -1156,24 +1274,6 @@ function endpointGPIOs(endpoint) { return values.map((value) => /^gpio:(\d+)$/.exec(value ?? "")?.[1]).filter((value) => value !== undefined); } -function connectorFunctionLabel(board, endpoint) { - const signalNames = { sclk: "SCK", data_in: "DATA IN", data_out: "DATA OUT" }; - const labels = []; - for (const gpio of endpointGPIOs(endpoint)) { - for (const role of board.pins?.[gpio]?.roles ?? []) { - const bus = /^bus:([a-z][a-z0-9_]*)\.([a-z0-9_]+)$/.exec(role); - if (bus) { - const signal = signalNames[bus[2]] ?? bus[2].replaceAll("_", " ").toUpperCase(); - labels.push(/^main_/.test(bus[1]) ? signal : `${signal} (${bus[1]})`); - continue; - } - const device = /^dev:([a-z][a-z0-9_]*)\.([a-z0-9_]+)$/.exec(role); - if (device) labels.push(`${device[1].replaceAll("_", " ").toUpperCase()} ${device[2].replaceAll("_", " ").toUpperCase()}`); - } - } - return [...new Set(labels)].join(" / "); -} - function connectorTableEndpointLabel(endpoint, position) { const label = endpointLabel(endpoint); const standardName = position?.name?.toUpperCase(); @@ -1181,78 +1281,51 @@ function connectorTableEndpointLabel(endpoint, position) { return label; } -function connectorPositionClasses(source, endpoint, positionId) { +function connectorPositionClasses(source, endpoint, positionId, connectorId) { const sourceClass = source.fixed ? "fixed" : source.explicit || source.gpios?.length ? "explicit" : source.default ? "default" : "missing"; const highlighted = currentEntry().expandedGPIO && endpointGPIOs(endpoint).includes(currentEntry().expandedGPIO) ? " gpio-highlight" : ""; - const selected = state.connectorPosition === positionId ? " selected" : ""; + const selected = state.connectorId === connectorId && state.connectorPosition === positionId ? " selected" : ""; return `${sourceClass}${highlighted}${selected}`; } -function ensureConnectorSelection(board) { - const ids = Object.keys(board.connectors ?? {}); - if (!ids.includes(state.connectorId)) { - state.connectorId = ids[0] ?? null; - state.connectorPosition = null; - } +function boardReferenceLinks(board) { + return ["product", "schematic"].map((key) => externalLinkHtml(board.spec?.links?.[key], t(`field.${key}Link`))).filter(Boolean).join(" · "); } -function connectorKeyMarkup(layout, width, height) { - const length = 28; - const depth = 10; - if (layout.key === "top") return ``; - if (layout.key === "bottom") return ``; - if (layout.key === "left") return ``; - if (layout.key === "right") return ``; - return ""; +function connectorMeta(board, connector, type) { + const provenance = (values) => Object.entries(values ?? {}).map(([key, value]) => `${escapeHtml(key)}: ${escapeHtml(value ?? t("connector.unverified"))}`).join(" · ") || escapeHtml(t("connector.unverified")); + const layout = type.verified?.layout ?? t("connector.unverified"); + const pitch = type.layout.pitch_mm == null ? t("connector.unverified") : `${type.layout.pitch_mm} mm`; + return `
    ${escapeHtml(type.name)} · ${escapeHtml(connector.standard ?? "")}
    ${escapeHtml(t("connector.typeView"))}: ${escapeHtml(type.layout.view)} · ${escapeHtml(t("connector.layoutStatus"))}: ${escapeHtml(layout)} · ${escapeHtml(t("connector.pitch"))}: ${escapeHtml(pitch)}
    ${escapeHtml(t("column.verified"))}: ${provenance(connector.verified)}
    ${connector.note ? `

    ${escapeHtml(connector.note)}

    ` : ""}${type.note ? `

    ${escapeHtml(t("connector.typeNote"))}: ${escapeHtml(type.note)}

    ` : ""}
    `; } -function renderConnectorDiagram(board, connectorId) { - const connector = board.connectors?.[connectorId]; - const type = resolveConnectorType(connectorTypes, connector?.type); - if (!connector || !type) return `
    ${escapeHtml(t("connector.noType"))}
    `; - const display = resolveConnectorPositions(board, connectorId, connectorTypes); - const source = resolveConnectorPositions(currentBase(), connectorId, connectorTypes); - const cellWidth = 92; - const cellHeight = 62; - const width = type.layout.cols * cellWidth + 24; - const height = type.layout.rows * cellHeight + 24; - const positions = type.positions.map((position) => { - const column = type.layout.mirror ? type.layout.cols - 1 - position.col : position.col; - const x = 12 + column * cellWidth + cellWidth / 2; - const y = 12 + position.row * cellHeight + cellHeight / 2; - const endpoint = display.positions[position.id]; - const sources = source.sources[position.id] ?? {}; - const classes = connectorPositionClasses(sources, endpoint, position.id); - const label = position.label ?? (position.name ? position.name.toUpperCase() : ""); - const fill = endpointGPIOs(endpoint).length - ? ` style="${roleColorInlineStyle(objectRoleOwner("conn", connectorId), true)}"` - : position.color ? ` style="--position-color:${escapeHtml(position.color)}"` : ""; - return ` - ${escapeHtml(`${position.id} ${label} ${endpointLabel(endpoint)}`)} - - ${escapeHtml(position.id)} - ${escapeHtml(label)} - ${escapeHtml(endpointLabel(endpoint))} - `; - }).join(""); - return `
    ${escapeHtml(type.name)}${escapeHtml(type.layout.view)}
    ${connectorKeyMarkup(type.layout, width, height)}${positions}`; -} - -function renderConnectorTableCells(board, display, source, position, side = "single") { - if (!position) return ''; +function renderConnectorTableCells(board, display, source, position, connectorId, side = "single") { + const type = resolveConnectorType(connectorTypes, board.connectors[connectorId].type); + const wire = type.positions.some((item) => item.color); + if (!position) return ``; const endpoint = display.positions[position.id]; - const classes = connectorPositionClasses(source.sources[position.id] ?? {}, endpoint, position.id); + const classes = connectorPositionClasses(source.sources[position.id] ?? {}, endpoint, position.id, connectorId); const data = `data-connector-position="${escapeHtml(position.id)}"`; const endpointText = escapeHtml(connectorTableEndpointLabel(endpoint, position)); - const functionText = escapeHtml(connectorFunctionLabel(board, endpoint)); - const positionText = escapeHtml(position.id); - const connectorOwner = objectRoleOwner("conn", state.connectorId); - const color = endpointGPIOs(endpoint).length - ? `` - : position.color ? `` : ""; - if (side === "left") return `${endpointText}${functionText}${color}${positionText}`; - if (side === "right") return `${color}${positionText}${functionText}${endpointText}`; - return `${color}${positionText}${functionText}${endpointText}`; + const endpoints = Array.isArray(endpoint) ? endpoint : [endpoint]; + const standardName = position?.name?.toUpperCase(); + const standardSuffix = !endpointGPIOs(endpoint).length && standardName && endpointLabel(endpoint) !== standardName && endpointLabel(endpoint) !== "—" ? ` (${escapeHtml(standardName)})` : ""; + const endpointHtml = endpoints.some((value) => /^pwr:/.test(value ?? "")) ? endpoints.map((value) => /^pwr:/.test(value ?? "") ? roleChip(board, value, true) : escapeHtml(endpointLabel(value))).join(" / ") + standardSuffix : endpointText; + const roles = connectorEndpointRoles(board, endpoint, connectorId).filter((role) => roleOwner(role)?.kind !== "pwr"); + const functionText = escapeHtml(roles.map((role) => roleDisplay(board, role).plain).join(" / ")); + const functionHtml = `
    ${roles.map((role) => roleChip(board, role, true)).join("")}
    `; + const positionText = `${position.id === firstConnectorPosition(type) ? "▸ " : ""}${escapeHtml(position.id)}`; + + const sideClass = side === "single" ? "" : ` pin-${side}`; + const cell = (kind, content, title = "", button = false) => `${content}`; + const cells = { + terminal: cell("number", positionText, "", true), + endpoint: cell("endpoint", endpointHtml, endpointText), + function: cell("function", functionHtml, functionText), + wire: cell("wire", position.color ? `` : "", escapeHtml(position.color ?? "")), + }; + const columns = side === "left" ? ["function", "endpoint", ...(wire ? ["wire"] : []), "terminal"] : ["terminal", ...(wire ? ["wire"] : []), "endpoint", "function"]; + return columns.map((key) => cells[key]).join(""); } function renderConnectorTable(board, connectorId) { @@ -1261,7 +1334,8 @@ function renderConnectorTable(board, connectorId) { if (!connector || !type) return `
    ${escapeHtml(t("connector.noType"))}
    `; const display = resolveConnectorPositions(board, connectorId, connectorTypes); const source = resolveConnectorPositions(currentBase(), connectorId, connectorTypes); - const editorRow = (positions, colspan) => positions.some((position) => position?.id === state.connectorPosition) + const columns = connectorTableColumns(type); + const editorRow = (positions, colspan) => state.connectorId === connectorId && positions.some((position) => position?.id === state.connectorPosition) ? `
    ${connectorEditorMarkup(board)}
    ` : ""; let rows; @@ -1272,14 +1346,15 @@ function renderConnectorTable(board, connectorId) { })); rows = Array.from({ length: type.layout.rows }, (_, row) => { const rowPositions = [positions.get(`${row}:0`), positions.get(`${row}:1`)]; - return `${renderConnectorTableCells(board, display, source, rowPositions[0], "left")}${renderConnectorTableCells(board, display, source, rowPositions[1], "right")}${editorRow(rowPositions, 6)}`; + return `${renderConnectorTableCells(board, display, source, rowPositions[0], connectorId, "left")}${renderConnectorTableCells(board, display, source, rowPositions[1], connectorId, "right")}${editorRow(rowPositions, columns.length)}`; }).join(""); } else { + // Extend this table layout when connector types with three or more columns are used. const direction = type.layout.mirror ? -1 : 1; const positions = [...type.positions].sort((a, b) => a.row - b.row || direction * (a.col - b.col)); - rows = positions.map((position) => `${renderConnectorTableCells(board, display, source, position)}${editorRow([position], 3)}`).join(""); + rows = positions.map((position) => `${renderConnectorTableCells(board, display, source, position, connectorId)}${editorRow([position], columns.length)}`).join(""); } - return `
    ${escapeHtml(type.name)}${escapeHtml(type.layout.view)}
    ${rows}
    `; + return `${connectorMeta(board, connector, type)}${columns.map((key) => ``).join("")}${columns.map((key) => ``).join("")}${rows}
    ${escapeHtml(t(`connector.${key}`))}
    `; } function gpioChoices(board) { @@ -1325,7 +1400,7 @@ function connectorEditorMarkup(board) { const readonly = readonlyAttributes(readonlyReason); const status = source.explicit || source.gpios.length ? t("connector.boardStatus") : source.default ? t("connector.defaultStatus") : t("connector.unsetStatus"); return `
    ${escapeHtml(positionId)} · ${escapeHtml(label)}${escapeHtml(status)} · ${escapeHtml(endpointLabel(resolved.positions[positionId]))}
    -
    +
      ${readonlyNote(readonlyReason)}`; } @@ -1345,44 +1420,30 @@ function hydrateConnectorEditor(root) { }, isResolvedMode() ? t("readonly.configuration") : ""); } -function renderConnectorEditor(board) { - if (state.connectorView === "table") { - elements.connectorEditor.hidden = true; - elements.connectorEditor.innerHTML = ""; - hydrateConnectorEditor(elements.connectorDiagram.querySelector("[data-connector-editor]")); - return; - } - elements.connectorEditor.hidden = false; - elements.connectorEditor.classList.toggle("open", Boolean(state.connectorPosition)); - elements.connectorEditor.innerHTML = connectorEditorMarkup(board); - hydrateConnectorEditor(elements.connectorEditor); +function renderConnectorUndo() { + const undo = state.undo; + const reason = isResolvedMode() ? t("readonly.configuration") : !undo || undo.boardId !== state.currentId ? t("unavailable.noUndo") : ""; + elements.connectorUndo.hidden = state.page?.kind !== "connector" || Boolean(reason); + applyControlLock(elements.connectorUndo, reason); + elements.connectorUndo.title = reason ? "" : t("connector.undoTarget", { id: undo.connectorId, position: undo.positionId }); } function renderConnectors(board) { - ensureConnectorSelection(board); const ids = Object.keys(board.connectors ?? {}); + document.getElementById("connector-reference-links").innerHTML = boardReferenceLinks(board); elements.connectorSectionTitle.textContent = `${t("connector.pinoutHeading")} (${ids.length})`; - elements.connectorSection.open = disclosureOpen("connectorMap"); - elements.connectorTabs.innerHTML = ids.map((id) => ``).join(""); - elements.connectorViewToggle.querySelectorAll("[data-connector-view]").forEach((button) => { - const active = button.dataset.connectorView === state.connectorView; - button.classList.toggle("active", active); - button.setAttribute("aria-pressed", String(active)); - }); - const renderer = state.connectorView === "svg" ? renderConnectorDiagram : renderConnectorTable; - elements.connectorDiagram.innerHTML = state.connectorId ? renderer(board, state.connectorId) : `
      ${escapeHtml(t("connector.none"))}
      `; - const undoReason = isResolvedMode() - ? t("readonly.configuration") - : (!state.undo || state.undo.boardId !== state.currentId) ? t("unavailable.noUndo") : ""; - applyControlLock(elements.connectorUndo, undoReason); - renderConnectorEditor(board); + const set = connectorDisplaySets(board).find((item) => item.ids.includes(state.connectorId)); + elements.connectorPinout.innerHTML = set ? `
      ${set.ids.map((id) => { + const type = resolveConnectorType(connectorTypes, board.connectors[id].type); + return `

      ${escapeHtml(id)} ${escapeHtml(t("connector.firstPin", { pin: firstConnectorPosition(type) }))}

      ${renderConnectorTable(board, id)}
      `; + }).join("")}
      ` : `
      ${escapeHtml(t("connector.none"))}
      `; + hydrateConnectorEditor(elements.connectorPinout.querySelector("[data-connector-editor]")); } function renderRelated(board) { const groups = [["buses", "bus", t("related.buses")], ["devices", "dev", t("related.devices")]]; const count = groups.reduce((sum, [field]) => sum + Object.keys(board[field] ?? {}).length, 0); elements.relatedSectionTitle.textContent = `${t("related.heading")} (${count})`; - elements.relatedSection.open = disclosureOpen("related"); elements.relatedList.innerHTML = groups.map(([field, prefix, label]) => { const items = Object.entries(board[field] ?? {}).map(([id, object]) => { const pins = objectAssignments(board, prefix, id, object); @@ -1394,7 +1455,7 @@ function renderRelated(board) { const links = gpioLinks + ownerLinks; return `
      ${roleOwnerLabel(objectRoleOwner(prefix, id), id, "object-owner-label")}${escapeHtml(object.kind ?? object.type ?? "")}
      ${objectSummary(field, object)}
      `; }).join(""); - return `

      ${escapeHtml(label)}

      ${items || `
      ${escapeHtml(t("related.empty"))}
      `}
      `; + return ``; }).join(""); } @@ -1445,7 +1506,7 @@ function boardSpecFields(board) { ["power.battery_mah", "number"], ["power.usb", "string"], ["links.schematic", "string"], ["links.product", "string"], ]; const resolution = valueAtPath(board.spec, "display.resolution") ?? []; - return `
      ${fields.map(([path, type]) => ``).join("")}
      `; + return `
      ${fields.map(([path, type]) => ``).join("")}
      `; } function renderBoardInfo(shown) { @@ -1454,12 +1515,11 @@ function renderBoardInfo(shown) { const display = Array.isArray(resolution) && resolution.length >= 2 ? `${resolution[0]}×${resolution[1]}` : "—"; const psram = shown.spec?.storage?.psram_mb ? `PSRAM ${shown.spec.storage.psram_mb}MB` : "PSRAM —"; elements.boardInfoSummary.textContent = `${shown.name} — ${shown.chip} — ${display} — ${psram}`; - elements.boardInfo.open = disclosureOpen("boardInfo"); elements.boardInfoBody.innerHTML = `
      ${boardSpecFields(board)}`; } function configurationDisclosure(id, label, count, description, body) { - return ``; + return ``; } function busRemovalReason(board, id) { @@ -1514,7 +1574,7 @@ function deviceRow(board, id, device) { ? `
      ` : ""; const partChoices = Object.values(parts).filter((part) => part.kind === device.kind && part.id !== device.part).map((part) => ``).join(""); - return `
      ${roleOwnerLabel(objectRoleOwner("dev", id), id, "configuration-owner-label")}${escapeHtml(device.part ?? device.kind)}${readonlyNote(reason)}${choiceControls}${choices}
      ${device.choices ? "" : ``}
      ${missing.length ? `
      ${escapeHtml(t("configuration.unassigned"))}:${missing.map((signal) => ``).join("")}
      ` : ""}${availableOptional.length ? `
      ${escapeHtml(t("configuration.optional"))}:${availableOptional.map((signal) => ``).join("")}
      ` : ""}
      `; + return `
      ${roleOwnerLabel(objectRoleOwner("dev", id), id, "configuration-owner-label")}${escapeHtml(device.part ?? device.kind)}${readonlyNote(reason)}${choiceControls}${choices}
      ${device.choices ? "" : ``}
      ${missing.length ? `
      ${escapeHtml(t("configuration.unassigned"))}:${missing.map((signal) => ``).join("")}
      ` : ""}${availableOptional.length ? `
      ${escapeHtml(t("configuration.optional"))}:${availableOptional.map((signal) => ``).join("")}
      ` : ""}
      `; } function addField(label, control, className = "") { @@ -1528,12 +1588,12 @@ function renderConfiguration() { const standards = Object.keys(type?.standards ?? {}); const reason = objectRemovalReason(board, "conn", id); const mark = reason ? `🔗` : ""; - return `
      ${mark}${roleOwnerLabel(objectRoleOwner("conn", id), id, "configuration-owner-label")}${escapeHtml(connector.type)}
      `; + return `
      ${mark}${roleOwnerLabel(objectRoleOwner("conn", id), id, "configuration-owner-label")}${escapeHtml(connector.type)}
      `; }).join(""); const busRows = Object.entries(board.buses ?? {}).map(([id, bus]) => { const reason = busRemovalReason(board, id); const mark = reason ? `🔗` : ""; - return `
      ${mark}${roleOwnerLabel(objectRoleOwner("bus", id), id, "configuration-owner-label")}${escapeHtml(bus.kind)}${escapeHtml((bus.signals ?? []).join(", "))}
      `; + return `
      ${mark}${roleOwnerLabel(objectRoleOwner("bus", id), id, "configuration-owner-label")}${escapeHtml(bus.kind)}${escapeHtml((bus.signals ?? []).join(", "))}
      `; }).join(""); const deviceRows = Object.entries(board.devices ?? {}).map(([id, device]) => deviceRow(board, id, device)).join(""); const entries = state.compositionEntries.get(state.currentId) ?? []; @@ -1670,10 +1730,6 @@ function renderOwnerEditor(board) { } function renderViews(board) { - for (const button of elements.viewTabs.querySelectorAll("[data-view]")) button.classList.toggle("active", button.dataset.view === state.activeView); - elements.configurationView.hidden = state.activeView !== "configuration"; - elements.revisionsView.hidden = state.activeView !== "revisions"; - elements.ownersView.hidden = state.activeView !== "owners"; renderBoardInfo(board); renderConfiguration(); renderRevisionEditor(); @@ -1693,7 +1749,7 @@ function renderSaveState() { function renderRepositoryNext() { const file = state.lastExported; prepareMotionRegion(elements.repositoryNext); - if (file) elements.repositoryNext.querySelector(".motion-region-inner").innerHTML = `
      ${escapeHtml(t("repository.downloaded", { file }))}

      ${escapeHtml(t("repository.notAutomatic"))}

      1. ${escapeHtml(t("repository.replace", { file }))}
      2. node cli/spec.js check
      3. ${escapeHtml(t("repository.commit"))}
      `; + if (file) elements.repositoryNext.querySelector(".motion-region-inner").innerHTML = `
      ${escapeHtml(t("repository.downloaded", { file }))}

      ${escapeHtml(t("repository.notAutomatic"))}

      1. ${escapeHtml(t("repository.replace", { file }))}
      2. node cli/spec.js check
      3. ${escapeHtml(t("repository.commit"))}
      `; elements.repositoryNext.classList.toggle("is-open", Boolean(file)); elements.repositoryNext.setAttribute("aria-hidden", String(!file)); elements.repositoryNext.inert = !file; @@ -1702,10 +1758,10 @@ function renderRepositoryNext() { function renderAll() { try { applyStaticTranslations(); - setSidebarWidth(state.layout.sidebarWidth); renderOnboarding(); const board = shownBoard(); const errors = validationFor(currentBase()); + navigateTo(state.page ?? state.layout.page, { remember: false, render: false }); renderBoardSelect(); renderOptions(); renderViews(board); @@ -1716,6 +1772,9 @@ function renderAll() { renderRelated(board); renderSaveState(); renderRepositoryNext(); + renderNavigation(board); + applyPageVisibility(); + requestAnimationFrame(() => { if (state.page.kind === "gpio") sizeConnectorLanes(assignConnectorLanes(board.pins).lanes.length); }); prepareDisclosureTransitions(); } catch (failure) { showToast(failure.message); @@ -1760,11 +1819,13 @@ function addRoleUI(gpio, role) { } catch (failure) { operationFailure(failure); } } -function rememberConnectorUndo(board) { +function rememberConnectorUndo(board, connectorId, positionId) { const entry = currentEntry(); state.undo = { boardId: state.currentId, base: clone(board), + connectorId, + positionId, dirty: entry.dirty, expandedGPIO: entry.expandedGPIO, }; @@ -1799,9 +1860,8 @@ function assignConnectorPosition(board, connectorId, positionId, endpoint, optio if (!window.confirm(t("connector.multiConfirm"))) return; } - rememberConnectorUndo(board); - state.connectorId = connectorId; - state.connectorPosition = positionId; + rememberConnectorUndo(board, connectorId, positionId); + if (state.page?.kind === "connector") navigateTo({ kind: "connector", id: connectorId, position: positionId }, { remember: false, render: false }); if (options.removeGPIO !== undefined) { removeConnectorRole(board, connectorId, positionId, options.removeGPIO); @@ -1842,50 +1902,21 @@ function assignConnectorPosition(board, connectorId, positionId, endpoint, optio renderAll(); } -function connectorForGPIO(board, gpio) { - for (const [id, connector] of Object.entries(board.connectors ?? {})) { - const positions = connectorRoleGPIOs(board, id); - if ([...positions.values()].some((gpios) => gpios.includes(Number(gpio)))) return id; - } - return null; -} - -function revealConnectorForGPIO(gpio) { - const connectorId = connectorForGPIO(currentBase(), gpio); - if (connectorId && connectorId !== state.connectorId) { - state.connectorId = connectorId; - state.connectorPosition = null; - } -} - function focusGPIO(gpio) { if (gpio === null || gpio === undefined) return; - const entry = currentEntry(); - entry.expandedGPIO = String(gpio); - revealConnectorForGPIO(gpio); - writeDraft(entry); - renderAll(); - const row = document.getElementById(`gpio-${gpio}`); - if (!row) return; - row.scrollIntoView({ behavior: "smooth", block: "center" }); - row.classList.remove("flash"); - requestAnimationFrame(() => row.classList.add("flash")); + navigateTo({ kind: "gpio", gpio }); } function openRoleChooser(role) { - state.activeView = "configuration"; clearResolvedMode(); const chip = chipForBoard(currentBase()); const conditional = chip.reserved_conditional?.[currentBase().spec?.storage?.psram_mode] ?? []; const gpio = Array.from({ length: chip.gpio_count }, (_, index) => String(index)).find((id) => !chip.absent.includes(Number(id)) && !chip.reserved.includes(Number(id)) && !conditional.includes(Number(id)) && allRoleOptions(currentBase(), id).includes(role) && !(currentBase().pins[id]?.roles?.length)) ?? "0"; - currentEntry().expandedGPIO = gpio; renderAll(); - const input = elements.tableBody.querySelector(`[data-role-combobox="${gpio}"] input`); - if (input) { - input.value = role; - input.focus(); - input.dispatchEvent(new Event("input", { bubbles: true })); - } + navigateTo({ kind: "gpio", gpio }, { after: () => { + const input = elements.tableBody.querySelector(`[data-role-combobox="${gpio}"] input`); + if (input) { input.value = role; input.focus(); input.dispatchEvent(new Event("input", { bubbles: true })); } + } }); } async function openFile(file) { @@ -1901,8 +1932,7 @@ async function openFile(file) { state.selectedOptions.clear(); state.runtimeSelections.set(value.id, Object.fromEntries(choiceSlots(value).filter(([, device]) => device.selected_by === "runtime").map(([slot, device]) => [slot, device.default]))); state.compositionEntries.set(value.id, clone(targets.m5unified_pin_table?.compositions?.[value.id]?.default?.accessories ?? [])); - state.connectorId = null; - state.connectorPosition = null; + navigateTo({ kind: state.page?.kind ?? "gpio" }, { reset: true, render: false }); state.undo = null; renderAll(); showToast(t("file.opened", { file: file.name })); @@ -2011,8 +2041,40 @@ elements.onboardingHelp.addEventListener("click", () => { renderOnboarding(); }); elements.onboardingClose.addEventListener("click", dismissOnboarding); +elements.onboarding.addEventListener("cancel", (event) => { event.preventDefault(); dismissOnboarding(); }); +elements.repositoryNext.addEventListener("click", (event) => { + if (event.target.closest("[data-dismiss-export]")) { state.lastExported = null; renderRepositoryNext(); } +}); +const fileMenu = document.getElementById("file-menu"); +fileMenu.addEventListener("click", (event) => { if (event.target.closest("button")) fileMenu.open = false; }); +fileMenu.addEventListener("keydown", (event) => { + if (event.key === "Escape") { fileMenu.open = false; fileMenu.querySelector("summary").focus(); } +}); +function closeRuntimePanel(returnFocus = false) { + state.runtimePanelOpen = false; + const panel = document.getElementById("runtime-panel"); + if (panel) panel.hidden = true; + const button = elements.optionPanel.querySelector("[data-runtime-toggle]"); + button?.setAttribute("aria-expanded", "false"); + if (returnFocus) button?.focus(); +} +elements.optionPanel.addEventListener("click", (event) => { + const button = event.target.closest("[data-runtime-toggle]"); + if (!button) return; + state.runtimePanelOpen = !state.runtimePanelOpen; + document.getElementById("runtime-panel").hidden = !state.runtimePanelOpen; + button.setAttribute("aria-expanded", String(state.runtimePanelOpen)); +}); +elements.optionPanel.addEventListener("keydown", (event) => { + if (event.key === "Escape" && state.runtimePanelOpen) { event.preventDefault(); closeRuntimePanel(true); } +}); +document.addEventListener("click", (event) => { + if (!event.target.closest(".runtime-control")) closeRuntimePanel(); + if (!fileMenu.contains(event.target)) fileMenu.open = false; +}); elements.fileInput.addEventListener("change", () => { + fileMenu.open = false; const [file] = elements.fileInput.files; if (file) openFile(file); }); @@ -2027,33 +2089,38 @@ elements.editBase.addEventListener("click", () => { elements.gpioFilter.value = state.gpioFilter; elements.gpioAssignedOnly.checked = state.gpioAssignedOnly; elements.gpioFilter.addEventListener("input", () => { + navigateTo({ kind: "gpio" }, { remember: false, render: false }); state.gpioFilter = elements.gpioFilter.value; saveGPIOFilterState(); renderWithFade(() => renderTable(shownBoard(), validationFor(currentBase()))); }); elements.gpioAssignedOnly.addEventListener("change", () => { + navigateTo({ kind: "gpio" }, { remember: false, render: false }); state.gpioAssignedOnly = elements.gpioAssignedOnly.checked; saveGPIOFilterState(); renderWithFade(() => renderTable(shownBoard(), validationFor(currentBase()))); }); -elements.roleColorLegend.addEventListener("change", (event) => { +for (const legend of [elements.roleColorLegend, elements.connectorRoleColorLegend]) { +legend.addEventListener("change", (event) => { const owner = event.target.dataset.roleColor; if (!owner || !/^#[0-9a-f]{6}$/i.test(event.target.value)) return; state.roleColors = { ...state.roleColors, [owner]: event.target.value.toLowerCase() }; saveRoleColors(); renderWithFade(renderAll); }); -elements.roleColorLegend.addEventListener("toggle", (event) => { - if (event.target.matches("details")) state.roleColorsOpen = event.target.open; +legend.addEventListener("toggle", (event) => { + if (event.target.matches("details")) state[legend === elements.roleColorLegend ? "roleColorsOpen" : "connectorRoleColorsOpen"] = event.target.open; }, true); -elements.roleColorLegend.addEventListener("click", (event) => { +legend.addEventListener("click", (event) => { if (!event.target.closest("[data-reset-role-colors]")) return; state.roleColors = {}; saveRoleColors(); renderWithFade(renderAll); }); +} + elements.optionPanel.addEventListener("change", (event) => { if (event.target.matches("[data-display-revision]")) { state.selectedOptions.clear(); @@ -2069,11 +2136,17 @@ elements.optionPanel.addEventListener("change", (event) => { } }); -elements.viewTabs.addEventListener("click", (event) => { - const button = event.target.closest("[data-view]"); - if (!button) return; - state.activeView = button.dataset.view; - renderAll(); +elements.pageNav.addEventListener("click", (event) => { + const group = event.target.closest("[data-nav-group]"); + if (group) { const focused = document.activeElement === group; state.layout.navGroups.connectors = !state.layout.navGroups.connectors; renderNavigation(shownBoard()); applyPageVisibility(); saveLayoutState(); if (focused) elements.pageNav.querySelector("[data-nav-group]")?.focus(); return; } + const button = event.target.closest("[data-route-kind]"); + if (button) navigateTo({ kind: button.dataset.routeKind, id: button.dataset.routeId }); +}); +elements.navToggle.addEventListener("click", () => { state.layout.navCollapsed = !state.layout.navCollapsed; applyPageVisibility(); saveLayoutState(); requestAnimationFrame(() => { if (state.page.kind === "gpio") sizeConnectorLanes(assignConnectorLanes(shownBoard().pins).lanes.length); }); }); +elements.pageBack.addEventListener("click", () => { + const previous = state.previousPage; + state.previousPage = null; + if (previous?.boardId === state.currentId) navigateTo(previous.route, { remember: false, restoreScroll: previous.scroll }); }); elements.configurationView.addEventListener("focusout", (event) => { @@ -2143,34 +2216,6 @@ elements.configurationView.addEventListener("toggle", (event) => { saveLayoutState(); }, true); -elements.sideResizer.addEventListener("pointerdown", (event) => { - if (event.button !== 0 || window.matchMedia("(max-width: 980px)").matches) return; - event.preventDefault(); - const startX = event.clientX; - const startWidth = state.layout.sidebarWidth; - elements.sideResizer.classList.add("is-dragging"); - elements.sideResizer.setPointerCapture?.(event.pointerId); - const move = (moveEvent) => setSidebarWidth(startWidth - (moveEvent.clientX - startX)); - const finish = () => { - elements.sideResizer.classList.remove("is-dragging"); - window.removeEventListener("pointermove", move); - window.removeEventListener("pointerup", finish); - window.removeEventListener("pointercancel", finish); - saveLayoutState(); - }; - window.addEventListener("pointermove", move); - window.addEventListener("pointerup", finish); - window.addEventListener("pointercancel", finish); -}); - -elements.sideResizer.addEventListener("keydown", (event) => { - if (!["ArrowLeft", "ArrowRight"].includes(event.key)) return; - event.preventDefault(); - const step = event.shiftKey ? 32 : 16; - setSidebarWidth(state.layout.sidebarWidth + (event.key === "ArrowLeft" ? step : -step)); - saveLayoutState(); -}); - elements.configurationList.addEventListener("click", (event) => { const roleButton = event.target.closest("[data-unassigned-role]"); if (roleButton) return openRoleChooser(roleButton.dataset.unassignedRole); @@ -2259,8 +2304,7 @@ elements.revisionEditor.addEventListener("click", (event) => { elements.ownerEditor.addEventListener("click", (event) => { const tab = event.target.closest("[data-owner-key]"); if (!tab) return; - state.ownerKey = tab.dataset.ownerKey; - renderAll(); + navigateTo({ kind: "owners", id: tab.dataset.ownerKey }); }); function toggleGPIORow(row) { @@ -2269,7 +2313,7 @@ function toggleGPIORow(row) { const next = previous === row.dataset.gpioRow ? null : row.dataset.gpioRow; if (prefersReducedMotion()) { entry.expandedGPIO = next; - if (next !== null) revealConnectorForGPIO(next); + navigateTo({ kind: "gpio", gpio: next }, { remember: false, render: false }); writeDraft(entry); renderAll(); return; @@ -2294,7 +2338,7 @@ function toggleGPIORow(row) { openingGPIOs.add(next); } entry.expandedGPIO = next; - if (entry.expandedGPIO !== null) revealConnectorForGPIO(entry.expandedGPIO); + navigateTo({ kind: "gpio", gpio: next }, { remember: false, render: false }); writeDraft(entry); renderAll(); } @@ -2307,16 +2351,8 @@ elements.tableBody.addEventListener("click", (event) => { } const related = event.target.closest("[data-target-object]"); if (related) { - if (related.dataset.targetObject.startsWith("connectors:")) { - state.connectorId = related.dataset.targetObject.slice("connectors:".length); - state.connectorPosition = null; - renderAll(); - return; - } - const object = elements.relatedList.querySelector(`[data-object-key="${related.dataset.targetObject}"]`); - object?.scrollIntoView({ behavior: "smooth", block: "center" }); - object?.classList.add("flash"); - setTimeout(() => object?.classList.remove("flash"), 1200); + const [kind, ...id] = related.dataset.targetObject.split(":"); + navigateTo({ kind: kind === "connectors" ? "connector" : kind, id: id.join(":"), position: related.dataset.targetPosition }); return; } const row = event.target.closest("[data-gpio-row]"); @@ -2344,7 +2380,9 @@ elements.validationList.addEventListener("click", (event) => { if (!button) return; const board = shownBoard(); const errors = validationFor(currentBase()); - focusGPIO(gpioForError(errors[Number(button.dataset.errorIndex)], board)); + const item = errors[Number(button.dataset.errorIndex)]; + const gpio = gpioForError(item, board); + navigateTo(gpio != null ? { kind: "gpio", gpio } : editorErrorRoute(item, board)); }); elements.relatedList.addEventListener("click", (event) => { @@ -2354,39 +2392,24 @@ elements.relatedList.addEventListener("click", (event) => { if (owner) { const [ownerId, pin] = owner.dataset.ownerTarget.split(":"); const shown = shownBoard(); - state.ownerKey = ownerDefinitions(currentBase()).find((item) => item.id === ownerId && item.part === shown.devices?.[ownerId]?.part)?.key ?? ownerId; - state.activeView = "owners"; - renderAll(); - requestAnimationFrame(() => document.getElementById(`owner-${state.ownerKey}-${pin}`)?.scrollIntoView({ behavior: "smooth", block: "center" })); + const key = ownerDefinitions(currentBase()).find((item) => item.id === ownerId && item.part === shown.devices?.[ownerId]?.part)?.key ?? ownerId; + navigateTo({ kind: "owners", id: key }, { after: () => document.getElementById(`owner-${key}-${pin}`)?.scrollIntoView({ behavior: "smooth", block: "center" }) }); } }); -elements.connectorTabs.addEventListener("click", (event) => { - const tab = event.target.closest("[data-connector-tab]"); - if (!tab) return; - state.connectorId = tab.dataset.connectorTab; - state.connectorPosition = null; - renderAll(); -}); - -elements.connectorViewToggle.addEventListener("click", (event) => { - const button = event.target.closest("[data-connector-view]"); - if (!button) return; - state.connectorView = button.dataset.connectorView; - renderAll(); -}); - function selectConnectorPosition(target) { - state.connectorPosition = state.connectorPosition === target.dataset.connectorPosition ? null : target.dataset.connectorPosition; - renderAll(); + const id = target.closest("[data-connector-id]")?.dataset.connectorId ?? state.connectorId; + navigateTo({ kind: "connector", id, position: state.connectorId === id && state.connectorPosition === target.dataset.connectorPosition ? null : target.dataset.connectorPosition }); } -elements.connectorDiagram.addEventListener("click", (event) => { +elements.connectorPinout.addEventListener("click", (event) => { + const gpio = event.target.closest("[data-target-gpio]"); + if (gpio) return focusGPIO(gpio.dataset.targetGpio); const position = event.target.closest("[data-connector-position]"); if (position) selectConnectorPosition(position); }); -elements.connectorDiagram.addEventListener("keydown", (event) => { +elements.connectorPinout.addEventListener("keydown", (event) => { if (event.key !== "Enter" && event.key !== " ") return; const position = event.target.closest("[data-connector-position]"); if (!position) return; @@ -2394,27 +2417,9 @@ elements.connectorDiagram.addEventListener("keydown", (event) => { selectConnectorPosition(position); }); -elements.connectorDiagram.addEventListener("mouseover", (event) => { - const position = event.target.closest("[data-connector-position]"); - const related = event.relatedTarget?.closest?.("[data-connector-position]"); - if (!position || related?.dataset.connectorPosition === position.dataset.connectorPosition) return; - const endpoint = resolveConnectorPositions(shownBoard(), state.connectorId, connectorTypes).positions[position.dataset.connectorPosition]; - const rows = endpointGPIOs(endpoint).map((gpio) => document.getElementById(`gpio-${gpio}`)).filter(Boolean); - rows.forEach((row) => row.classList.add("diagram-highlight")); - rows[0]?.scrollIntoView({ behavior: "smooth", block: "nearest" }); -}); - -elements.connectorDiagram.addEventListener("mouseout", (event) => { - const position = event.target.closest("[data-connector-position]"); - const related = event.relatedTarget?.closest?.("[data-connector-position]"); - if (!position || related?.dataset.connectorPosition === position.dataset.connectorPosition) return; - elements.tableBody.querySelectorAll(".diagram-highlight").forEach((row) => row.classList.remove("diagram-highlight")); -}); - function handleConnectorEditorClick(event) { if (event.target.closest("[data-connector-close]")) { - state.connectorPosition = null; - renderAll(); + navigateTo({ kind: "connector", id: state.connectorId }); return; } const tab = event.target.closest("[data-connector-editor-tab]"); @@ -2428,8 +2433,7 @@ function handleConnectorEditorClick(event) { } } -elements.connectorEditor.addEventListener("click", handleConnectorEditorClick); -elements.connectorDiagram.addEventListener("click", (event) => { +elements.connectorPinout.addEventListener("click", (event) => { if (event.target.closest("[data-connector-editor]")) handleConnectorEditorClick(event); }); @@ -2442,8 +2446,7 @@ document.addEventListener("keydown", (event) => { return; } if (event.key !== "Escape" || !state.connectorPosition) return; - state.connectorPosition = null; - renderAll(); + navigateTo({ kind: "connector", id: state.connectorId }); }); elements.connectorUndo.addEventListener("click", () => { @@ -2458,25 +2461,30 @@ elements.connectorUndo.addEventListener("click", () => { renderAll(); }); +let fileDragDepth = 0; +const isFileDrag = (event) => [...(event.dataTransfer?.types ?? [])].includes("Files"); for (const eventName of ["dragenter", "dragover"]) { elements.dropZone.addEventListener(eventName, (event) => { + if (!isFileDrag(event)) return; event.preventDefault(); + if (eventName === "dragenter") fileDragDepth += 1; + if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; elements.dropZone.classList.add("dragging"); }); } -for (const eventName of ["dragleave", "drop"]) { - elements.dropZone.addEventListener(eventName, (event) => { - event.preventDefault(); - elements.dropZone.classList.remove("dragging"); - }); -} +elements.dropZone.addEventListener("dragleave", (event) => { + if (!isFileDrag(event)) return; + fileDragDepth = Math.max(0, fileDragDepth - 1); + if (!fileDragDepth) elements.dropZone.classList.remove("dragging"); +}); elements.dropZone.addEventListener("drop", (event) => { + if (!isFileDrag(event) && !event.dataTransfer?.files?.length) return; + event.preventDefault(); + fileDragDepth = 0; + elements.dropZone.classList.remove("dragging"); const [file] = event.dataTransfer.files; if (file) openFile(file); }); -elements.dropZone.addEventListener("keydown", (event) => { - if (event.key === "Enter" || event.key === " ") elements.fileInput.click(); -}); window.addEventListener("beforeunload", (event) => { if (![...state.documents.values()].some((entry) => entry.dirty)) return; diff --git a/extras/board_spec/editor/html.js b/extras/board_spec/editor/html.js index b0baf906..ea64c2d4 100644 --- a/extras/board_spec/editor/html.js +++ b/extras/board_spec/editor/html.js @@ -17,3 +17,80 @@ export function ownerSignalsHtml(device) { .map(([signal, endpoint]) => `${escapeHtml(signal)} → ${escapeHtml(endpoint)}`) .join(" · ") || "—"; } + +export function externalLinkHtml(url, label) { + try { + const parsed = new URL(url); + if (!["https:", "http:"].includes(parsed.protocol)) return ""; + return `${escapeHtml(label)}`; + } catch { return ""; } +} + +export function gpioConnectorTargets(board, gpio) { + const targets = new Map(); + for (const role of board.pins?.[gpio]?.roles ?? []) { + const match = /(?:^|\/)conn:([^.]+)\.(.+)$/.exec(role); + if (match && board.connectors?.[match[1]]) targets.set(`${match[1]}.${match[2]}`, { connector: match[1], position: match[2] }); + } + return [...targets.values()]; +} + +export function resolveEditorRoute(target, board, context = {}) { + const kinds = ["board", "gpio", "connectors", "connector", "buses", "devices", "accessories", "revisions", "owners", "validation"]; + const route = { kind: kinds.includes(target?.kind) ? target.kind : "gpio" }; + let id = target?.id == null ? null : String(target.id); + if (target?.kind === "connector-group") { + id = Object.keys(board.connectors ?? {}).find((key) => board.connectors[key].group === id); + route.kind = "connector"; + } + if (route.kind === "connector") { + if (!id || !board.connectors?.[id]) return { kind: "connectors" }; + route.id = id; + if (board.connectors[id].group) route.group = board.connectors[id].group; + const type = context.connectorTypes?.[board.connectors[id].type]; + if (target.position != null && type?.positions?.some((position) => position.id === String(target.position))) route.position = String(target.position); + } else if (["buses", "devices"].includes(route.kind) && id && board[route.kind]?.[id]) route.id = id; + else if (route.kind === "revisions" && id && board.revisions?.some((revision) => revision.id === id)) route.id = id; + else if (route.kind === "owners" && id && (context.ownerKeys ?? Object.keys(board.devices ?? {})).includes(id)) route.id = id; + if (route.kind === "gpio" && target?.gpio != null) { + const gpio = Number(target.gpio); + const count = context.chip?.gpio_count; + if (Number.isInteger(gpio) && gpio >= 0 && (count === undefined || gpio < count) && !context.chip?.absent?.includes(gpio)) route.gpio = String(gpio); + } + return route; +} + +export function editorErrorRoute(item, board) { + const path = String(item.path ?? "").split("/").slice(1).map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~")); + if (path[0] === "pins") return { kind: "gpio", gpio: path[1] }; + if (path[0] === "connectors") return path[1] ? { kind: "connector", id: path[1], ...(path[2] === "positions" ? { position: path[3] } : {}) } : { kind: "connectors" }; + if (["buses", "devices"].includes(path[0])) return { kind: path[0], id: path[1] }; + if (path[0] === "revisions") return { kind: "revisions", id: board.revisions?.[Number(path[1])]?.id }; + return { kind: "board" }; +} + + +export function connectorDisplaySets(board) { + const sets = new Map(); + for (const [id, connector] of Object.entries(board.connectors ?? {})) { + const key = connector.group ? `group:${connector.group}` : `connector:${id}`; + if (!sets.has(key)) sets.set(key, { group: connector.group ?? null, ids: [] }); + sets.get(key).ids.push(id); + } + return [...sets.values()]; +} + +export function editorPageKey(route) { + return `${route.kind}:${route.group ? `group:${route.group}` : route.kind === "owners" ? "" : route.id ?? ""}`; +} + +export function firstConnectorPosition(type) { + return type?.positions?.find((position) => position.id === "1")?.id ?? type?.positions?.[0]?.id ?? "1"; +} + +export function connectorTableColumns(type) { + const wire = type.positions.some((position) => position.color) ? ["wire"] : []; + return type.layout.cols === 2 + ? ["function", "endpoint", ...wire, "terminal", "terminal", ...wire, "endpoint", "function"] + : ["terminal", ...wire, "endpoint", "function"]; +} diff --git a/extras/board_spec/editor/index.html b/extras/board_spec/editor/index.html index e1001f7a..656d9dfc 100644 --- a/extras/board_spec/editor/index.html +++ b/extras/board_spec/editor/index.html @@ -6,29 +6,34 @@ M5 Board Spec Editor - -
      -
      -

      M5 board specification catalog

      -

      GPIO specification editor

      -
      + +
      + +
      + + +
      + Open ▾ +
      + + + + +
      +
      + + +
      -
      - - + +
      - - - -
      - - - - -
      Drop JSON to open
      - - -
      + - -
      -
      -

      - - ? -
      - - - +
      -
      - Board information -
      -
      -
      + +
      +

      +
      + + - - - - -
      - -
      -

      choices

      Revisions

      Record which device choice belongs to each hardware revision.

      -
      - -
      -

      device pins

      Pin owners

      Inspect terminals owned by devices such as PMICs and I/O expanders.

      + +
      + + + + + +
      @@ -178,6 +124,22 @@

      "app.catalog": { "en": "M5 board specification catalog", "zh": "M5 板卡规格目录", "ja": "M5 ボード仕様カタログ" }, "app.heading": { "en": "GPIO specification editor", "zh": "GPIO 规格编辑器", "ja": "GPIO 仕様エディタ" }, "toolbar.label": { "en": "Files and board", "zh": "文件与板卡", "ja": "ファイルと機種" }, + "toolbar.openMenu": {"en": "Open ▾", "zh": "打开 ▾", "ja": "開く ▾"}, + "toolbar.exportShort": {"en": "Export", "zh": "导出", "ja": "書き出し"}, + "toolbar.discardShort": {"en": "Discard", "zh": "丢弃", "ja": "破棄"}, + "options.conditions": {"en": "Conditions", "zh": "条件", "ja": "条件"}, + "mode.returnHint": {"en": "Clear revision and runtime selections to return to base data.", "zh": "清除版本和运行时选择,返回基本数据。", "ja": "版と実行時構成の選択を解除して基本データに戻します。"}, + "repository.dismiss": {"en": "Close export instructions", "zh": "关闭导出说明", "ja": "書き出し案内を閉じる"}, + "nav.label": {"en": "Catalog pages", "zh": "目录页面", "ja": "項目一覧"}, + "nav.collapse": {"en": "Collapse list", "zh": "收起列表", "ja": "一覧を畳む"}, + "nav.expand": {"en": "Expand list", "zh": "展开列表", "ja": "一覧を開く"}, + "nav.back": {"en": "← {page}", "zh": "← {page}", "ja": "← {page}"}, + "nav.gpioCount": {"en": "GPIO with assigned roles / available GPIO", "zh": "有角色的 GPIO / 可用 GPIO", "ja": "役割のあるGPIO / 実在するGPIO"}, + "nav.objectCount": {"en": "Declared objects in this category", "zh": "此类别的对象数", "ja": "この種類で宣言された項目数"}, + "nav.terminalCount": {"en": "Connector positions declared by its type", "zh": "类型定义的端子数", "ja": "型で定義された端子数"}, + "nav.validationCount": {"en": "All validation errors and warnings", "zh": "所有验证错误和警告", "ja": "検証のエラーと警告の合計"}, + "nav.connectorChildren": {"en": "Show connector entries", "zh": "显示连接器条目", "ja": "コネクタ項目を表示"}, + "nav.connector": {"en": "Connector", "zh": "连接器", "ja": "コネクタ"}, "toolbar.board": { "en": "Board", "zh": "板卡", "ja": "機種" }, "toolbar.boardSearch": { "en": "Search board name, ID, or alias", "zh": "搜索板卡名称、ID 或别名", "ja": "機種名・ID・別名で検索" }, "toolbar.language": { "en": "Language", "zh": "语言", "ja": "言語" }, @@ -255,7 +217,7 @@

      "options.runtime": { "en": "Detected variant preview", "zh": "实机判别变体预览", "ja": "起動時判別バリエーション(表示のみ)" }, "mode.base": { "en": "Editing the shared definition. All GPIO rows are editable; changes are auto-saved only in this browser.", "zh": "正在编辑共通定义。所有 GPIO 行均可编辑;更改仅自动保存在此浏览器。", "ja": "共通定義を編集中。すべての GPIO 行を編集でき、変更はこのブラウザだけに自動下書き保存されます。" }, "mode.resolved": { "en": "Viewing {board} {revision} ({choices}). Values supplied by this selection are read-only.", "zh": "正在查看 {board} {revision}({choices})。由此选择提供的值为只读。", "ja": "{board} {revision} ({choices}) を表示中。この選択で決まる値は読み取り専用です。" }, - "mode.editBase": { "en": "Clear preview and edit shared definition", "zh": "清除预览并编辑共通定义", "ja": "表示条件を解除して共通定義を編集" }, + "mode.editBase": {"en": "Return to base data", "zh": "返回基本数据", "ja": "基本データに戻す"}, "readonly.configuration": { "en": "Supplied by the displayed variant; clear the preview to edit", "zh": "由当前显示变体提供;清除预览后可编辑", "ja": "表示中のバリエーション由来です。表示条件を解除すると編集できます" }, "readonly.revision": { "en": "Changed by revision {revision}", "zh": "由版本 {revision} 的差分更改", "ja": "版 {revision} の差分で変わる値です" }, "readonly.runtime": { "en": "Changed by detected-hardware choice {choice}", "zh": "来自实机判别选项 {choice}", "ja": "実機判別 {choice} で変わる値です" }, @@ -396,12 +358,21 @@

      "validation.count": { "en": "{count}", "zh": "{count} 项", "ja": "{count} 件" }, "validation.ok": { "en": "✓ No errors", "zh": "✓ 没有错误", "ja": "✓ エラーはありません" }, "connector.eyebrow": { "en": "positions and endpoints", "zh": "位置与连接目标", "ja": "位置と接続先" }, + "connector.groupLabel": { "en": "Set {group}", "zh": "连接器组 {group}", "ja": "セット {group}" }, + "connector.firstPin": { "en": "First pin: {pin}", "zh": "首引脚: {pin}", "ja": "1番端子: {pin}" }, "connector.heading": { "en": "Connectors", "zh": "connector", "ja": "コネクタ" }, + "connector.unverified": {"en": "unverified", "zh": "未验证", "ja": "未確認"}, + "connector.typeView": {"en": "Type view", "zh": "类型视角", "ja": "型の視点"}, + "connector.layoutStatus": {"en": "Physical layout", "zh": "物理布局", "ja": "物理配置"}, + "connector.pitch": {"en": "Pitch", "zh": "间距", "ja": "ピッチ"}, + "connector.typeNote": {"en": "Type note", "zh": "类型备注", "ja": "型の注記"}, + "connector.wire": {"en": "Wire", "zh": "线色", "ja": "線色"}, + "connector.terminal": {"en": "Terminal", "zh": "端子", "ja": "端子"}, + "connector.function": {"en": "Function", "zh": "功能", "ja": "機能"}, + "connector.endpoint": {"en": "Endpoint", "zh": "连接目标", "ja": "接続先"}, "connector.pinoutHeading": { "en": "Connector diagrams & pinouts", "zh": "连接器图和引脚排列", "ja": "コネクタ図・ピン配置" }, "connector.description": { "en": "Select a connector position to review or change its endpoint.", "zh": "选择连接器位置以查看或更改其端点。", "ja": "コネクタ位置を選び、接続先を確認・変更します。" }, - "connector.view": { "en": "View", "zh": "显示方式", "ja": "表示形式" }, - "connector.table": { "en": "Table", "zh": "表格", "ja": "表" }, - "connector.diagram": { "en": "Diagram", "zh": "图", "ja": "図" }, + "connector.undoTarget": { "en": "Undo assignment of {id} {position}", "zh": "撤销 {id} {position} 的分配", "ja": "{id} {position} の割り当てを戻す" }, "connector.undo": { "en": "Undo connector change", "zh": "撤销连接器更改", "ja": "直前のコネクタ変更を戻す" }, "connector.noType": { "en": "Type data is unavailable", "zh": "没有类型数据", "ja": "型データがありません" }, "connector.pinoutAria": { "en": "{id} connector pinout", "zh": "{id} connector 引脚排列", "ja": "{id} コネクタのピン配置" }, diff --git a/extras/board_spec/editor/role_colors.js b/extras/board_spec/editor/role_colors.js index 16306777..7cff3f96 100644 --- a/extras/board_spec/editor/role_colors.js +++ b/extras/board_spec/editor/role_colors.js @@ -36,7 +36,8 @@ export function roleOwner(role) { const plain = String(role ?? "").split("/").at(-1); const object = /^(bus|dev|conn):([a-z][a-z0-9_]*)\./.exec(plain); if (object) return objectRoleOwner(object[1], object[2]); - const power = /^pwr:([a-z][a-z0-9_]*)$/.exec(plain); + // Voltage rail names such as 3v3 and 5v begin with a digit. + const power = /^pwr:([a-z0-9_]+)$/.exec(plain); return power ? objectRoleOwner("pwr", power[1]) : null; } @@ -110,3 +111,17 @@ export function connectorRolesByLane(roles, assignment) { } return byLane; } + + +export function connectorEndpointRoles(board, endpoint, connectorId) { + const roles = []; + for (const value of Array.isArray(endpoint) ? endpoint : [endpoint]) { + const gpio = /^gpio:(\d+)$/.exec(value ?? "")?.[1]; + if (gpio !== undefined) roles.push(...(board.pins?.[gpio]?.roles ?? [])); + if (/^pwr:/.test(value ?? "")) roles.push(value); + } + return [...new Set(roles)].filter((role) => { + const owner = roleOwner(role); + return owner?.kind !== "conn" || owner.id !== connectorId; + }); +} diff --git a/extras/board_spec/test/board_spec.test.js b/extras/board_spec/test/board_spec.test.js index 22811711..3d6f687b 100644 --- a/extras/board_spec/test/board_spec.test.js +++ b/extras/board_spec/test/board_spec.test.js @@ -1,3 +1,4 @@ +import { connectorTableColumns } from "../editor/html.js"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { promises as fs } from "node:fs"; @@ -744,14 +745,9 @@ test("editor translations are complete and every static reference exists", async assert.match(editorSource, /--combobox-max-height/); assert.match(editorHtml, /[\s\S]*gpio-col-validation[\s\S]*<\/colgroup>/); assert.match(editorCSS, /#gpio-table \{ table-layout: fixed; \}/); - assert.match(editorHtml, /id="board-info"[^>]*data-layout-section="boardInfo"/); - assert.match(editorHtml, /id="side-resizer"[^>]*role="separator"[^>]*aria-valuemin="360"[^>]*aria-valuemax="620"/); - assert.match(editorSource, /m5-board-spec-layout:v1/); - assert.match(editorSource, /sidebarWidthLimits = \{ min: 360, max: 620, default: 460 \}/); - assert.match(editorSource, /event\.key === "ArrowLeft" \? step : -step/); - assert.match(editorCSS, /\.side-column \{[^}]*position:\s*sticky[^}]*max-height:\s*100%[^}]*overflow-y:\s*auto/s); - assert.match(editorCSS, /@media \(max-width: 980px\)[\s\S]*?\.side-resizer \{ display: none; \}/); - assert.match(editorCSS, /\.workspace \{[^}]*--side-width, 460px/s); + assert.match(editorHtml, /id="board-info"[^>]*data-page="board"/); + assert.match(editorSource, /m5-board-spec-layout:v3/); + assert.doesNotMatch(editorHtml, /id="side-resizer"|id="view-tabs"/); assert.match(editorCSS, /\.row-edit-hint \{[^}]*opacity:\s*0[^}]*visibility:\s*hidden/s); assert.doesNotMatch(editorCSS, /\.gpio-row:hover \.row-edit-hint \{\s*display:/); assert.match(editorSource, /internalRoles: `
      [\s\S]*\$\{rowHint\}/); @@ -2795,3 +2791,349 @@ test("connector display groups validate identifiers and warn about a solitary me sample.connectors[ids[1]] = { ...sample.connectors[ids[0]], group: "paired" }; assert.equal(validateBoard(sample, context).some((item) => item.id === "W_CONN_GROUP_SINGLE"), false); }); + +test("editor reference links reject executable URLs and escape labels", async () => { + const { externalLinkHtml } = await import("../editor/html.js"); + assert.equal(externalLinkHtml("javascript:alert(1)", "open"), ""); + assert.equal(externalLinkHtml("invalid", "open"), ""); + assert.match(externalLinkHtml("https://example.com/?a=1&b=2", ""), /target="_blank" rel="noopener noreferrer"/); + assert.match(externalLinkHtml("https://example.com", ""), /<open>/); +}); + +test("editor GPIO links enumerate shared connector terminals without duplicates", async () => { + const { gpioConnectorTargets } = await import("../editor/html.js"); + const board = { pins: { 8: { roles: ["conn:a.1", "conn:a.2", "conn:b.1", "conn:a.1", "dev:test.in"] } }, connectors: { a: {}, b: {} } }; + assert.deepEqual(gpioConnectorTargets(board, 8), [{ connector: "a", position: "1" }, { connector: "a", position: "2" }, { connector: "b", position: "1" }]); +}); + +test("compact header keeps all runtime selectors and only exposes base reset for resolved data", () => { + const source = editorSource.slice(editorSource.indexOf("function renderOptions()"), editorSource.indexOf("function renderTable(")); + const panel = { innerHTML: "", title: "", querySelector: () => ({ after: () => {}, title: "" }) }; + const editBase = { hidden: false }; + const runtime = { lcd: "panel_a", epd: "panel_b" }; + let slots = [["lcd", { selected_by: "runtime", choices: { panel_a: {} } }], ["epd", { selected_by: "runtime", choices: { panel_b: {} } }]]; + let resolved = true; + const sandbox = vm.createContext({ + state: { currentId: "demo", selectedOptions: new Set(), runtimePanelOpen: true, runtimeSelections: new Map([["demo", runtime]]) }, + elements: { optionPanel: panel, editBase, modeNote: { textContent: "" } }, + currentBase: () => ({ name: "Demo", revisions: [] }), choiceSlots: () => slots, + revisionSelection: () => ({}), isResolvedMode: () => resolved, + escapeHtml: (value) => String(value ?? ""), t: (key) => key, + }); + vm.runInContext(source + "\nrenderOptions();", sandbox); + assert.match(panel.innerHTML, /data-display-runtime="lcd"/); + assert.match(panel.innerHTML, /data-display-runtime="epd"/); + assert.match(panel.innerHTML, /aria-expanded="true"/); + assert.equal(editBase.hidden, false); + assert.deepEqual(runtime, { lcd: "panel_a", epd: "panel_b" }); + slots = []; resolved = false; + vm.runInContext("renderOptions();", sandbox); + assert.doesNotMatch(panel.innerHTML, /data-runtime-toggle/); + assert.equal(editBase.hidden, true); +}); + +test("compact header retains unique action targets and uses a dialog for Guide", () => { + const ids = [...editorHtml.matchAll(/\bid="([^"]+)"/g)].map((match) => match[1]); + assert.equal(ids.length, new Set(ids).size); + assert.match(editorHtml, //); + const header = //.exec(editorHtml)[0]; + for (const id of ["board-select", "option-panel", "file-menu", "download-button", "discard-button", "save-state", "onboarding-help", "language-select"]) assert.match(header, new RegExp(`id="${id}"`)); + assert.doesNotMatch(header, / { + const { resolveEditorRoute, editorPageKey } = await import("../editor/html.js"); + const board = { connectors: { port: { type: "socket" } }, buses: { bus: {} }, devices: { dev: {} }, revisions: [{ id: "rev" }] }; + const context = { chip: { gpio_count: 5, absent: [4] }, connectorTypes: { socket: { positions: [{ id: "1" }] } }, ownerKeys: ["dev:part"] }; + assert.deepEqual(resolveEditorRoute({ kind: "connector", id: "port", position: "1" }, board, context), { kind: "connector", id: "port", position: "1" }); + assert.deepEqual(resolveEditorRoute({ kind: "connector", id: "removed" }, board, context), { kind: "connectors" }); + assert.deepEqual(resolveEditorRoute({ kind: "connector", id: "port", position: "removed" }, board, context), { kind: "connector", id: "port" }); + for (const kind of ["buses", "devices", "revisions", "owners"]) assert.deepEqual(resolveEditorRoute({ kind, id: "removed" }, board, context), { kind }); + assert.deepEqual(resolveEditorRoute({ kind: "gpio", gpio: 4 }, board, context), { kind: "gpio" }); + assert.deepEqual(resolveEditorRoute({ kind: "gpio", gpio: 5 }, board, context), { kind: "gpio" }); + assert.deepEqual(resolveEditorRoute({ kind: "gpio", gpio: 1 }, board, context), { kind: "gpio", gpio: "1" }); + assert.deepEqual(resolveEditorRoute(null, board, context), { kind: "gpio" }); +}); + +test("editor validation destinations cover non-GPIO objects", async () => { + const { editorErrorRoute, resolveEditorRoute } = await import("../editor/html.js"); + const board = { connectors: { port: { type: "socket" } }, buses: { bus: {} }, devices: { dev: {} }, revisions: [{ id: "rev" }] }; + assert.deepEqual(editorErrorRoute({ path: "/pins/2/roles" }, board), { kind: "gpio", gpio: "2" }); + assert.deepEqual(editorErrorRoute({ path: "/connectors/port/positions/1" }, board), { kind: "connector", id: "port", position: "1" }); + assert.deepEqual(editorErrorRoute({ path: "/buses/bus/signals" }, board), { kind: "buses", id: "bus" }); + assert.deepEqual(editorErrorRoute({ path: "/devices/dev/signals/sda" }, board), { kind: "devices", id: "dev" }); + assert.deepEqual(editorErrorRoute({ path: "/revisions/0/select" }, board), { kind: "revisions", id: "rev" }); + assert.deepEqual(resolveEditorRoute(editorErrorRoute({ path: "/connectors/missing" }, board), board), { kind: "connectors" }); + assert.deepEqual(editorErrorRoute({ path: "/spec/storage" }, board), { kind: "board" }); +}); + +test("editor navigation shows pages before scrolling, preserves data and resets connector context on board changes", async () => { + const { resolveEditorRoute, editorPageKey } = await import("../editor/html.js"); + let shown = { id: "one", pins: {}, connectors: { port: { type: "socket" } } }; + const before = JSON.stringify(shown); + const entry = { expandedGPIO: null }; + const state = { currentId: "one", page: null, previousPage: null, routeBoardId: null, pageScroll: new Map(), connectorId: null, connectorPosition: null, layout: {}, undo: { marker: true } }; + const steps = [], frames = []; + const node = { dataset: { connectorPosition: "1" }, closest: () => ({ dataset: { connectorId: "port" } }), scrollIntoView: () => steps.push("scroll"), focus: () => steps.push("focus") }; + const sandbox = vm.createContext({ + state, shownBoard: () => shown, routeContext: () => ({ ownerKeys: ["device:a"], chip: { gpio_count: 5, absent: [] }, connectorTypes: { socket: { positions: [{ id: "1" }, { id: "2" }] } } }), resolveEditorRoute, editorPageKey, + currentPageScroll: () => ({ top: 70, left: 0, gpioTop: 100, connectorTop: 80, connectorLeft: 45 }), saveLayoutState: () => {}, + currentEntry: () => entry, currentBase: () => shown, writeDraft: () => {}, validationFor: () => [], + renderTable: () => steps.push("table"), renderConnectors: () => steps.push("connector"), renderOwnerEditor: () => {}, renderNavigation: () => {}, + applyPageVisibility: () => steps.push("visible"), requestAnimationFrame: (fn) => frames.push(fn), sizeConnectorLanes: () => {}, assignConnectorLanes: () => ({ lanes: [] }), prefersReducedMotion: () => true, + document: { getElementById: () => node }, elements: { pageHost: { scrollTop: 0, scrollLeft: 0 }, tableBody: { closest: () => ({ scrollTop: 0 }) }, connectorPinout: { querySelectorAll: () => [node] } }, + renderAll: () => { throw new Error("navigation must not redraw all pages"); }, + }); + const source = editorSource.slice(editorSource.indexOf("function navigateTo("), editorSource.indexOf("function renderNavigation(")); + vm.runInContext(source + '\nnavigateTo({kind:"gpio",gpio:1});', sandbox); + assert.equal(steps.includes("scroll"), false); + frames.shift()(); + assert.ok(steps.indexOf("visible") < steps.indexOf("scroll")); + vm.runInContext('navigateTo({kind:"connector",id:"port",position:"1"});', sandbox); + frames.shift()(); + assert.equal(state.previousPage.route.kind, "gpio"); + vm.runInContext('navigateTo({kind:"connector",id:"port",position:"2"});', sandbox); + frames.shift()(); + assert.equal(state.previousPage.route.kind, "gpio", "terminal selection does not replace page history"); + steps.length = 0; + vm.runInContext('navigateTo({kind:"gpio",gpio:1}); navigateTo({kind:"connector",id:"port",position:"1"});', sandbox); + frames.shift()(); + assert.equal(steps.includes("scroll"), false, "a superseded navigation frame must not scroll or focus a hidden page"); + frames.shift()(); + assert.equal(steps.includes("scroll"), true); + vm.runInContext('navigateTo({kind:"connector",id:"port"},{restoreScroll:{top:70,left:0,connectorTop:80,connectorLeft:45}});', sandbox); + frames.shift()(); + assert.equal(sandbox.elements.connectorPinout.scrollTop, 80); + assert.equal(sandbox.elements.connectorPinout.scrollLeft, 45); + vm.runInContext('navigateTo({kind:"owners"});', sandbox); frames.shift()(); + const previousPage = state.previousPage; + vm.runInContext('navigateTo({kind:"owners",id:"device:a"});', sandbox); frames.shift()(); + assert.equal(state.previousPage, previousPage, "owner tabs must preserve the previous page"); + assert.equal(JSON.stringify(shown), before); + assert.deepEqual(state.undo, { marker: true }); + shown = { id: "two", pins: {}, connectors: { port: { type: "socket" } } }; state.currentId = "two"; + vm.runInContext('navigateTo({kind:"connector",id:"port"});', sandbox); + assert.equal(state.page.kind, "connectors", "same-named connector is not carried into another board"); + assert.equal(state.previousPage, null); +}); + +test("editor pages stay mounted with unique IDs and centralized route writes", () => { + const ids = [...editorHtml.matchAll(/\bid="([^"]+)"/g)].map((match) => match[1]); + assert.equal(ids.length, new Set(ids).size); + assert.match(editorHtml, /