diff --git a/index.html b/index.html index bf2724e..781dabc 100644 --- a/index.html +++ b/index.html @@ -393,6 +393,7 @@

Select USB Host Folder

+

More network devices

@@ -434,6 +435,7 @@

More network devices

diff --git a/js/common/circuitpython_highlight.js b/js/common/circuitpython_highlight.js new file mode 100644 index 0000000..8b51313 --- /dev/null +++ b/js/common/circuitpython_highlight.js @@ -0,0 +1,221 @@ +// CircuitPython syntax highlighting overlay for CodeMirror 6. +// +// CodeMirror 6 dropped the simple `extra_keywords` mechanism that CM5 had, +// so instead of forking @codemirror/lang-python we layer extra decorations +// on top of the existing Python syntax tree. We walk the tree inside the +// viewport, find identifier nodes whose text matches a CircuitPython name, +// and tag them with a CSS class that the theme can style. + +import { ViewPlugin, Decoration } from "@codemirror/view"; +import { syntaxTree } from "@codemirror/language"; +import { Prec } from "@codemirror/state"; + +// Core/built-in CircuitPython modules. These are the identifiers that show +// up in `import foo` / `from foo import ...` inside CircuitPython code. +// +// Sourced from the upstream `shared-bindings/` directory in +// adafruit/circuitpython, plus port-specific bindings that are widely used +// (espidf/espnow/espulp on ESP, picodvi/rp2pio on RP2). Standard-Python +// modules that CircuitPython also exposes (math, os, time, random, struct, +// hashlib, ipaddress, locale, __future__) are intentionally omitted — they +// aren't CircuitPython-specific and highlighting them as such would be +// noisy in regular Python code shown in the editor. +// +// Underscore-prefixed internal bindings (_bleio, _eve, _pew, _pixelmap, +// _stage) are also omitted; users access those via the corresponding +// `adafruit_*` libraries which are matched by the prefix wildcard below. +// +// Third-party Adafruit libraries are matched by the `adafruit_` prefix +// instead of being listed individually, and community-bundle libraries by +// the `circuitpython_` prefix, so this set only needs updating when a new +// shared binding lands upstream. +const CIRCUITPYTHON_CORE_MODULES = new Set([ + "aesio", + "alarm", + "analogbufio", + "analogio", + "atexit", + "audiobusio", + "audiocore", + "audiodelays", + "audiofilters", + "audiofreeverb", + "audioio", + "audiomixer", + "audiomp3", + "audiopwmio", + "audiospeed", + "aurora_epaper", + "bitbangio", + "bitmapfilter", + "bitmaptools", + "bitops", + "board", + "busdisplay", + "busio", + "camera", + "canio", + "codeop", + "countio", + "digitalio", + "displayio", + "dotclockframebuffer", + "dualbank", + "epaperdisplay", + "espidf", + "espnow", + "espulp", + "floppyio", + "fontio", + "fourwire", + "framebufferio", + "frequencyio", + "getpass", + "gifio", + "gnss", + "i2cdisplaybus", + "i2cioexpander", + "i2ctarget", + "imagecapture", + "is31fl3741", + "jpegio", + "keypad", + "keypad_demux", + "lvfontio", + "max3421e", + "mcp4822", + "mdns", + "memorymap", + "memorymonitor", + "microcontroller", + "mipidsi", + "msgpack", + "neopixel_write", + "nvm", + "onewireio", + "paralleldisplaybus", + "picodvi", + "ps2io", + "pulseio", + "pwmio", + "qrio", + "qspibus", + "rainbowio", + "rclcpy", + "rgbmatrix", + "rotaryio", + "rp2pio", + "rtc", + "sdcardio", + "sdioio", + "sharpdisplay", + "socketpool", + "spitarget", + "ssl", + "storage", + "supervisor", + "synthio", + "terminalio", + "tilepalettemapper", + "touchio", + "traceback", + "uheap", + "ulab", + "usb", + "usb_cdc", + "usb_hid", + "usb_host", + "usb_midi", + "usb_video", + "ustack", + "vectorio", + "warnings", + "watchdog", + "wifi", + "zlib", + + // Early Adafruit-maintained libraries that predate the `adafruit_` + // naming convention and shipped without a prefix. Listed explicitly + // because the prefix wildcard below can't catch them. + "neopixel", + "simpleio", +]); + +// Returns true when `name` is a CircuitPython module worth highlighting. +// Wildcard-matches anything starting with `adafruit_` (Adafruit-maintained +// libraries) or `circuitpython_` (community bundle libraries) so new +// libraries light up automatically without touching this file. Both +// prefixes are distinctive enough that false positives against ordinary +// Python code are essentially nil. +function isCircuitPythonModule(name) { + if (CIRCUITPYTHON_CORE_MODULES.has(name)) return true; + if (name.startsWith("adafruit_") && name.length > "adafruit_".length) { + return true; + } + if ( + name.startsWith("circuitpython_") && + name.length > "circuitpython_".length + ) { + return true; + } + return false; +} + +const moduleMark = Decoration.mark({ class: "tok-cp-module" }); + +// Build the decoration set for the part of the document currently visible. +// Walking only visible ranges keeps this cheap on big files. +function buildDecorations(view) { + const builder = []; + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter(node) { + // We care about identifier-like leaves only. Lezer Python emits + // `VariableName` for bare identifiers (including module names + // in `import foo` and `from foo import ...`). Module names + // accessed as attributes (e.g. `adafruit_io.MQTT`) come in as + // `VariableName` for the leftmost part, then `PropertyName` + // children — we only mark the root reference. + if (node.name !== "VariableName") return; + const text = view.state.doc.sliceString(node.from, node.to); + if (isCircuitPythonModule(text)) { + builder.push(moduleMark.range(node.from, node.to)); + } + }, + }); + } + // Decoration ranges must be sorted by `from`, which they already are + // because we iterate the tree in document order. + return Decoration.set(builder); +} + +// ViewPlugin keeps decorations in sync with viewport / document changes. +const circuitpythonHighlightPlugin = ViewPlugin.fromClass( + class { + constructor(view) { + this.decorations = buildDecorations(view); + } + update(update) { + if ( + update.docChanged || + update.viewportChanged || + syntaxTree(update.startState) !== syntaxTree(update.state) + ) { + this.decorations = buildDecorations(update.view); + } + } + }, + { + decorations: (v) => v.decorations, + }, +); + +// Wrap the plugin with Prec.highest so its decoration nests inside the +// classHighlighter span. CodeMirror renders overlapping mark decorations +// as nested spans where higher-precedence decorations end up closer to +// the text. The inner span’s `color` is what the user sees, so making +// `tok-cp-module` the inner class is what lets our pink override the +// underlying `tok-variableName` blue without resorting to !important. +export const circuitpythonHighlight = Prec.highest(circuitpythonHighlightPlugin); diff --git a/js/common/dialogs.js b/js/common/dialogs.js index 4c0295c..42e2f7d 100644 --- a/js/common/dialogs.js +++ b/js/common/dialogs.js @@ -1,4 +1,5 @@ import {sleep, isIp, switchDevice} from './utilities.js'; +import {renderFirmwareSuggestions} from './firmware-check.js'; import * as focusTrap from 'focus-trap'; const SELECTOR_CLOSE_BUTTON = ".popup-modal__close"; @@ -357,6 +358,10 @@ class DiscoveryModal extends GenericModal { this._currentModal.querySelector("#mcuname").textContent = deviceInfo.mcu_name; this._currentModal.querySelector("#boardid").textContent = deviceInfo.board_id; this._currentModal.querySelector("#uid").textContent = deviceInfo.uid; + const updateContainer = this._currentModal.querySelector("#firmware-update"); + if (updateContainer) { + renderFirmwareSuggestions(updateContainer, deviceInfo); + } } async _refreshDevices() { @@ -417,6 +422,10 @@ class DeviceInfoModal extends GenericModal { this._currentModal.querySelector("#mcuname").textContent = deviceInfo.mcu_name; this._currentModal.querySelector("#boardid").textContent = deviceInfo.board_id; this._currentModal.querySelector("#uid").textContent = deviceInfo.uid; + const updateContainer = this._currentModal.querySelector("#firmware-update"); + if (updateContainer) { + renderFirmwareSuggestions(updateContainer, deviceInfo); + } } async open(workflow, documentState) { diff --git a/js/common/firmware-check.js b/js/common/firmware-check.js new file mode 100644 index 0000000..8b56f40 --- /dev/null +++ b/js/common/firmware-check.js @@ -0,0 +1,200 @@ +// Helpers for comparing CircuitPython firmware versions and surfacing +// "newer firmware available" suggestions in the UI. +// +// CircuitPython release tags follow a SemVer-ish form, e.g.: +// 9.2.8 stable +// 10.0.0-alpha.1 development pre-release +// 10.0.0-beta.0 +// 10.0.0-rc.0 +// +// We parse the version into a tuple we can compare numerically and use the +// GitHub releases API for adafruit/circuitpython to find the latest stable +// and the latest dev (prerelease) versions. Per-board availability is left +// to the linked board page on circuitpython.org, which only lists builds +// that actually exist for that board. +// +// See https://github.com/circuitpython/web-editor/issues/357 + +const RELEASES_API = "https://api.github.com/repos/adafruit/circuitpython/releases"; + +// Cache the API result for the lifetime of the page so opening the device +// info dialog repeatedly doesn't hammer the API. +let _releasesPromise = null; + +// Pre-release identifier ranking. Lower number = earlier in the release cycle. +// Anything not listed (or an empty pre-release section) is treated as a final +// stable release and ranks highest within the same X.Y.Z. +const PRERELEASE_RANK = { + "alpha": 0, + "beta": 1, + "rc": 2, +}; + +// Parse a version string like "9.2.8", "10.0.0-alpha.1", or "10.0.0-rc.0" +// into a comparable structure. Returns null if it can't be parsed. +function parseVersion(versionString) { + if (typeof versionString !== "string") return null; + // Trim a leading "v" and any trailing build metadata after "+" + let raw = versionString.trim().replace(/^v/i, "").split("+", 1)[0]; + // Tolerate "-dirty" suffix on builds compiled from a working tree + raw = raw.replace(/-dirty$/i, ""); + const match = raw.match(/^(\d+)\.(\d+)\.(\d+)(?:[-.]([A-Za-z]+)\.?(\d+)?)?$/); + if (!match) return null; + + const [, maj, min, patch, preLabel, preNum] = match; + let preRank = Number.POSITIVE_INFINITY; // stable releases rank above any pre-release + let preNumber = 0; + let isPrerelease = false; + if (preLabel) { + isPrerelease = true; + const label = preLabel.toLowerCase(); + preRank = label in PRERELEASE_RANK ? PRERELEASE_RANK[label] : -1; + preNumber = preNum ? parseInt(preNum, 10) : 0; + } + return { + raw: versionString, + major: parseInt(maj, 10), + minor: parseInt(min, 10), + patch: parseInt(patch, 10), + prerelease: isPrerelease, + preRank, + preNumber, + }; +} + +// Compare two parsed versions. Returns negative if a < b, positive if a > b, 0 if equal. +function compareVersions(a, b) { + if (!a && !b) return 0; + if (!a) return -1; + if (!b) return 1; + if (a.major !== b.major) return a.major - b.major; + if (a.minor !== b.minor) return a.minor - b.minor; + if (a.patch !== b.patch) return a.patch - b.patch; + if (a.preRank !== b.preRank) return a.preRank - b.preRank; + return a.preNumber - b.preNumber; +} + +// Fetch (and cache) the list of CircuitPython releases from GitHub and pick +// the highest stable + highest dev pre-release. Returns +// { stable: parsedVersion|null, dev: parsedVersion|null }. +async function fetchLatestReleases() { + if (_releasesPromise) return _releasesPromise; + + _releasesPromise = (async () => { + let response; + try { + response = await fetch(`${RELEASES_API}?per_page=30`, { + headers: {"Accept": "application/vnd.github+json"}, + }); + } catch (err) { + console.warn("Firmware check: fetch failed", err); + return {stable: null, dev: null}; + } + if (!response.ok) { + console.warn("Firmware check: GitHub API returned", response.status); + return {stable: null, dev: null}; + } + let releases; + try { + releases = await response.json(); + } catch (err) { + console.warn("Firmware check: bad JSON from GitHub", err); + return {stable: null, dev: null}; + } + + let stable = null; + let dev = null; + for (const release of releases) { + if (release.draft) continue; + const parsed = parseVersion(release.tag_name); + if (!parsed) continue; + if (release.prerelease || parsed.prerelease) { + if (compareVersions(parsed, dev) > 0) dev = parsed; + } else { + if (compareVersions(parsed, stable) > 0) stable = parsed; + } + } + return {stable, dev}; + })(); + + return _releasesPromise; +} + +// Decide which (if any) firmware suggestions to surface for a device that +// is currently running `currentVersionString`. Implements the logic from +// https://github.com/circuitpython/web-editor/issues/357: +// +// - If the user is running a development release: +// - Suggest the latest stable if it is newer. +// - Suggest the latest dev release if it is newer than what they're running. +// - If the user is running a stable release: +// - Suggest a newer stable, if any. +// - Suggest a newer dev release, if any. +// +// Returns { suggestions: [{type: "stable"|"dev", version: "10.0.0"}], current }. +function buildSuggestions(currentVersionString, latestReleases) { + const current = parseVersion(currentVersionString); + const suggestions = []; + if (!current || !latestReleases) { + return {suggestions, current}; + } + const {stable, dev} = latestReleases; + + if (stable && compareVersions(stable, current) > 0) { + suggestions.push({type: "stable", version: stable.raw}); + } + if (dev && compareVersions(dev, current) > 0) { + suggestions.push({type: "dev", version: dev.raw}); + } + return {suggestions, current}; +} + +// Format suggestions as a small HTML snippet suitable for injecting into a +// device info table. `boardId` is used to deep-link to the board's download +// page on circuitpython.org. Returns an empty string when there is nothing +// to suggest. +function renderSuggestionsHtml(suggestions, boardId) { + if (!suggestions || suggestions.length === 0) return ""; + const safeBoard = encodeURIComponent(boardId || ""); + const link = safeBoard + ? `https://circuitpython.org/board/${safeBoard}/` + : "https://circuitpython.org/downloads"; + const items = suggestions.map((s) => { + const label = s.type === "dev" ? "development release" : "stable release"; + return `
  • Newer ${label} available: ${s.version}
  • `; + }).join(""); + return ( + `
    ` + + ` ` + + `Update available` + + `` + + `Download from circuitpython.org` + + `
    ` + ); +} + +// Convenience: fetch latest releases, compute suggestions for the given +// device version + board, and (if any) render them into `containerElement`. +// Failures are non-fatal -- nothing is rendered if the API call fails or the +// version string can't be parsed. +async function renderFirmwareSuggestions(containerElement, deviceInfo) { + if (!containerElement || !deviceInfo) return; + try { + const latest = await fetchLatestReleases(); + const {suggestions} = buildSuggestions(deviceInfo.version, latest); + const html = renderSuggestionsHtml(suggestions, deviceInfo.board_id); + containerElement.innerHTML = html; + } catch (err) { + console.warn("Firmware check failed", err); + containerElement.innerHTML = ""; + } +} + +export { + parseVersion, + compareVersions, + fetchLatestReleases, + buildSuggestions, + renderSuggestionsHtml, + renderFirmwareSuggestions, +}; diff --git a/js/common/plotter.js b/js/common/plotter.js index f0e6303..3ca92fc 100644 --- a/js/common/plotter.js +++ b/js/common/plotter.js @@ -3,7 +3,38 @@ import Chart from "chart.js/auto"; let textLineBuffer = ""; let textLine; -let defaultColors = ['#8888ff', '#ff8888', '#88ff88']; +// Plotter color palette. +// Plotter background is #777 on dark theme and #ccc on light theme, so colors +// must be readable against mid-gray. Pale tints, gray, and brown are dropped +// for that reason. Picked from the Okabe-Ito + tab10 mid-saturation set; +// reused round-robin if a sketch sends more series than colors. +let defaultColors = [ + '#1f77b4', // blue + '#ff7f0e', // orange + '#2ca02c', // green + '#d62728', // red + '#9467bd', // purple + '#e377c2', // pink + '#17becf', // cyan + '#bcbd22', // olive + '#e41a1c', // vivid red + '#377eb8', // steel blue + '#4daf4a', // leaf green + '#984ea3', // violet +]; + +// Resolve a CSS custom property from :root (or body, for theme overrides) +// at chart-build time. Falls back to the supplied default if the variable +// is unset or empty. +function getCssVar(name, fallback) { + if (typeof window === 'undefined' || !window.getComputedStyle) { + return fallback; + } + const value = window.getComputedStyle(document.body || document.documentElement) + .getPropertyValue(name) + .trim(); + return value || fallback; +} /** * @name LineBreakTransformer @@ -26,6 +57,65 @@ class LineBreakTransformer { let lineTransformer = new LineBreakTransformer() +/** + * Parse an Arduino Serial Plotter style line into an array of + * { label, value } pairs. + * + * The Arduino Serial Plotter accepts values separated by commas, tabs, or + * spaces, and each value may be prefixed with a label using "label:value". + * Labels are optional; positional values without a label fall back to their + * index. Examples that should all parse: + * "1,2,3" + * "1\t2\t3" + * "Temp:23.4,Hum:55.1" + * "405nm_F1:123\t425nm_F2:456\tClear:789" + * + * @param {string} textLine + * @returns {Array<{label: (string|null), value: number}>} + */ +function parseLabeledValues(textLine) { + // Split on commas, tabs, or runs of spaces. Arduino's plotter is lenient + // about which of these the sketch picks. + const tokens = textLine.split(/[,\t]|\s+/).filter(t => t.length > 0); + const parsed = []; + for (const token of tokens) { + const colonIdx = token.indexOf(":"); + let label = null; + let valueText = token; + if (colonIdx > 0) { + label = token.substring(0, colonIdx).trim(); + valueText = token.substring(colonIdx + 1).trim(); + } + const value = parseFloat(valueText); + parsed.push({ label, value }); + } + return parsed; +} + +/** + * Find the dataset index that matches the incoming sample. + * + * If the sample carries a label, prefer matching against an existing dataset + * with the same label so labeled series stay on the same line across frames + * (and across reordering). Without a label, fall back to positional index so + * legacy unlabeled CSV / list / tuple behaviour is unchanged. + * + * @param {object} chartObj + * @param {{label: (string|null), value: number}} sample + * @param {number} positionalIndex + * @returns {number} + */ +function resolveDatasetIndex(chartObj, sample, positionalIndex) { + if (sample.label) { + for (let i = 0; i < chartObj.data.datasets.length; i++) { + if (chartObj.data.datasets[i].label === sample.label) { + return i; + } + } + } + return positionalIndex; +} + export function plotValues(chartObj, serialMessage, bufferSize) { /* Given a string serialMessage, parse it into the plottable value(s) that @@ -44,7 +134,7 @@ export function plotValues(chartObj, serialMessage, bufferSize) { continue; } - let valuesToPlot; + let samples; // handle possible tuple in textLine if (textLine.startsWith("(") && textLine.endsWith(")")) { @@ -54,24 +144,25 @@ export function plotValues(chartObj, serialMessage, bufferSize) { textValues = textValues.substring(0, textValues.length - 1); } textLine = "[" + textValues + "]"; - console.log("after tuple conversion: " + textLine); } // handle possible list in textLine if (textLine.startsWith("[") && textLine.endsWith("]")) { - valuesToPlot = JSON.parse(textLine); - for (let i = 0; i < valuesToPlot.length; i++) { - valuesToPlot[i] = parseFloat(valuesToPlot[i]) - } - - } else { // handle possible CSV in textLine - valuesToPlot = textLine.split(",") - for (let i = 0; i < valuesToPlot.length; i++) { - valuesToPlot[i] = parseFloat(valuesToPlot[i]) + let valuesToPlot; + try { + valuesToPlot = JSON.parse(textLine); + } catch (e) { + // Not a valid JSON list; skip this line. + continue; } + samples = valuesToPlot.map(v => ({ label: null, value: parseFloat(v) })); + } else { + // Handle CSV / tab-separated / labeled values, matching the + // Arduino IDE Serial Plotter format. See parseLabeledValues. + samples = parseLabeledValues(textLine); } - if (valuesToPlot === undefined || valuesToPlot.length === 0) { + if (samples === undefined || samples.length === 0) { continue; } @@ -86,23 +177,38 @@ export function plotValues(chartObj, serialMessage, bufferSize) { } chartObj.data.labels.push(""); - for (let i = 0; i < valuesToPlot.length; i++) { - if (isNaN(valuesToPlot[i])) { + for (let i = 0; i < samples.length; i++) { + const sample = samples[i]; + if (isNaN(sample.value)) { continue; } - if (i > chartObj.data.datasets.length - 1) { - let curColor = '#000000'; - if (i < defaultColors.length) { - curColor = defaultColors[i]; - } + const datasetIndex = resolveDatasetIndex(chartObj, sample, i); + if (datasetIndex > chartObj.data.datasets.length - 1) { + const colorIdx = chartObj.data.datasets.length % defaultColors.length; + const curColor = defaultColors[colorIdx]; chartObj.data.datasets.push({ - label: i.toString(), + label: sample.label !== null ? sample.label : datasetIndex.toString(), data: [], borderColor: curColor, backgroundColor: curColor }); + } else if (sample.label && chartObj.data.datasets[datasetIndex].label !== sample.label) { + // Upgrade a previously-unlabeled positional dataset to use + // the label the sketch is now sending. This lets a sketch + // that starts unlabeled and switches to labels stay on the + // same series rather than spawning duplicates. + chartObj.data.datasets[datasetIndex].label = sample.label; + } + chartObj.data.datasets[datasetIndex].data.push(sample.value); + } + + // Pad any datasets that didn't receive a sample on this frame so + // x-axis alignment stays consistent across labeled series. + for (let i = 0; i < chartObj.data.datasets.length; i++) { + const ds = chartObj.data.datasets[i]; + while (ds.data.length < chartObj.data.labels.length) { + ds.data.push(null); } - chartObj.data.datasets[i].data.push(valuesToPlot[i]); } updatePlotterScales(chartObj); @@ -121,7 +227,12 @@ function updatePlotterScales(chartObj) { */ let allData = [] for (let i = 0; i < chartObj.data.datasets.length; i++) { - allData = allData.concat(chartObj.data.datasets[i].data) + // Filter out nulls used for x-axis padding so they don't break min/max. + const cleaned = chartObj.data.datasets[i].data.filter(v => v !== null && !isNaN(v)); + allData = allData.concat(cleaned); + } + if (allData.length === 0) { + return; } chartObj.options.scales.y.min = Math.min(...allData) - 10 chartObj.options.scales.y.max = Math.max(...allData) + 10 @@ -142,6 +253,20 @@ export async function setupPlotterChart(workflow) { type: 'line', options: { animation: false, + plugins: { + legend: { + // Show the legend so labeled series are easy to + // identify, matching the Arduino IDE Serial Plotter. + display: true, + position: 'top', + labels: { + // Pick a color that contrasts with the current + // theme's plotter background (set via + // --terminal-text-color in sass/layout/_themes.scss). + color: getCssVar('--terminal-text-color', '#ddd') + } + } + }, scales: { y: { min: -1, diff --git a/js/script.js b/js/script.js index bb17fb2..64906cf 100644 --- a/js/script.js +++ b/js/script.js @@ -1,10 +1,17 @@ import { basicSetup } from "codemirror"; import { EditorView, keymap } from "@codemirror/view"; -import { EditorState } from "@codemirror/state"; +import { EditorState, Compartment } from "@codemirror/state"; import { indentWithTab } from "@codemirror/commands" import { python } from "@codemirror/lang-python"; +import { json } from "@codemirror/lang-json"; +import { html } from "@codemirror/lang-html"; +import { css } from "@codemirror/lang-css"; +import { javascript } from "@codemirror/lang-javascript"; +import { xml } from "@codemirror/lang-xml"; +import { markdown } from "@codemirror/lang-markdown"; import { syntaxHighlighting, indentUnit } from "@codemirror/language"; import { classHighlighter } from "@lezer/highlight"; +import { circuitpythonHighlight } from "./common/circuitpython_highlight.js"; import { getFileIcon } from "./common/file_dialog.js"; import { Terminal } from '@xterm/xterm'; @@ -83,6 +90,87 @@ function rememberLastBackend(workflowType) { const editorTheme = EditorView.theme({}, {dark: getCssVar('editor-theme-dark').trim() === '1'}); +// Map file extensions to a CodeMirror 6 language extension factory. +// Anything not in this map falls back to plain text (no language plugin). +// Python is handled separately because it also gets the CircuitPython +// highlight overlay. +const LANGUAGE_EXTENSION_MAP = { + "css": css, + "htm": html, + "html": html, + "js": javascript, + "json": json, + "md": markdown, + "xml": xml, +}; + +function getFileExtensionFromPath(path) { + if (!path) return null; + // Use the basename so a dotted directory in the path doesn't fool us. + const base = path.split("/").pop(); + if (!base || base.indexOf(".") < 0) return null; + return base.split(".").pop().toLowerCase(); +} + +// Pick the CodeMirror language extensions to use for a given file path. +// Returns an array so callers can spread it directly into the editor's +// extension list. New (untitled) docs default to Python so the editor +// behaves the same as before for the common "create code.py" case. +function languageExtensionsForPath(path) { + if (path === null || path === undefined) { + return [python(), circuitpythonHighlight]; + } + const ext = getFileExtensionFromPath(path); + if (ext === "py") { + return [python(), circuitpythonHighlight]; + } + if (ext && Object.prototype.hasOwnProperty.call(LANGUAGE_EXTENSION_MAP, ext)) { + return [LANGUAGE_EXTENSION_MAP[ext]()]; + } + return []; +} + +// Compartment used so we can hot-swap the language plugin when the +// active file's extension changes (e.g. user opens an .html file, or +// uses Save As to rename code.py to test.html). +const languageCompartment = new Compartment(); + +// Track which path the editor's language plugin is currently configured +// for, so we can decide whether a reconfigure is actually needed. We +// can't compare against `workflow.currentFilename` because +// `workflow.saveFileAs()` mutates that BEFORE the post-save +// `setFilename` callback runs, so by the time we'd see it the +// "old" path is already gone. +let editorLanguagePath = null; + +function extensionKey(path) { + if (path === null || path === undefined) return "__null__"; + const ext = getFileExtensionFromPath(path); + return ext || "__noext__"; +} + +// Apply the language plugin matching `path` to the running editor. +// Safe to call before `editor` exists (the initial state already gets +// the correct language via languageCompartment.of(...) below). +function setEditorLanguageForPath(path) { + if (!editor) { + editorLanguagePath = path; + return; + } + if (extensionKey(path) === extensionKey(editorLanguagePath)) { + // Same language plugin would be installed — skip the + // reconfigure to avoid needlessly resetting language-internal + // state (folds, parser caches, etc.). + return; + } + editorLanguagePath = path; + editor.dispatch({ + effects: languageCompartment.reconfigure( + languageExtensionsForPath(path), + ), + }); +} + document.addEventListener('DOMContentLoaded', function() { document.getElementById('mobile-menu-button').addEventListener('click', handleMobileToggle); document.querySelectorAll('#mobile-menu-contents li a').forEach((element) => { @@ -276,10 +364,9 @@ async function checkConnected() { if (!workflow.connectionStatus()) { // Display the appropriate connection dialog await workflow.showConnect(getDocState()); - } else if (workflow.type === CONNTYPE.Web) { - // We're connected, local, and using Web Workflow - await workflow.showInfo(getDocState()); } + // Note: the Device Info dialog is now opened from loadEditor() so that + // BLE/USB/Web all behave the same way after a fresh connect. } return true; @@ -349,6 +436,12 @@ async function checkReadOnly() { /* Update the filename and update the UI */ function setFilename(path) { + // Refresh the CodeMirror language plugin whenever the active file + // changes — this is the single chokepoint that all filename + // changes route through (Open File, New File, Save As, backend + // load), so it's the right place to keep the language in sync. + setEditorLanguageForPath(path); + // Use the extension_map to figure out the file icon let filename = path; @@ -470,28 +563,42 @@ const hotkeyMap = [ { key: "Alt-n", run: newFile }, { key: "Mod-r", run: saveRunFile }, ]; -const editorExtensions = [ +// Extensions that are always present, regardless of file type. The +// per-file language extensions live in `languageCompartment` so they +// can be swapped at runtime (e.g. on Save As to a different +// extension). +const baseEditorExtensions = [ basicSetup, keymap.of([indentWithTab]), keymap.of(hotkeyMap), indentUnit.of(" "), - python(), editorTheme, syntaxHighlighting(classHighlighter), EditorView.updateListener.of(onTextChange) ]; +function buildEditorExtensions(path) { + return [ + ...baseEditorExtensions, + languageCompartment.of(languageExtensionsForPath(path)), + ]; +} + // Use the editor's function to check if anything has changed function isDirty() { if (unchanged == editor.state.doc.length) return false; return true; } -function loadEditorContents(content) { +function loadEditorContents(content, path = null) { editor.setState(EditorState.create({ doc: content, - extensions: editorExtensions + extensions: buildEditorExtensions(path) })); + // Keep our tracked language path in sync with the fresh state's + // compartment contents so the next setEditorLanguageForPath call + // can correctly skip a no-op reconfigure. + editorLanguagePath = path; unchanged = editor.state.doc.length; //console.log("doc length", unchanged); } @@ -537,6 +644,12 @@ window.onbeforeunload = () => { } }; +// Tracks whether we've already shown the post-connect Device Info dialog +// for the current workflow. Reset to false in disconnectCallback() so that +// a fresh connect always re-shows it, while silent reconnects (which also +// run loadEditor) do not. +let shownDeviceInfoForCurrentSession = false; + async function loadEditor() { let documentState = loadParameterizedContent(); if (documentState) { @@ -546,6 +659,24 @@ async function loadEditor() { } updateUIConnected(true); + + // Show the Device Info dialog once per fresh connect, regardless of + // workflow (Web / USB / BLE). This is where the firmware-update + // suggestion (issue #357) is surfaced, so the user notices it just + // after connecting without us introducing a new dialog. + // + // Fire-and-forget: we don't await the dialog because it stays open until + // the user dismisses it, and we don't want to block the rest of the + // post-connect flow (busy spinner, parameterized doc loading, etc.). + if (!shownDeviceInfoForCurrentSession + && workflow + && workflow.showInfo + && workflow.connectionStatus && workflow.connectionStatus()) { + shownDeviceInfoForCurrentSession = true; + Promise.resolve() + .then(() => workflow.showInfo(getDocState())) + .catch((err) => console.warn("Could not show device info dialog", err)); + } } var editor; @@ -572,7 +703,9 @@ async function saveFileContents(path) { } saveInFlight = true; try { - // If this is a different file, we write everything + // If this is a different file, we write everything. The language + // plugin is refreshed by setFilename below (it routes through + // setEditorLanguageForPath), so no extra dispatch is needed here. if (path !== workflow.currentFilename) { unchanged = 0; } @@ -670,7 +803,7 @@ async function saveFileContents(path) { // Load the File Contents and Path into the UI function loadFileContents(path, contents, saved = true) { setFilename(path); - loadEditorContents(contents); + loadEditorContents(contents, path); if (saved !== null) { setSaved(saved); } @@ -706,13 +839,14 @@ function disconnectCallback() { // saveInFlight is intentionally not forced here -- the in-flight // saveFileContents loop checks connectionStatus() between retries and // exits cleanly on its own, then clears the flag in its finally block. + shownDeviceInfoForCurrentSession = false; updateUIConnected(false); } editor = new EditorView({ state: EditorState.create({ doc: "", - extensions: editorExtensions + extensions: buildEditorExtensions(null) }), parent: document.querySelector('#editor') }); @@ -824,10 +958,10 @@ document.addEventListener('DOMContentLoaded', async (event) => { // If we don't have all the info we need to connect let returnVal = await workflow.parseParams(); if (returnVal === true && await workflowConnect() && workflow.type === CONNTYPE.Web) { - if (await checkReadOnly()) { - // We're connected, local, no errors, and using Web Workflow - await workflow.showInfo(getDocState()); - } + // We're connected, local, no errors, and using Web Workflow. + // The Device Info dialog is opened from loadEditor() now, so we + // just need to verify read-only state here. + await checkReadOnly(); } else { if (returnVal instanceof Error) { await showMessage(returnVal); diff --git a/js/workflows/ble.js b/js/workflows/ble.js index ca4a5ec..9cf97d9 100644 --- a/js/workflows/ble.js +++ b/js/workflows/ble.js @@ -437,7 +437,9 @@ class BLEWorkflow extends Workflow { updateConnected(connectionState) { super.updateConnected(connectionState); - this.connectionStep(2); + if (this.connectDialog && this.connectDialog.isOpen()) { + this.connectionStep(2); + } } async available() { diff --git a/js/workflows/usb.js b/js/workflows/usb.js index e5e2e10..728c17e 100644 --- a/js/workflows/usb.js +++ b/js/workflows/usb.js @@ -45,10 +45,13 @@ class USBWorkflow extends Workflow { } async onConnected(e) { - this.connectDialog.close(); - await this.loadEditor(); + // super.onConnected sets _connected=CONNSTATE.connected and closes + // the connect dialog. Run it first so that loadEditor() (and any + // other code that gates on connectionStatus()) sees us as fully + // connected. + await super.onConnected(e); this.debugLog("connected"); - super.onConnected(e); + await this.loadEditor(); } async onDisconnected(e, reconnect = true) { diff --git a/package-lock.json b/package-lock.json index 2ca508f..371bdb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,13 @@ "dependencies": { "@adafruit/ble-file-transfer-js": "adafruit/ble-file-transfer-js#1.0.5", "@adafruit/circuitpython-repl-js": "adafruit/circuitpython-repl-js#3.3.0", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.0", "@codemirror/lang-python": "^6.2.1", + "@codemirror/lang-xml": "^6.1.0", "@fortawesome/fontawesome-free": "^7.2.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", @@ -67,6 +73,76 @@ "@lezer/common": "^1.1.0" } }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz", + "integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" + } + }, "node_modules/@codemirror/lang-python": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", @@ -80,6 +156,20 @@ "@lezer/python": "^1.1.4" } }, + "node_modules/@codemirror/lang-xml": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", + "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/xml": "^1.0.0" + } + }, "node_modules/@codemirror/language": { "version": "6.12.3", "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", @@ -192,6 +282,17 @@ "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", "license": "MIT" }, + "node_modules/@lezer/css": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz", + "integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, "node_modules/@lezer/highlight": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", @@ -201,6 +302,39 @@ "@lezer/common": "^1.3.0" } }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@lezer/lr": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", @@ -210,6 +344,16 @@ "@lezer/common": "^1.0.0" } }, + "node_modules/@lezer/markdown": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz", + "integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0" + } + }, "node_modules/@lezer/python": { "version": "1.1.18", "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz", @@ -221,6 +365,17 @@ "@lezer/lr": "^1.0.0" } }, + "node_modules/@lezer/xml": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", + "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@marijn/find-cluster-break": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", diff --git a/package.json b/package.json index efcae35..8d47714 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,13 @@ "dependencies": { "@adafruit/ble-file-transfer-js": "adafruit/ble-file-transfer-js#1.0.5", "@adafruit/circuitpython-repl-js": "adafruit/circuitpython-repl-js#3.3.0", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.0", "@codemirror/lang-python": "^6.2.1", + "@codemirror/lang-xml": "^6.1.0", "@fortawesome/fontawesome-free": "^7.2.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", diff --git a/sass/layout/_layout.scss b/sass/layout/_layout.scss index 2924be8..b7d7c31 100644 --- a/sass/layout/_layout.scss +++ b/sass/layout/_layout.scss @@ -490,6 +490,43 @@ &[data-popup-modal="device-discovery"], &[data-popup-modal="device-info"] { + .firmware-update-suggestion-container { + // Filled in by firmware-check.js when a newer firmware is found. + // Empty by default so the dialog layout doesn't shift when the + // GitHub releases API hasn't responded yet (or fails). + &:empty { + display: none; + } + } + + .firmware-update-suggestion { + margin: 10px 0 5px; + padding: 10px 12px; + border: 1px solid $light-purple; + border-radius: 5px; + background-color: #faf6ff; + font-size: 0.95rem; + + i { + color: $purple; + margin-right: 6px; + } + + .firmware-update-suggestion__title { + font-weight: bold; + } + + ul { + margin: 6px 0; + padding-left: 24px; + } + + a { + color: $purple; + font-weight: bold; + } + } + .device-info { margin-top: 5px; width: 100%; diff --git a/sass/layout/_themes.scss b/sass/layout/_themes.scss index 500f1fd..c135fb2 100644 --- a/sass/layout/_themes.scss +++ b/sass/layout/_themes.scss @@ -14,6 +14,23 @@ --gutter-text-color: #ddd; --debug-message-color: #fce94f; --unsaved-file-color: #f60; + + // Editor token colors. Defaults are tuned for the dark editor + // background; the light theme below overrides any that would be + // unreadable on a near-white background. + --editor-text-color: #ddd; + --tok-comment-color: #7F848E; + --tok-variable-color: #61AFEF; + --tok-operator-color: #56B6C2; + --tok-string-color: #98C379; + --tok-string2-color: #FFB86C; + --tok-number-color: #E5C07B; + --tok-keyword-color: #C678DD; + --tok-property-color: #D19A66; + --tok-atom-color: #E06C75; + --tok-typename-color: #61AFEF; + --tok-cp-module-color: #FF79C6; + --tok-meta-color: #b084eb; } // Light Theme @@ -28,6 +45,23 @@ --gutter-active-line-color: #ccc; --gutter-text-color: #222; --debug-message-color: #FF9900; + + // Darken token colors enough to stay readable on the + // near-white editor background. Dark theme values above are + // tuned for #333; these are tuned for #f8f8f8. + --editor-text-color: #222; + --tok-comment-color: #6B7280; + --tok-variable-color: #1F6FB8; + --tok-operator-color: #1F8FA8; + --tok-string-color: #2E7D32; + --tok-string2-color: #B85C00; + --tok-number-color: #8A5A00; + --tok-keyword-color: #7C2DB5; + --tok-property-color: #9A5A1A; + --tok-atom-color: #B91C1C; + --tok-typename-color: #1F6FB8; + --tok-cp-module-color: #C71585; + --tok-meta-color: #6E3FBE; } // Styles applied to both themes @@ -75,7 +109,7 @@ } .cm-editor { - color: #ddd; + color: var(--editor-text-color); background-color: var(--background-color); line-height: 1.5; font-family: 'Operator Mono', 'Source Code Pro', Menlo, Monaco, Consolas, Courier New, monospace; @@ -87,7 +121,7 @@ .cm-comment { font-style: italic; - color: #676B79; + color: var(--tok-comment-color); } .cm-operator { @@ -95,19 +129,19 @@ } .cm-string { - color: #19F9D8; + color: var(--tok-string-color); } .cm-string-2 { - color: #FFB86C; + color: var(--tok-string2-color); } .cm-tag { - color: #ff2c6d; + color: var(--tok-typename-color); } .cm-meta { - color: #b084eb; + color: var(--tok-meta-color); } &.cm-focused .cm-cursor { @@ -139,19 +173,19 @@ /* Highlight Tags */ .tok-comment { - color: #7F848E; + color: var(--tok-comment-color); } .tok-variableName { - color: #61AFEF; + color: var(--tok-variable-color); } .tok-operator { - color: #56B6C2; + color: var(--tok-operator-color); } .tok-string { - color: #98C379; + color: var(--tok-string-color); } .tok-punctuation { @@ -159,19 +193,34 @@ } .tok-number { - color: #E5C07B, + color: var(--tok-number-color); } .tok-keyword { - color: #C678DD, + color: var(--tok-keyword-color); } .tok-propertyName { - color: #D19A66; + color: var(--tok-property-color); } .tok-atom, .tok-bool { - color: #E06C75; + color: var(--tok-atom-color); + } + + // HTML/XML tag names, type names. Without an explicit color these + // fall back to the editor default text color, which on the light + // theme renders as nearly invisible #ddd-on-#f8f8f8. + .tok-typeName { + color: var(--tok-typename-color); + } + + // CircuitPython-specific module names (overlay added by + // js/common/circuitpython_highlight.js). The overlay extension is + // wrapped with Prec.highest so its decoration nests inside the + // classHighlighter span, letting this color win without !important. + .tok-cp-module { + color: var(--tok-cp-module-color); } }