diff --git a/CHANGELOG.md b/CHANGELOG.md index 46ffb6b..e81f7e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to the Simulink Data Explorer extension. +## [Unreleased] + +- You can now add variables, strings and Simulink objects to MAT-files from the Add gallery. +- Bus elements in MAT-files can be added and removed. +- Properties of Simulink objects in MAT-files can be edited in the Properties panel. + ## [1.43.0] — 2026-10-21 - MAT-files can now be edited and saved. diff --git a/package-lock.json b/package-lock.json index 0b7faf6..203f221 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "BSD-3-Clause", "dependencies": { "@lit/context": "^1.1.6", - "data-explorer-core": "github:mathworks/data-explorer-core#v1.44.0", + "data-explorer-core": "github:mathworks/data-explorer-core#v1.45.0", "fast-xml-parser": "^5.8.0", "fflate": "^0.8.3", "highlight.js": "^11.11.1", @@ -3184,8 +3184,8 @@ } }, "node_modules/data-explorer-core": { - "version": "1.44.0", - "resolved": "git+ssh://git@github.com/mathworks/data-explorer-core.git#8af6456533f9cd0d5861b55909d92fc19871b4fc", + "version": "1.45.0", + "resolved": "git+ssh://git@github.com/mathworks/data-explorer-core.git#58b7289371e7fa362efda99cc1e2ef5783949865", "license": "BSD-3-Clause", "dependencies": { "fast-xml-parser": "^5.8.0", diff --git a/package.json b/package.json index d07de6d..ec65092 100644 --- a/package.json +++ b/package.json @@ -243,7 +243,7 @@ }, "dependencies": { "@lit/context": "^1.1.6", - "data-explorer-core": "github:mathworks/data-explorer-core#v1.44.0", + "data-explorer-core": "github:mathworks/data-explorer-core#v1.45.0", "fast-xml-parser": "^5.8.0", "fflate": "^0.8.3", "highlight.js": "^11.11.1", diff --git a/src/common/addCatalog.ts b/src/common/addCatalog.ts index ab56092..889b945 100644 --- a/src/common/addCatalog.ts +++ b/src/common/addCatalog.ts @@ -216,6 +216,45 @@ export const OMITTED: readonly { className: string; section: string; why: string }, ]; +/** + * Tiles only a MAT-file's gallery offers, each with the category it joins there. A `string` is a + * MATLAB value a MAT-file holds and a dictionary does not (no section admits one), so it has no + * place in ADD_CATALOG, which is every dictionary's gallery as it is. + */ +export const MAT_ONLY_TILES: readonly { category: string; after: string; tile: GalleryTile }[] = [ + { + category: 'Parameters', + after: 'MatlabStruct', + tile: { label: 'MATLAB String', className: 'string', section: 'design', iconId: 'wsString' }, + }, +]; + +/** + * A MAT-file's gallery: the tiles of `classes`, the classes core says the file can take a new + * variable of (MatNode.getAllowedTypes), and no other. A MAT-file has no sections, so a class is + * offered once, by its Design Data tile; the Architectural Data tiles of the same classes, and + * every class core does not list (an enumeration, the variants, a configuration set), are left + * out, and so is a category none of whose tiles is left. The `.mat`-only tiles join where + * MAT_ONLY_TILES says. Every category left is Design Data's alone, so no tile is badged. + */ +export function matCatalog(classes: readonly string[]): GalleryCategory[] { + const offered = new Set(classes); + const out: GalleryCategory[] = []; + for (const c of ADD_CATALOG) { + const tiles: GalleryTile[] = []; + for (const t of c.tiles) { + if (t.section === 'design' && offered.has(t.className)) tiles.push(t); + for (const extra of MAT_ONLY_TILES) { + if (extra.category === c.title && extra.after === t.className && t.section === 'design' && offered.has(extra.tile.className)) { + tiles.push(extra.tile); + } + } + } + if (tiles.length > 0) out.push(category(c.title, tiles)); + } + return out; +} + /** Every tile, flat, in display order — the order a keyboard walk follows. */ export function allTiles(): GalleryTile[] { return ADD_CATALOG.flatMap((c) => c.tiles as GalleryTile[]); diff --git a/src/common/protocol.ts b/src/common/protocol.ts index 9b9f262..d5cfbf9 100644 --- a/src/common/protocol.ts +++ b/src/common/protocol.ts @@ -69,11 +69,11 @@ export interface SetRowsMessage { */ hasTextView?: boolean; /** - * What the Add button (and its key) creates. Absent: an entry of the class the user picks - * in the gallery, a dictionary's. `'variable'`: one plain variable, at once, with its name - * editor open, a MAT-file's, whose Add (core's) makes `var` = 0 and nothing else. + * What the Add gallery offers. Absent: every tile, a dictionary's, each naming its section. + * `{classes}`: a MAT-file's, the tiles of the classes core says the file can take a new + * variable of (MatNode.getAllowedTypes), in no section. */ - add?: 'variable'; + add?: { classes: readonly string[] }; /** * Whether rows can be copied, cut, pasted and dragged. Absent is true. False for a * MAT-file, whose variables have no dictionary entry form to travel as: the menu leaves @@ -231,6 +231,15 @@ export interface ValidationErrorMessage { export interface ShowPropsMessage { type: 'showProps'; groups: any[]; + // The node the groups are of, which an edit made in the panel names (PropertyEditMessage). + nodeId?: string; +} + +/** The panel's last edit was refused: why, in core's words. Follows the showProps that reverts it. */ +export interface PropertyRefusedMessage { + type: 'propertyRefused'; + propName: string; + reason: string; } /** Clear the inspector (nothing selected). */ @@ -351,7 +360,7 @@ export type HostToTableMessage = | ChildRowsMessage; /** Every message the property-inspector webview can receive from the host. */ -export type HostToPropsMessage = ShowPropsMessage | EmptyMessage | MatrixCellsMessage; +export type HostToPropsMessage = ShowPropsMessage | EmptyMessage | MatrixCellsMessage | PropertyRefusedMessage; // --- Webview -> Host (table view -> providers) -------------------------------- @@ -556,4 +565,16 @@ export type ProjectToHostMessage = ReadyMessage | OpenFileMessage; // same thing from either webview, and the host answers both with the same closure. The // same goes for `RequestMatrixMessage`: the inspector's Value row opens the same Variable // Editor the table's does, and it is answered by the same function. -export type PropsToHostMessage = ReadyMessage | NavigateMessage | RequestMatrixMessage; +/** + * An edit committed in the Property Inspector: property `propName` (core's key) of node + * `nodeId`, the node the panel shows. Taken only where the host gave the panel an edit channel. + */ +export interface PropertyEditMessage { + type: 'editProperty'; + nodeId: string; + propName: string; + newValue: string; + oldValue: string; +} + +export type PropsToHostMessage = ReadyMessage | NavigateMessage | RequestMatrixMessage | PropertyEditMessage; diff --git a/src/extension.ts b/src/extension.ts index 71f3885..bd84d3a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,7 +1,7 @@ // Copyright 2026 The MathWorks, Inc. import * as vscode from 'vscode'; import { SectionsTreeProvider } from './host/SectionsTreeProvider.js'; -import { PropertiesViewProvider } from './host/PropertiesViewProvider.js'; +import { PropertiesViewProvider, type PropertyEditHandler } from './host/PropertiesViewProvider.js'; import { BinaryEditorProvider } from './host/BinaryEditorProvider.js'; import { SlddTextEditorProvider } from './host/SlddTextEditorProvider.js'; import { BinarySlddEditorProvider } from './host/BinarySlddEditorProvider.js'; @@ -96,8 +96,10 @@ export function activate(context: vscode.ExtensionContext): void { // Section rows (id `section:`) have no properties, so clear the PI. // The selection the inspector was last asked to show, so a view whose tree is replaced (a // MAT-file reverted or reloaded from disk) can have its node shown again from the new tree. + // `editor` is the edit channel of a document whose host takes edits made in the inspector (a + // MAT-file's); the inspector is read-only for every other. let lastSelection: { uriString: string; rowIds: string[] } | undefined; - const showSelection = (uriString: string, rowIds: string[]): void => { + const showSelection = (uriString: string, rowIds: string[], editor?: PropertyEditHandler): void => { lastSelection = { uriString, rowIds }; const first = rowIds && rowIds.length > 0 ? rowIds[0] : undefined; if (!first || isSectionRowId(first)) { @@ -105,7 +107,7 @@ export function activate(context: vscode.ExtensionContext): void { return; } const node = findNode(uriString, first); - if (node) piProvider.showNode(node, uriString); + if (node) piProvider.showNode(node, uriString, editor); else piProvider.clear(); }; @@ -139,12 +141,20 @@ export function activate(context: vscode.ExtensionContext): void { // A MAT-file opens in this writable provider (edits through core's session, save // through core's writer). Shares the selection→PI and navigate wiring. const matProvider = new MatEditorProvider(context); - matProvider.onSelect = (uriString, rowIds) => showSelection(uriString, rowIds); + // Its inspector takes edits: each is a gesture of the MAT-file editor's, after which the node is + // shown again (a mode edit of a NumericType derives its other properties), with the reason + // core gave where it refused. + const matInspector = (uriString: string): PropertyEditHandler => (nodeId, propName, newValue, oldValue) => { + const result = matProvider.editFromInspector(uriString, nodeId, propName, newValue, oldValue); + showSelection(uriString, [result.nodeId ?? nodeId], matInspector(uriString)); + if (result.refused) piProvider.refuse(propName, result.refused); + }; + matProvider.onSelect = (uriString, rowIds) => showSelection(uriString, rowIds, matInspector(uriString)); matProvider.onNavigate = navigate; // Node ids are paths, so the node the inspector shows is found again in the new tree, or the // inspector is cleared if it is gone; it never goes on showing a node of the tree that left. matProvider.onReplaced = (uriString) => { - if (lastSelection?.uriString === uriString) showSelection(uriString, lastSelection.rowIds); + if (lastSelection?.uriString === uriString) showSelection(uriString, lastSelection.rowIds, matInspector(uriString)); }; // Health badges/colors on tree rows (missing/cycle/modified). The tree diff --git a/src/host/MatEditorProvider.ts b/src/host/MatEditorProvider.ts index 0c00d6f..6f5493a 100644 --- a/src/host/MatEditorProvider.ts +++ b/src/host/MatEditorProvider.ts @@ -57,6 +57,7 @@ import { binaryEditSrcId } from '../common/srcId.js'; import { basename } from '../common/pathUtil.js'; import { toArrayBuffer } from '../common/bytes.js'; import type { TableToHostMessage } from '../common/protocol.js'; +import { matCatalog } from '../common/addCatalog.js'; /** One read of the file behind a document, with the stamp it was read under (stat first). */ interface DiskRead { @@ -479,6 +480,41 @@ function backupSource(id: string): { uri: vscode.Uri; stamp: DiskStamp | undefin return { uri: vscode.Uri.parse(id), stamp: undefined }; } +/** + * The classes `mat` can take a new variable of, as core lists them (MatNode.getAllowedTypes): a + * plain variable and a struct, and the classes of MATLAB's own default objects where the file's + * objects can be added to. A core without the list adds a plain variable only, as it always has. + */ +/** What an edit from the Property Inspector came to: the node's id after it, or why it was refused. */ +export interface InspectorResult { + nodeId?: string; + refused?: string; +} +type InspectorEdit = (nodeId: string, propName: string, newValue: string, oldValue: string) => InspectorResult; + +function allowedClasses(mat: any): readonly string[] { + if (typeof mat?.getAllowedTypes !== 'function') return ['MatlabVariable']; + const classes = mat.getAllowedTypes(); + return Array.isArray(classes) ? classes.filter((c: unknown): c is string => typeof c === 'string') : []; +} + +/** The label the gallery's tile of `className` shows ('MATLAB Structure'), or the class itself. */ +function tileLabelOf(className: string): string { + return matCatalog([className]).flatMap((c) => c.tiles).find((t) => t.className === className)?.label ?? className; +} + +/** The label the inspector shows property `key` of `node` by, or the key where it shows none. */ +function inspectorLabelOf(node: any, key: string): string { + try { + const prop = node?.toPIObject?.()?.propertySheet?.properties?.find((p: any) => p.name === key); + return typeof prop?.displayName === 'string' && prop.displayName !== '' ? prop.displayName : key; + } catch { + return key; + } +} + +const sameList = (a: readonly string[], b: readonly string[]): boolean => a.length === b.length && a.every((x, i) => x === b[i]); + export class MatEditorProvider implements vscode.CustomEditorProvider { public static readonly viewType = 'dataExplorer.matView'; @@ -490,6 +526,9 @@ export class MatEditorProvider implements vscode.CustomEditorProvider void; + // Each open view's edit of a node from the Property Inspector, by document (editFromInspector). + private readonly inspectorEdits = new Map(); + private readonly _onDidChangeCustomDocument = new vscode.EventEmitter>(); readonly onDidChangeCustomDocument = this._onDidChangeCustomDocument.event; @@ -559,6 +598,12 @@ export class MatEditorProvider implements vscode.CustomEditorProvider => { let mat: any; try { @@ -592,8 +637,8 @@ export class MatEditorProvider implements vscode.CustomEditorProvider { + const node = findNode(nodeId); + if (!node) return { refused: 'Could not locate the item in the model.' }; + // What the user knows the property as: the inspector's label for it ('Word Length'), not its key. + const label = inspectorLabelOf(node, propName); + let result: unknown; + const observed = gesture('Edit ' + label, (mat) => { + DataModel.setActive(mat, node); + result = DataModel.editProperty(node.id, propName, newValue, oldValue); + }); + if (!observed) return { refused: `${name} cannot be edited.` }; + const refused = result as { error?: boolean; reason?: string } | boolean; + if (refused && typeof refused === 'object' && refused.error) { + for (let v = node; v; v = v.parent) { + if (v.parent === document.model()) { + repaintVariables([v]); + break; + } + } + return { refused: document.describe(refused.reason) }; + } + if (refused === false) return { refused: `Could not edit ${label} of this item.` }; + if (propName === 'Name') webview.postMessage({ type: 'selectRows', rowIds: [node.id] }); + return { nodeId: node.id }; + }; + // Delete the rows the gesture named, as ONE edit: the variables in one batch (one step), and // each row under a variable on its own (one step each). Nodes are resolved first and each is // deleted by the id it has at that moment: an element's id is its index, which the removal of @@ -742,17 +817,24 @@ export class MatEditorProvider implements vscode.CustomEditorProvider { + // Add one variable of the class the gallery's tile names: core's addEntry, which names it (the + // class's own default name, `var` for a plain variable, with a counter where it is taken) and + // makes MATLAB's default value of it — the double 0, a struct of no field, or MATLAB's own + // default object of a Simulink class or a `string`. Then its name editor, unless the gallery is + // pinned. An add core refuses (a class the file takes no new variable of, a file no save of + // which can be written, objects no new one can join) adds nothing, and says core's reason. + const applyAddEntry = (msg: { className?: string; rename: boolean }) => { + const className = msg.className || 'MatlabVariable'; let added: any = null; + let refusal: string | null = null; const observed = gesture('Add', (mat) => { DataModel.setActiveContext(mat); - added = DataModel.addEntry('', 'MatlabVariable'); + added = DataModel.addEntry('', className); + if (!added) refusal = (DataModel as any).addEntryRefusal?.() ?? null; }); if (!observed) return; if (!added) { - webview.postMessage({ type: 'error', message: `Could not add a variable to ${name}.` }); + webview.postMessage({ type: 'error', message: refusal ? document.describe(refusal) : `Could not add a ${tileLabelOf(className)} to ${name}.` }); return; } webview.postMessage({ type: 'selectRows', rowIds: [added.id] }); @@ -819,8 +901,11 @@ export class MatEditorProvider implements vscode.CustomEditorProvider { document.views.delete(view); + if (this.inspectorEdits.get(uriString) === applyInspectorEdit) this.inspectorEdits.delete(uriString); unregisterAddGalleryView(webviewPanel); sub.dispose(); navSub.dispose(); @@ -828,6 +913,18 @@ export class MatEditorProvider implements vscode.CustomEditorProvider { diff --git a/src/host/PropertiesViewProvider.ts b/src/host/PropertiesViewProvider.ts index f60ad66..a9d74e7 100644 --- a/src/host/PropertiesViewProvider.ts +++ b/src/host/PropertiesViewProvider.ts @@ -5,11 +5,20 @@ import { matrixCellsMessage } from './matrixRequest.js'; import { renderWebviewHtml } from './webviewHtml.js'; import type { PropsToHostMessage } from '../common/protocol.js'; +/** + * Where an edit made in the panel goes: the host of the node's document, which makes it a + * gesture of its own (one undo step, core's refusal reported back through `refuse`). Handed to + * showNode by a host that takes such edits; the panel is read-only for the others. + */ +export type PropertyEditHandler = (nodeId: string, propName: string, newValue: string, oldValue: string) => void; + export class PropertiesViewProvider implements vscode.WebviewViewProvider { public static readonly viewType = 'dataExplorer.properties'; private view: vscode.WebviewView | null = null; private ready = false; - private pending: any[] | null = null; // pending groups + private pending: { groups: any[]; nodeId?: string } | null = null; // pending groups + // Where an edit of the node on display goes, or null: the panel is then read-only. + private editor: PropertyEditHandler | null = null; // The node currently on display, kept for `requestMatrix` only: its Value row may // carry a MatrixDescriptor, and the cells behind one are fetched when the panel // opens rather than stamped onto the row (see matrixRequest.ts for why). @@ -37,7 +46,7 @@ export class PropertiesViewProvider implements vscode.WebviewViewProvider { if (msg?.type === 'ready') { this.ready = true; if (this.pending) { - view.webview.postMessage({ type: 'showProps', groups: this.pending }); + view.webview.postMessage({ type: 'showProps', ...this.pending }); this.pending = null; } } else if (msg?.type === 'navigate') { @@ -46,6 +55,12 @@ export class PropertiesViewProvider implements vscode.WebviewViewProvider { this.onNavigate?.(msg.target, this.shownFrom); } else if (msg?.type === 'requestMatrix') { this.answerMatrix(view.webview, msg.nodeId); + } else if (msg?.type === 'editProperty') { + // Only an edit of the node on display, and only where its host takes one: a stale + // edit (the selection moved while it was typed) or one no host takes changes nothing. + if (this.editor && this.shown && this.shown.id === msg.nodeId) { + this.editor(msg.nodeId, msg.propName, msg.newValue, msg.oldValue); + } } }); view.onDidDispose(() => { @@ -53,24 +68,39 @@ export class PropertiesViewProvider implements vscode.WebviewViewProvider { this.ready = false; this.shown = null; this.shownFrom = undefined; + this.editor = null; }); view.webview.html = this.getHtml(view.webview, distRoot); } - showNode(node: any, fromUri?: string): void { - const groups = buildPropertyGroups(node); + /** + * Show `node`'s properties. With `editor`, the rows core says are editable are, and an edit + * of one goes to it; without, the panel is read-only. + */ + showNode(node: any, fromUri?: string, editor?: PropertyEditHandler): void { + const groups = buildPropertyGroups(node, { editable: !!editor }); this.shown = node; this.shownFrom = fromUri; + this.editor = editor ?? null; + const payload = { groups, ...(typeof node?.id === 'string' ? { nodeId: node.id } : {}) }; if (this.view && this.ready) { - this.view.webview.postMessage({ type: 'showProps', groups }); + this.view.webview.postMessage({ type: 'showProps', ...payload }); } else { - this.pending = groups; + this.pending = payload; + } + } + + /** Say why the panel's last edit was refused. Sent after the showProps that put the row back. */ + refuse(propName: string, reason: string): void { + if (this.view && this.ready) { + this.view.webview.postMessage({ type: 'propertyRefused', propName, reason }); } } clear(): void { this.shown = null; this.shownFrom = undefined; + this.editor = null; if (this.view && this.ready) { this.view.webview.postMessage({ type: 'empty' }); } else { diff --git a/src/host/piBuilder.ts b/src/host/piBuilder.ts index 8346ebe..1bcc468 100644 --- a/src/host/piBuilder.ts +++ b/src/host/piBuilder.ts @@ -5,6 +5,9 @@ export interface PIPropertyRow { name: string; value: string; editable: boolean; + // On an editable row: the property's key, as core's editProperty takes it ('wordLength'), + // where `name` is the label the panel shows ('Word Length'). + key?: string; type: 'text' | 'link'; linkTarget?: string; // A link on the VALUE, set by core on the Data Type property when the type names another @@ -24,9 +27,17 @@ export interface PIPropertyGroup { properties: PIPropertyRow[]; } +export interface PIBuildOptions { + // Whether the panel can write an edit back: its host handed it an edit channel for this + // node's document (PropertiesViewProvider.showNode). Then a row is editable exactly where core + // says it is (toPIObject's `editable`), and carries the key core's editProperty takes. + // Without one every row is read-only, as the dictionary and model views' panels are. + editable?: boolean; +} + // Replicates the vendored two-step transform (PIController.convertToPIObject + // LitRenderer.setPIContent). graphture/usedBy extras are intentionally omitted. -export function buildPropertyGroups(node: any): PIPropertyGroup[] { +export function buildPropertyGroups(node: any, options: PIBuildOptions = {}): PIPropertyGroup[] { if (!node || typeof node.toPIObject !== 'function') return []; const raw = node.toPIObject(); if (!raw || !raw.propertySheet) return []; @@ -45,10 +56,12 @@ export function buildPropertyGroups(node: any): PIPropertyGroup[] { // folded into `link`: that one would make this a `type: 'link'` row, whose template // anchors the property name and mutes the value. const valueLink = (propDef as any).valueLink; + const editable = !!options.editable && propDef.editable === true && !link; const row: PIPropertyRow = { name: propDef.displayName || propDef.name, value: String(obj[propDef.name] ?? ''), - editable: false, // read-only V1 + editable, + ...(editable ? { key: propDef.name } : {}), type: link ? 'link' : 'text', linkTarget: link || undefined, ...(typeof valueLink === 'string' && valueLink !== '' ? { valueLink } : {}), diff --git a/src/webview/components/dex-add-gallery.ts b/src/webview/components/dex-add-gallery.ts index 44c656a..5e5ae06 100644 --- a/src/webview/components/dex-add-gallery.ts +++ b/src/webview/components/dex-add-gallery.ts @@ -18,7 +18,7 @@ import { LitElement, html, css } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import './dex-icon.js'; -import { ADD_CATALOG, SECTION_LABEL, badgeOf, labelLinesOf } from '../../common/addCatalog.js'; +import { ADD_CATALOG, SECTION_LABEL, badgeOf, labelLinesOf, matCatalog } from '../../common/addCatalog.js'; import type { GalleryCategory, GalleryTile } from '../../common/addCatalog.js'; /** @@ -448,6 +448,13 @@ export class DexAddGallery extends LitElement { */ @property({ type: Boolean }) pinned = false; + /** + * The classes a MAT-file can take a new variable of, as its host says core lists them, or null + * for a dictionary's gallery. Given, the tiles are those classes' (matCatalog): a MAT-file has + * no sections, so no tile names one, and nothing is offered that the file cannot take. + */ + @property({ attribute: false }) classes: readonly string[] | null = null; + /** * Which tile is the grid's one tab stop — the roving tabindex. * @@ -569,7 +576,7 @@ export class DexAddGallery extends LitElement { private _onTile(tile: GalleryTile): void { this.dispatchEvent( new CustomEvent('dex-add-tile', { - detail: { className: tile.className, section: tile.section, label: tile.label }, + detail: { className: tile.className, section: this.classes ? '' : tile.section, label: tile.label }, bubbles: true, composed: true, }), @@ -624,7 +631,7 @@ export class DexAddGallery extends LitElement { // disagree with the DOM order it names. let index = 0; return html` -
+
- ${ADD_CATALOG.map( + ${(this.classes ? matCatalog(this.classes) : ADD_CATALOG).map( (category) => html`
${category.title} @@ -654,11 +661,12 @@ export class DexAddGallery extends LitElement { } private _renderTile(category: GalleryCategory, tile: GalleryTile, index: number) { - const badge = badgeOf(category, tile); + // A MAT-file's tiles name no destination: the file is the only one. + const badge = this.classes ? null : badgeOf(category, tile); // The accessible name has to carry the destination whether or not a badge does: // a screen reader gets no heading context from a button inside a group, and the // destination is the one thing about a tile that is not in its label. - const description = `Add ${tile.label} to ${SECTION_LABEL[tile.section]}`; + const description = this.classes ? `Add ${tile.label}` : `Add ${tile.label} to ${SECTION_LABEL[tile.section]}`; return html`