docs: meshforge docs update

This commit is contained in:
Ben Allfree
2026-04-17 03:32:18 -07:00
parent 60b46a078a
commit 2846e0c7f0
12 changed files with 308 additions and 178 deletions
+111
View File
@@ -0,0 +1,111 @@
# Firmware projects on MeshForge
This guide is for maintainers of **GitHub-hosted PlatformIO firmware** that users open in MeshForge.
## Quick tips
- **Optional `meshforge.yaml`** — full reference in the next section. Commit it on your **default branch** so MeshForge can narrow the tag and target pickers.
- After you push tags or change how the project should be discovered, hit **Refresh tags** on the repo page so MeshForge pulls the latest from GitHub.
- Set your repositorys **default branch** in GitHub to the branch you commit custom changes on (many maintainers name it after the mod). MeshForge periodically scans that branch.
## meshforge.yaml
`meshforge.yaml` is an **optional file at the repository root** (same level as `platformio.ini`). It tells MeshForge how to **filter Git tags** and **PlatformIO environments** (`[env:…]` names) in the web UI so users only see combinations that make sense for your project.
### Where MeshForge reads it
The **tag** and **target** dropdowns use the copy of `meshforge.yaml` from your repos **GitHub default branch** (via the GitHub contents API). That means:
- Put the file on whatever branch you set as **default** in GitHub settings.
- When you change the profile, merge or push to that default branch, then use **Refresh tags** so MeshForge refetches it.
- The profile is **not** read separately per selected tag for the pickers—one file on the default branch drives the filters for the whole repo view.
### File shape
MeshForge only understands a top-level `meshforge:` block with optional `tags:` and `targets:` sections. Other keys are ignored. The built-in parser is intentionally small: it expects **2-space indentation** under `meshforge:` (`tags:` / `targets:` at two spaces, their fields at four), supports **line comments** (`# …`), **quoted or unquoted** string values, and **inline lists** like `[wifi, ble]` for `require_capabilities`. If the file is missing or unparsable, MeshForge shows all tags and all scanned environments (subject to the normal scan).
```yaml
meshforge:
tags:
include: ^v2\.
targets:
include: HELTEC|TLORA|TBEAM
require_capabilities: [wifi]
```
### `tags.include`
- **Type:** string interpreted as a **JavaScript `RegExp`** source (same as `new RegExp(…)` in the browser).
- **Effect:** Only **tag names** that match this pattern stay in the tag dropdown. If the pattern is missing, every tag from GitHub is listed (still sorted as usual).
- **Invalid regex:** MeshForge treats the filter as absent and keeps the full tag list.
Use this to hide CI-only tags, old experiments, or anything you do not want offered as a firmware ref—for example `^v\d` for SemVer-looking tags only.
### `targets.include`
- **Type:** string → **JavaScript `RegExp`** over **PlatformIO environment names** (not display names).
- **Effect:** When no `include_template` applies (see below), environments whose names do not match are hidden from the target picker.
- **Invalid regex:** the regex filter is skipped (capabilities can still apply).
### `targets.include_template` (tag-aware targets)
When you want **which targets appear** to depend on **which tag is selected**, use **`tags.include` together with `targets.include_template`**:
1. `tags.include` must match the **currently selected tag**; the match is run with `RegExp.exec`, so you can use **capturing groups** `(...)` or **named groups** `(?<name>…)` (JavaScript regex syntax).
2. `targets.include_template` is a string that becomes a **new regex** after **placeholders** are filled in. Each substituted piece is **regex-escaped** so literal tag text does not break the pattern.
**Placeholders**
| Placeholder | Meaning |
| ------------------ | ---------------------------------------------- |
| `${1}`, `${2}`, … | Numbered capture groups from `tags.include` |
| `${myName}` | Named group `(?<myName>…)` from `tags.include` |
| `${myName_snake}` | Same capture, converted to **snake_case** |
| `${myName_camel}` | **lowerCamelCase** |
| `${myName_pascal}` | **PascalCase** |
**Precedence:** If both `include_template` and `tags.include` are set and the current tag **matches** `tags.include`, the expanded template is the target regex. Otherwise MeshForge uses **`targets.include`** as a plain static regex. If neither yields a valid regex, there is no name-based filter (capabilities may still apply).
**Example:** Tags like `acme-1.0.0` where environments are named `acme_1_0_0` and `acme_1_0_0_debug`:
```yaml
meshforge:
tags:
include: ^(?<mod>[a-z]+)-(?<ver>[0-9]+(?:\.[0-9]+)*)$
targets:
include_template: ^${mod_snake}_${ver_snake}
```
The template becomes a regex that still matches if you suffix env names (for example `_debug`). Adjust the tag regex and template to match how **you** name tags vs `env:` sections.
### `targets.require_capabilities`
- **Type:** inline list of strings, e.g. `[wifi, ble]`.
- **Effect:** An environment is shown only if **every** listed capability is present on that env. Capabilities are **inferred** by MeshForge from PlatformIOs `platform` / `board` for each env (not read from `meshforge.yaml`). Today that means roughly:
- **Espressif32** (including `pioarduino` URLs) → `wifi`, `ble`
- **nordicnrf52** → `ble`
- **Raspberry Pi / Pico** → `wifi` and `ble` only when the board looks like a **Pico W** (e.g. name contains `picow` or ends with `_w`)
- Other platforms → **no** inferred capabilities
You can use **only** `require_capabilities` (no `include` / `include_template`) to, for example, restrict the list to ESP32-class boards. If every env shows **no** capabilities (for example an old scan completed before capability detection), open the repo again on that ref so MeshForge can **rescan**; otherwise the capability filter may hide everything.
### Practical tips
- **Start without a file**, add `meshforge.yaml` once tag and env lists feel noisy or unsafe for end users.
- **Test regexes** in a JavaScript console (`new RegExp('…').test('tag-name')`) before committing.
- **Refresh tags** after editing the file on the default branch so the UI picks up changes quickly.
## Release tag naming
Tag releases so users can read both your mod and the upstream base at a glance, for example:
`<modName>-<modVersion>-<baseName>-<baseVersion>`
That makes it clear which upstream firmware version a release is built on.
## Maintaining a fork
- **Start from upstream** — Pick a tagged upstream release (often the latest) or any commit, and branch from there for your project (Meshtastic, MeshCore, or any PlatformIO-based LoRa mesh firmware).
- **Default branch** — Set that branch as the GitHub default. MeshForge uses it for periodic scans.
- **When upstream tags a new release** — **Merge** that tag into your branch and continue development. That keeps older release tags meaningful. **Do not rebase** published history you care about, or those tags will no longer sit on the older base revision users expect.
- **Branch-only workflow** — You can maintain a branch without tags; MeshForge builds from the **tip** of that branch. That works well for experimental projects before you cut releases.
+33 -8
View File
@@ -1,20 +1,45 @@
# Mesh Forge
# MeshForge
Build custom Meshtastic firmware right from your browser. No downloads, no toolchains—everything runs in the cloud.
Build custom Meshtastic firmware right from your browser. No downloads, no toolchains—everything runs in
the cloud.
MeshForge **cloud-compiles** and **web-flashes** custom firmware for LoRa mesh devices. It understands **PlatformIO** projects the same way your local build does. If PlatformIO can build it, MeshForge can run that build in the cloud and flash the artifacts in the browser.
## Features
- **Zero Install** - Everything runs in your browser
- **Custom Firmware** - Build bespoke Meshtastic firmware tailored to your exact needs
- **Community Extensions** - Include community modules and extensions beyond core Meshtastic
- **Share & Remix** - Publish your build profiles and let others remix your configs
- **Cloud Builds** - Compile in the cloud, flash directly to your device
- **Zero install** Everything runs in your browser
- **Custom firmware** Build bespoke firmware tailored to your exact needs
- **Community extensions** Include community modules and extensions beyond core projects
- **Share and remix** Publish your build profiles and let others remix your configs
- **Cloud builds** Compile in the cloud, flash directly to your device
## MeshForge understands any GitHub URL
MeshForge understands any GitHub project URL. Swap `github.com` for `meshforge.org` and you can build and flash from the cloud.
```
https://github.com/Reticulum-Community/microReticulum
```
becomes
```
https://meshforge.org/Reticulum-Community/microReticulum
```
Keep the same `owner/repo` path (and optional `/tree/…` ref). You land in MeshForge, where you choose **tags** (or refs) and **build targets** (PlatformIO environments).
When someone has already built your exact tag and target, MeshForge can reuse that build and you skip the wait. If you are the first for that combination, wait for the build to finish, then flash—you have saved the next person time.
## For project developers
If you have a custom build of Meshtastic, MeshCore, or any other PlatformIO project, head over to **[DEVELOPER.md](DEVELOPER.md)** to find out how to make sure your project works well on MeshForge.
## Community
Join our Discord community: [https://discord.gg/8KgJpvjfaJ](https://discord.gg/8KgJpvjfaJ)
## Contributing to Mesh Forge
## Contributing to MeshForge
```bash
# Install dependencies
+1 -1
View File
@@ -29,7 +29,7 @@ function FeaturedTile({ project, onOpenRepoUrl }: { project: FeaturedProject; on
<button
type="button"
onClick={() => onOpenRepoUrl(project.url)}
aria-label={`Open ${project.title} in Mesh Forge`}
aria-label={`Open ${project.title} in MeshForge`}
className="group flex w-full cursor-pointer items-center gap-3 rounded-xl border border-slate-700/80 bg-slate-900/60 p-3 text-left ring-1 ring-white/5 transition hover:border-cyan-700/60 hover:bg-slate-800/80 hover:ring-cyan-500/20"
>
<FeaturedAvatar src={project.logo} title={project.title} />
+2 -2
View File
@@ -17,9 +17,9 @@ export default function Navbar() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-8">
<Link to="/" className="flex items-center gap-3 hover:opacity-80 transition-opacity">
<img src={favicon} alt="Mesh Forge logo" className="h-10 w-10 rounded-lg" />
<img src={favicon} alt="MeshForge logo" className="h-10 w-10 rounded-lg" />
<span className="text-2xl font-bold bg-gradient-to-r from-cyan-400 to-blue-600 bg-clip-text text-transparent">
Mesh Forge
MeshForge
</span>
</Link>
<div className="flex items-center gap-4">
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mesh Forge</title>
<title>MeshForge</title>
</head>
<body>
<div id="root"></div>
+9 -9
View File
@@ -9,7 +9,7 @@ import webAppIcon512Url from "@/assets/web-app-manifest-512x512.png?url"
const headMarker = "link[data-mesh-forge-head]"
function appendLink(attrs: Record<string, string>) {
const el = document.createElement('link')
const el = document.createElement("link")
el.setAttribute("data-mesh-forge-head", "")
for (const [k, v] of Object.entries(attrs)) {
el.setAttribute(k, v)
@@ -21,15 +21,15 @@ function appendLink(attrs: Record<string, string>) {
export function bootstrapAppHead() {
if (document.head.querySelector(headMarker)) return
appendLink({ rel: 'icon', type: 'image/png', href: favicon96x96Url, sizes: '96x96' })
appendLink({ rel: 'icon', type: 'image/svg+xml', href: faviconSvgUrl })
appendLink({ rel: 'shortcut icon', href: faviconIcoUrl })
appendLink({ rel: 'apple-touch-icon', sizes: '180x180', href: appleTouchIconUrl })
appendLink({ rel: 'icon', href: logoUrl })
appendLink({ rel: "icon", type: "image/png", href: favicon96x96Url, sizes: "96x96" })
appendLink({ rel: "icon", type: "image/svg+xml", href: faviconSvgUrl })
appendLink({ rel: "shortcut icon", href: faviconIcoUrl })
appendLink({ rel: "apple-touch-icon", sizes: "180x180", href: appleTouchIconUrl })
appendLink({ rel: "icon", href: logoUrl })
const manifest = {
name: "Mesh Forge",
short_name: "Mesh Forge",
name: "MeshForge",
short_name: "MeshForge",
icons: [
{
src: webAppIcon192Url,
@@ -50,5 +50,5 @@ export function bootstrapAppHead() {
}
const blob = new Blob([JSON.stringify(manifest)], { type: "application/manifest+json" })
const manifestUrl = URL.createObjectURL(blob)
appendLink({ rel: 'manifest', href: manifestUrl })
appendLink({ rel: "manifest", href: manifestUrl })
}
+14 -11
View File
@@ -22,8 +22,8 @@ import {
runEspFlash,
type FlashPhase,
} from "../lib/espFlashRun"
import { runNrfFlash } from "../lib/nrfFlashRun"
import { inferTargetFamilyFromBundle, inferTargetFamilyFromEnv } from "../lib/flashTargetFamily"
import { runNrfFlash } from "../lib/nrfFlashRun"
import type { FlashTargetFamily } from "../lib/untarGz"
import { extractTarGz } from "../lib/untarGz"
@@ -58,7 +58,7 @@ type DeviceFlasherProps = {
className?: string
/** Firmware source repo tree at this ref (no target / flash path segment). */
githubRepoTreeHref?: string | null
/** Mesh Forge GitHub Actions run for this build. */
/** MeshForge GitHub Actions run for this build. */
githubActionsRunHref?: string | null
/** Download firmware bundle (icon button below controls). */
onDownloadBundle?: (() => void) | null
@@ -84,9 +84,7 @@ export default function DeviceFlasher({
const [busy, setBusy] = useState(false)
const shareDialogRef = useRef<HTMLDialogElement>(null)
const [eraseFlashForFactory, setEraseFlashForFactory] = useState(false)
const [bundleFamily, setBundleFamily] = useState<FlashTargetFamily>(
inferTargetFamilyFromEnv(targetEnv) ?? "esp32"
)
const [bundleFamily, setBundleFamily] = useState<FlashTargetFamily>(inferTargetFamilyFromEnv(targetEnv) ?? "esp32")
const [bundleCanErase, setBundleCanErase] = useState(false)
const [flashProgress, setFlashProgress] = useState<FlashProgress | null>(null)
const [bundleLoadError, setBundleLoadError] = useState<string | null>(null)
@@ -245,7 +243,7 @@ export default function DeviceFlasher({
try {
await navigator.share({
url: shareUrlTrimmed,
title: "Mesh Forge Web Flasher",
title: "MeshForge Web Flasher",
})
closeShareDialog()
} catch (e) {
@@ -406,7 +404,7 @@ export default function DeviceFlasher({
<div className="min-w-0 space-y-1">
<h2 className="text-lg font-semibold tracking-tight text-slate-50">Share Web Flasher</h2>
<p className="text-sm leading-relaxed text-slate-400">
Same firmware view in Mesh Forge repo, ref, target, and bundle.
Same firmware view in MeshForge repo, ref, target, and bundle.
</p>
</div>
</div>
@@ -440,7 +438,9 @@ export default function DeviceFlasher({
) : null}
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500">Open or post</p>
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500">
Open or post
</p>
<div className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4">
<Button
type="button"
@@ -457,7 +457,7 @@ export default function DeviceFlasher({
<span className="truncate">New tab</span>
</Button>
<a
href={`mailto:?subject=${encodeURIComponent("Mesh Forge Web Flasher")}&body=${encodeURIComponent(shareUrlTrimmed)}`}
href={`mailto:?subject=${encodeURIComponent("MeshForge Web Flasher")}&body=${encodeURIComponent(shareUrlTrimmed)}`}
title="Email this link"
className="inline-flex h-11 items-center justify-center gap-2 rounded-md border border-slate-600 bg-slate-900/40 px-3 text-sm font-medium text-slate-200 transition-colors hover:bg-slate-800"
onClick={closeShareDialog}
@@ -466,7 +466,7 @@ export default function DeviceFlasher({
<span className="truncate">Email</span>
</a>
<a
href={`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrlTrimmed)}&text=${encodeURIComponent("Mesh Forge Web Flasher")}`}
href={`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrlTrimmed)}&text=${encodeURIComponent("MeshForge Web Flasher")}`}
target="_blank"
rel="noreferrer"
title="Post on X"
@@ -486,7 +486,10 @@ export default function DeviceFlasher({
className="inline-flex h-11 items-center justify-center gap-2 rounded-md border border-slate-600 bg-slate-900/40 px-3 text-sm font-medium text-slate-200 transition-colors hover:bg-slate-800"
onClick={closeShareDialog}
>
<span className="h-2 w-2 shrink-0 rounded-full bg-sky-400 shadow-[0_0_8px_rgba(56,189,248,0.5)]" aria-hidden />
<span
className="h-2 w-2 shrink-0 rounded-full bg-sky-400 shadow-[0_0_8px_rgba(56,189,248,0.5)]"
aria-hidden
/>
<span className="truncate">Bluesky</span>
</a>
</div>
+19 -19
View File
@@ -1,10 +1,10 @@
/** Turn noisy Convex / GitHub errors into short copy for the repo build card. */
export function formatBuildErrorSummary(summary: string | undefined): string {
if (!summary) return ''
if (summary.includes('Unexpected inputs provided')) {
if (!summary) return ""
if (summary.includes("Unexpected inputs provided")) {
return (
'Mesh Forges own GitHub workflow didnt accept the build request (inputs out of sync). ' +
'Thats on the Mesh Forge service, not your firmware. Retry wont help until that workflow YAML is updated.'
"MeshForges own GitHub workflow didnt accept the build request (inputs out of sync). " +
"Thats on the MeshForge service, not your firmware. Retry wont help until that workflow YAML is updated."
)
}
if (summary.length > 600) {
@@ -13,44 +13,44 @@ export function formatBuildErrorSummary(summary: string | undefined): string {
return summary
}
/** Short headline + body for people browsing firmware, not operating Mesh Forge. */
/** Short headline + body for people browsing firmware, not operating MeshForge. */
export function buildFailurePresentation(summary: string | undefined): {
headline: string
body: string
} {
if (!summary?.trim()) {
return { headline: 'Build did not finish.', body: '' }
return { headline: "Build did not finish.", body: "" }
}
if (summary.includes('Unexpected inputs provided')) {
if (summary.includes("Unexpected inputs provided")) {
return {
headline: 'Couldnt start a cloud build',
headline: "Couldnt start a cloud build",
body:
'Something on the Mesh Forge side didnt line up with GitHub. Its not a signal that your repo is broken. ' +
'Retry usually wont fix this until the service is updated.',
"Something on the MeshForge side didnt line up with GitHub. Its not a signal that your repo is broken. " +
"Retry usually wont fix this until the service is updated.",
}
}
if (/GitHub API failed:\s*5\d\d/.test(summary) || /GitHub API failed:\s*429/.test(summary)) {
return {
headline: 'GitHub was temporarily unavailable',
body: 'Starting the build failed because GitHub returned an error or rate limit. Flash again — it often works on a second try.',
headline: "GitHub was temporarily unavailable",
body: "Starting the build failed because GitHub returned an error or rate limit. Flash again — it often works on a second try.",
}
}
if (/GitHub API failed:\s*4\d\d/.test(summary) && !summary.includes('422')) {
if (/GitHub API failed:\s*4\d\d/.test(summary) && !summary.includes("422")) {
return {
headline: 'Couldnt start the build',
headline: "Couldnt start the build",
body: formatBuildErrorSummary(summary),
}
}
if (summary.includes('GitHub API failed: 422')) {
if (summary.includes("GitHub API failed: 422")) {
return {
headline: 'Couldnt start the build',
headline: "Couldnt start the build",
body: formatBuildErrorSummary(summary),
}
}
return {
headline: 'Build failed in CI',
headline: "Build failed in CI",
body:
'Often a compile error, missing PlatformIO dependency, or bad env config in the repo. Fix the project if you can, then use Flash again. ' +
'Transient CI issues also happen — trying again is safe.',
"Often a compile error, missing PlatformIO dependency, or bad env config in the repo. Fix the project if you can, then use Flash again. " +
"Transient CI issues also happen — trying again is safe.",
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Mesh Forge tree URLs: `/owner/repo/tree/<tag-or-ref segments>/target/<env>` with optional `/flash` for the flasher-only view.
* MeshForge tree URLs: `/owner/repo/tree/<tag-or-ref segments>/target/<env>` with optional `/flash` for the flasher-only view.
* Source ref may contain `/` (nested tags are rare but allowed). `target` is a reserved final segment pair.
*/
const TARGET_TAIL = /\/target\/([^/]+)$/
+3 -3
View File
@@ -3,9 +3,9 @@ import { FeaturedProjects } from "@/components/FeaturedProjects"
import { Button } from "@/components/ui/button"
import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { parseGithubUrl } from "../lib/parseGithubUrl"
const MESH_FORGE_README_URL = "https://github.com/MeshEnvy/mesh-forge#readme"
import { parseGithubUrl } from "../lib/parseGithubUrl"
function encodeTreePath(ref: string) {
return ref.split("/").map(encodeURIComponent).join("/")
@@ -43,12 +43,12 @@ export default function HomePage() {
<div className="space-y-5">
<div className="flex justify-center">
<div className="rounded-2xl bg-slate-100 p-4 md:p-5 shadow-lg shadow-black/25 ring-1 ring-white/10">
<img src={logo} alt="Mesh Forge" className="h-20 w-auto md:h-24" width={120} height={120} />
<img src={logo} alt="MeshForge" className="h-20 w-auto md:h-24" width={120} height={120} />
</div>
</div>
<div>
<h1 className="text-4xl md:text-5xl font-bold bg-gradient-to-r from-cyan-400 to-blue-600 bg-clip-text text-transparent">
Mesh Forge
MeshForge
</h1>
<p className="mt-3 text-slate-200 text-lg md:text-xl font-medium leading-snug">
An open ecosystem and web flasher for mesh plugins, extensions, and firmware.
+3 -3
View File
@@ -1,11 +1,11 @@
import { Button } from '@/components/ui/button'
import { Link } from 'react-router-dom'
import { Button } from "@/components/ui/button"
import { Link } from "react-router-dom"
export default function NotFoundPage() {
return (
<div className="px-6 py-16 max-w-lg mx-auto">
<h1 className="text-2xl font-semibold tracking-tight mb-2">Page not found</h1>
<p className="text-muted-foreground mb-8">That URL does not match anything in Mesh Forge.</p>
<p className="text-muted-foreground mb-8">That URL does not match anything in MeshForge.</p>
<div className="flex flex-wrap gap-3">
<Button asChild variant="default">
<Link to="/">Home</Link>
+111 -120
View File
@@ -479,119 +479,114 @@ export default function RepoPage() {
/>
) : showCiInner ? (
<>
{build.status === "failed" ? (
<div className="flex flex-wrap gap-2 items-center">
{build.githubRunId ? (
<a
className="text-cyan-400 hover:underline text-xs"
href={`https://github.com/${MESH_FORGE_ACTIONS_REPO}/actions/runs/${build.githubRunId}`}
target="_blank"
rel="noreferrer"
{build.status === "failed" ? (
<div className="flex flex-wrap gap-2 items-center">
{build.githubRunId ? (
<a
className="text-cyan-400 hover:underline text-xs"
href={`https://github.com/${MESH_FORGE_ACTIONS_REPO}/actions/runs/${build.githubRunId}`}
target="_blank"
rel="noreferrer"
>
View run on GitHub
</a>
) : (
<a
className="text-cyan-400 hover:underline text-xs"
href={meshForgeWorkflowUrl}
target="_blank"
rel="noreferrer"
title="No run ID — usually the workflow never started (e.g. dispatch rejected). Open the MeshForge workflow to fix YAML or inspect recent runs."
>
MeshForge workflow on GitHub
</a>
)}
</div>
) : null}
{buildInProgress ? (
<div className="space-y-2 pt-1">
{(() => {
const step = build.ciProgressStep
const total = build.ciProgressTotal
const label = build.ciProgressLabel
const hasSteps =
typeof step === "number" && typeof total === "number" && total > 0 && step >= 1 && step <= total
const pct = hasSteps ? Math.min(100, Math.round((step / total) * 100)) : null
return (
<>
<div
className={`relative h-2.5 w-full overflow-hidden rounded-full bg-slate-800/90 ${
pct === null ? "animate-pulse" : ""
}`}
>
View run on GitHub
</a>
) : (
<a
className="text-cyan-400 hover:underline text-xs"
href={meshForgeWorkflowUrl}
target="_blank"
rel="noreferrer"
title="No run ID — usually the workflow never started (e.g. dispatch rejected). Open the Mesh Forge workflow to fix YAML or inspect recent runs."
>
Mesh Forge workflow on GitHub
</a>
)}
</div>
) : null}
{buildInProgress ? (
<div className="space-y-2 pt-1">
{(() => {
const step = build.ciProgressStep
const total = build.ciProgressTotal
const label = build.ciProgressLabel
const hasSteps =
typeof step === "number" && typeof total === "number" && total > 0 && step >= 1 && step <= total
const pct = hasSteps ? Math.min(100, Math.round((step / total) * 100)) : null
return (
<>
{pct !== null ? (
<div
className={`relative h-2.5 w-full overflow-hidden rounded-full bg-slate-800/90 ${
pct === null ? "animate-pulse" : ""
}`}
>
{pct !== null ? (
<div
className="ci-progress-fill-throb h-full rounded-full transition-[width] duration-500 ease-out"
style={{ width: `${pct}%` }}
/>
) : (
<div className="ci-progress-shimmer-x top-0 h-full w-[42%] rounded-full bg-linear-to-r from-cyan-700/85 via-emerald-400/95 to-cyan-700/85 shadow-[0_0_10px_rgba(52,211,153,0.4)]" />
)}
</div>
<p className="text-xs text-slate-400">
{hasSteps ? (
<>
Step {step} of {total}
{label ? ` · ${label}` : ""}
</>
) : (
<>Waiting for CI progress</>
)}
</p>
{build.githubRunId ? (
<a
className="inline-block text-cyan-400 hover:underline text-xs pt-0.5"
href={`https://github.com/${MESH_FORGE_ACTIONS_REPO}/actions/runs/${build.githubRunId}`}
target="_blank"
rel="noreferrer"
>
View run on GitHub
</a>
) : null}
</>
)
})()}
</div>
) : null}
{build.status === "failed" && build.errorSummary ? (
<div className="space-y-2 text-xs">
{(() => {
const { headline, body } = buildFailurePresentation(build.errorSummary)
return (
<>
<p className="font-medium text-slate-200">{headline}</p>
{body ? <p className="text-slate-400 leading-relaxed">{body}</p> : null}
<details className="text-slate-500">
<summary className="cursor-pointer select-none hover:text-slate-400">Technical details</summary>
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap wrap-break-word text-[11px] text-red-300/90">
{build.errorSummary.length > 2500
? `${build.errorSummary.slice(-2500)}`
: build.errorSummary}
</pre>
</details>
</>
)
})()}
</div>
) : null}
{build.status === "succeeded" && !flashUrl ? (
<>
{flashPrep === "loading" ? (
<p className="text-sm text-slate-400">Preparing USB flasher</p>
) : null}
{flashPrep === "error" ? (
<p className="text-sm text-amber-200/90">
Could not load a signed URL for flashing. Use <strong>Download bundle</strong> if you need the
file.
className="ci-progress-fill-throb h-full rounded-full transition-[width] duration-500 ease-out"
style={{ width: `${pct}%` }}
/>
) : (
<div className="ci-progress-shimmer-x top-0 h-full w-[42%] rounded-full bg-linear-to-r from-cyan-700/85 via-emerald-400/95 to-cyan-700/85 shadow-[0_0_10px_rgba(52,211,153,0.4)]" />
)}
</div>
<p className="text-xs text-slate-400">
{hasSteps ? (
<>
Step {step} of {total}
{label ? ` · ${label}` : ""}
</>
) : (
<>Waiting for CI progress</>
)}
</p>
) : null}
{flashPrep !== "loading" && flashPrep !== "error" ? (
<Button type="button" size="sm" variant="secondary" onClick={() => void download()}>
Download bundle
</Button>
) : null}
</>
{build.githubRunId ? (
<a
className="inline-block text-cyan-400 hover:underline text-xs pt-0.5"
href={`https://github.com/${MESH_FORGE_ACTIONS_REPO}/actions/runs/${build.githubRunId}`}
target="_blank"
rel="noreferrer"
>
View run on GitHub
</a>
) : null}
</>
)
})()}
</div>
) : null}
{build.status === "failed" && build.errorSummary ? (
<div className="space-y-2 text-xs">
{(() => {
const { headline, body } = buildFailurePresentation(build.errorSummary)
return (
<>
<p className="font-medium text-slate-200">{headline}</p>
{body ? <p className="text-slate-400 leading-relaxed">{body}</p> : null}
<details className="text-slate-500">
<summary className="cursor-pointer select-none hover:text-slate-400">Technical details</summary>
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap wrap-break-word text-[11px] text-red-300/90">
{build.errorSummary.length > 2500 ? `${build.errorSummary.slice(-2500)}` : build.errorSummary}
</pre>
</details>
</>
)
})()}
</div>
) : null}
{build.status === "succeeded" && !flashUrl ? (
<>
{flashPrep === "loading" ? <p className="text-sm text-slate-400">Preparing USB flasher</p> : null}
{flashPrep === "error" ? (
<p className="text-sm text-amber-200/90">
Could not load a signed URL for flashing. Use <strong>Download bundle</strong> if you need the file.
</p>
) : null}
{flashPrep !== "loading" && flashPrep !== "error" ? (
<Button type="button" size="sm" variant="secondary" onClick={() => void download()}>
Download bundle
</Button>
) : null}
</>
) : null}
</>
) : null}
</div>
@@ -604,9 +599,7 @@ export default function RepoPage() {
<div className="min-w-0 space-y-6 md:space-y-8">
<header className="space-y-5 border-b border-slate-800/90 pb-8">
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-500/90">
Web Flasher
</p>
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-500/90">Web Flasher</p>
<Link
to={backToRepoPath}
className="text-sm text-cyan-400 hover:text-cyan-300 hover:underline underline-offset-4 shrink-0"
@@ -631,9 +624,7 @@ export default function RepoPage() {
<dt className="text-xs font-semibold uppercase tracking-wider text-slate-500 shrink-0 w-30">
Target
</dt>
<dd className="font-mono text-amber-200/95 min-w-0 wrap-break-word">
{resolvedTargetEnv ?? "—"}
</dd>
<dd className="font-mono text-amber-200/95 min-w-0 wrap-break-word">{resolvedTargetEnv ?? "—"}</dd>
</div>
</dl>
</div>
@@ -713,11 +704,11 @@ export default function RepoPage() {
<Button
type="button"
className="h-9 shrink-0 bg-amber-600 px-4 text-white hover:bg-amber-700"
disabled={flashPrimaryDisabled}
onClick={queueFlashArtifacts}
>
Flash
</Button>
disabled={flashPrimaryDisabled}
onClick={queueFlashArtifacts}
>
Flash
</Button>
</div>
{statusStripEl}