From 4eaf657374afd609ab3b3ae086d8000f4336f369 Mon Sep 17 00:00:00 2001 From: ainyan03 <205502311+ainyan03@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:48:47 +0000 Subject: [PATCH] Rework the board spec editor around a page list and readable connector pinouts Header: merge the title, toolbar, revision options, mode bar and view tabs into one row. Runtime choices open from a compact condition panel, New/Duplicate/Remove move into an Open menu, the first-run guide becomes a dialog and JSON files can be dropped anywhere. "Edit raw data" is renamed to describe what it does: return to base data by clearing the resolved selection. Pages: a list on the left (board information, GPIO, each connector or connector group, buses, devices, accessories, revisions, pin owners, validation) selects the page shown at full height. All pages stay rendered and only visibility changes, so links into hidden pages resolve. Every navigation goes through one function with a one-step back history and scroll restoration, and falls back to the category when an object disappears after switching boards or importing. Editing a role does not change the page. Validation errors open the GPIO row when they resolve to one, otherwise the affected object's page. Layout state uses a new storage key. Connector pages: show the connector note, verification, type view, layout status and clickable official links next to the pinout. Sockets that share a connector group are shown side by side with headers, references and tables aligned in shared grid rows, while names, first-terminal marks, notes and editing stay per socket. Role chips and power terminals use the same owner colors as the GPIO table, and wire colors get their own column only for connector types that define them. Each endpoint sits beside its terminal number; terminal and endpoint columns are sized to their content and functions take the remaining width, mirrored around the center of two-row connectors. The diagram view is removed because tables cover every connector type in the catalog, and the undo button moves into the page heading, shown only when there is something to undo. A GPIO shared by several connector positions lists all of them, and hovering only highlights; moving between a terminal and its GPIO row needs an explicit click or key. --- extras/board_spec/dist/board_spec_editor.html | 1116 ++++++++++------- extras/board_spec/editor/editor.css | 199 ++- extras/board_spec/editor/editor.js | 644 +++++----- extras/board_spec/editor/html.js | 77 ++ extras/board_spec/editor/index.html | 183 ++- extras/board_spec/editor/role_colors.js | 17 +- extras/board_spec/test/board_spec.test.js | 358 +++++- 7 files changed, 1664 insertions(+), 930 deletions(-) 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, /