Compare commits

...
11 Commits
Author SHA1 Message Date
swaits 49aa173b58 chore: repoint remaining links and host-specific tooling at git.swaits.com
Covers files missed by the first pass (.svelte/.astro components, bare org and
profile links, plugin doc headers), plus content that needed rewriting rather
than a URL swap: the chezmoi bootstrap script now provisions SSH for
git.swaits.com (User git, port 2222) and initialises from Gitea.

Link labels were updated to match their new targets.
2026-08-31 18:35:20 -06:00
swaits 999e91226b chore: repoint remaining links at git.swaits.com
Rewrites lingering github.com / *.sr.ht references to this Gitea instance,
translating GitHub and Sourcehut URL shapes to Gitea's (/blob/<ref>/ and
sr.ht /tree/<ref>/item/ -> /src/branch/<ref>/, /tree/<tag> -> /src/tag/<tag>,
/actions/workflows/X -> /actions?workflow=X, /edit/ -> /_edit/).

Left pointing at the old hosts on purpose: pull-request links (PRs were not
migrated), forks that were never migrated, and GitHub-only surfaces Gitea
lacks. OpenPGP identity proofs are untouched by design.
2026-08-31 18:32:10 -06:00
swaits f64365bc35 feat(boards): add Heltec Mesh Node T114
First color-display board on the site (nRF52840 + SX1262 + 1.14" 240x135
ST7789 TFT). Slotted into the Heltec block in boards.ts; Uf2DownloadFlow
maps the T114 id to the bootloader drive name (T114BOOT — verify on
real hardware).

Drive-by: the /flash page subtitle said "download UF2 files for nRF
boards" but the Waveshare RP2040-LoRa is also a UF2 target. Updated to
"nRF52840 and RP2040 boards".
2026-04-30 03:16:25 -06:00
swaits 55f565c619 feat(boards): unify homepage grid with flasher + clickable cards
Three changes, all from finally having working RAK4631 firmware and
noticing the homepage grid was hand-coded, stale, and disconnected from
the flasher.

1. RAK4631 display name. Silkscreen + RAK marketing call this module
   'RAK4631'; 'WisBlock' is the ecosystem brand. The site was showing
   'RAK WisBlock 4631', which matched neither the product name nor the
   firmware commit message ('RAK4631 WisBlock bring-up'). It also
   contradicted Uf2DownloadFlow.svelte, which already correctly told
   users 'A USB drive named RAK4631 should appear'. Renamed to 'RAK4631
   WisBlock' in boards.ts. Stable identifiers (board id, asset filename
   pattern, driveName lookup) unchanged so v1.4.0 artifacts still
   resolve.

2. Homepage grid is now data-driven from boards.ts. Previously a
   hand-coded list that had silently drifted: missing the LilyGo T-Beam
   classic, LilyGo T-Beam S3 Supreme, and the Heltec V3 UART variant.
   Now maps over the same `boards` array the flasher uses, sorted
   case-insensitively by display name. Adding a board in one place
   updates both pages. `badgeText()` and `badgeColor()` lifted out of
   BoardSelector.svelte into boards.ts so the label and accent color
   have a single source of truth too.

3. Homepage cards are now links — clicking a card takes you to
   /flash?board=<id> with that board pre-selected. Flasher.svelte reads
   the param on mount and sets selectedBoard if the id resolves, so the
   user lands on the version-picker step directly.

The flasher's BoardSelector keeps its richer UI (search filter,
MCU-family grouping) — only the simpler homepage grid was unified.
2026-04-25 20:41:35 -06:00
swaits 7067038ab5 style: normalize board card box sizing 2026-04-23 13:26:52 -06:00
swaits 921d73bf08 feat: add search filter to board selector 2026-04-23 13:09:24 -06:00
swaits 6486ce1f91 feat: add LilyGo T-Beam and T-Beam Supreme board support 2026-04-23 13:07:17 -06:00
swaits 389b6354f7 fix: flash Elecrow M2 and Heltec V3 UART via web serial, bump astro to 6.1.9 2026-04-22 22:39:50 -06:00
swaits cfa6cf2be2 feat: style release changelog with color-coded sections
Gives the GitHub-release-body markdown real typography on the
/flash page:

- `## [version]` becomes a bordered mono version heading
- `### Added / Changed / Fixed / Removed / Deprecated / Security`
  render as uppercase section headers, color-coded per
  Keep-a-Changelog semantics (green / yellow / blue / red)
- Nested bullet lists switch marker style (disc → circle → square)
- Inline `code`, fenced `pre`, hr, strong, em, blockquote all styled
- Proper vertical rhythm (line-height 1.65, section spacing)

To colorize the section headers, `VersionSelector`.renderBody now
post-processes marked's output to tag known h3 texts with a
`data-section` attribute that CSS targets.

Rules live in `src/styles/global.css` under `@layer base`, not in
the Svelte component's `<style>` block: the Astro + Svelte 5 build
pipeline was silently dropping the component's scoped CSS in the
production bundle (works in dev via HMR, but `dist/_astro/*.css`
shipped with zero `.changelog` rules), leaving the netlify site
completely unstyled. Moving to global CSS sidesteps that toolchain
issue; the `.changelog` parent selector is specific enough that no
scope leakage is possible.
2026-04-11 23:17:59 -06:00
swaits 81130108b7 feat: add ELECROW ThinkNode-M2 board support
ESP32-S3 Meshtastic handset with SH1106 OLED. Flashes via the
CP2102 bridge, so this board is download-only like heltec_v3_uart —
Web Serial is unreliable through the bridge. The existing
`download-only` branch of EspFlashFlow surfaces the CP2102 warning
plus a .bin download and espflash CLI instructions, so no new
component code is needed.

ESP32-S3 grid in BoardSelector goes from sm:grid-cols-3 to
sm:grid-cols-2 lg:grid-cols-4 to accommodate the fourth ESP board.
Homepage Supported Boards grid drops from lg:grid-cols-5 to
lg:grid-cols-3 (3x2 layout) — six 200-px cards in one row was tight.
2026-04-11 22:51:45 -06:00
swaitsandClaude Opus 4.6 9889f05df6 style: sort board families alphabetically (ESP, nRF, RP)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:50:02 -06:00
14 changed files with 473 additions and 200 deletions

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@
"dependencies": {
"@astrojs/svelte": "^8.0.4",
"@tailwindcss/vite": "^4.2.2",
"astro": "^6.1.4",
"astro": "^6.1.9",
"esptool-js": "^0.6.0",
"marked": "^18.0.0",
"svelte": "^5.55.2",
+29 -26
View File
@@ -10,13 +10,13 @@ importers:
dependencies:
'@astrojs/svelte':
specifier: ^8.0.4
version: 8.0.4(astro@6.1.4(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(typescript@5.9.3)(yaml@2.8.3))(jiti@2.6.1)(lightningcss@1.32.0)(svelte@5.55.2)(typescript@5.9.3)(yaml@2.8.3)
version: 8.0.4(astro@6.1.9(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(typescript@5.9.3)(yaml@2.8.3))(jiti@2.6.1)(lightningcss@1.32.0)(svelte@5.55.2)(typescript@5.9.3)(yaml@2.8.3)
'@tailwindcss/vite':
specifier: ^4.2.2
version: 4.2.2(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))
astro:
specifier: ^6.1.4
version: 6.1.4(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(typescript@5.9.3)(yaml@2.8.3)
specifier: ^6.1.9
version: 6.1.9(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(typescript@5.9.3)(yaml@2.8.3)
esptool-js:
specifier: ^0.6.0
version: 0.6.0
@@ -35,11 +35,11 @@ packages:
'@astrojs/compiler@3.0.1':
resolution: {integrity: sha512-z97oYbdebO5aoWzuJ/8q5hLK232+17KcLZ7cJ8BCWk6+qNzVxn/gftC0KzMBUTD8WAaBkPpNSQK6PXLnNrZ0CA==}
'@astrojs/internal-helpers@0.8.0':
resolution: {integrity: sha512-J56GrhEiV+4dmrGLPNOl2pZjpHXAndWVyiVDYGDuw6MWKpBSEMLdFxHzeM/6sqaknw9M+HFfHZAcvi3OfT3D/w==}
'@astrojs/internal-helpers@0.9.0':
resolution: {integrity: sha512-GdYkzR26re8izmyYlBqf4z2s7zNngmWLFuxw0UKiPNqHraZGS6GKWIwSHgS22RDlu2ePFJ8bzmpBcUszut/SDg==}
'@astrojs/markdown-remark@7.1.0':
resolution: {integrity: sha512-P+HnCsu2js3BoTc8kFmu+E9gOcFeMdPris75g+Zl4sY8+bBRbSQV6xzcBDbZ27eE7yBGEGQoqjpChx+KJYIPYQ==}
'@astrojs/markdown-remark@7.1.1':
resolution: {integrity: sha512-C6e9BnLGlbdv6bV8MYGeHpHxsUHrCrB4OuRLqi5LI7oiBVcBcqfUN06zpwFQdHgV48QCCrMmLpyqBr7VqC+swA==}
'@astrojs/prism@4.0.1':
resolution: {integrity: sha512-nksZQVjlferuWzhPsBpQ1JE5XuKAf1id1/9Hj4a9KG4+ofrlzxUUwX4YGQF/SuDiuiGKEnzopGOt38F3AnVWsQ==}
@@ -53,8 +53,8 @@ packages:
svelte: ^5.43.6
typescript: ^5.3.3
'@astrojs/telemetry@3.3.0':
resolution: {integrity: sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==}
'@astrojs/telemetry@3.3.1':
resolution: {integrity: sha512-7fcIxXS9J4ls5tr8b3ww9rbAIz2+HrhNJYZdkAhhB4za/I5IZ/60g+Bs8q7zwG0tOIZfNB4JWhVJ1Qkl/OrNCw==}
engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0}
'@babel/helper-string-parser@7.27.1':
@@ -761,8 +761,8 @@ packages:
array-iterate@2.0.1:
resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==}
astro@6.1.4:
resolution: {integrity: sha512-SRy1bONuCHkGWhI5JiWCQKVDVbeaXOikjAVZs/Nz+lvUvubtdLoZfnacmuZHQ9RL2IOkU54M8/qZYm9ypJDKrg==}
astro@6.1.9:
resolution: {integrity: sha512-NsAHzMzpznB281g2aM5qnBt2QjfH6ttKiZ3hSZw52If8JJ+62kbnBKbyKhR2glQcJLl7Jfe4GSl0DihFZ36rRQ==}
engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'}
hasBin: true
@@ -1038,6 +1038,11 @@ packages:
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
hasBin: true
is-docker@4.0.0:
resolution: {integrity: sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==}
engines: {node: '>=20'}
hasBin: true
is-inside-container@1.0.0:
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
engines: {node: '>=14.16'}
@@ -1741,13 +1746,13 @@ snapshots:
'@astrojs/compiler@3.0.1': {}
'@astrojs/internal-helpers@0.8.0':
'@astrojs/internal-helpers@0.9.0':
dependencies:
picomatch: 4.0.4
'@astrojs/markdown-remark@7.1.0':
'@astrojs/markdown-remark@7.1.1':
dependencies:
'@astrojs/internal-helpers': 0.8.0
'@astrojs/internal-helpers': 0.9.0
'@astrojs/prism': 4.0.1
github-slugger: 2.0.0
hast-util-from-html: 2.0.3
@@ -1775,10 +1780,10 @@ snapshots:
dependencies:
prismjs: 1.30.0
'@astrojs/svelte@8.0.4(astro@6.1.4(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(typescript@5.9.3)(yaml@2.8.3))(jiti@2.6.1)(lightningcss@1.32.0)(svelte@5.55.2)(typescript@5.9.3)(yaml@2.8.3)':
'@astrojs/svelte@8.0.4(astro@6.1.9(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(typescript@5.9.3)(yaml@2.8.3))(jiti@2.6.1)(lightningcss@1.32.0)(svelte@5.55.2)(typescript@5.9.3)(yaml@2.8.3)':
dependencies:
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.55.2)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))
astro: 6.1.4(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(typescript@5.9.3)(yaml@2.8.3)
astro: 6.1.9(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(typescript@5.9.3)(yaml@2.8.3)
svelte: 5.55.2
svelte2tsx: 0.7.53(svelte@5.55.2)(typescript@5.9.3)
typescript: 5.9.3
@@ -1796,17 +1801,14 @@ snapshots:
- tsx
- yaml
'@astrojs/telemetry@3.3.0':
'@astrojs/telemetry@3.3.1':
dependencies:
ci-info: 4.4.0
debug: 4.4.3
dlv: 1.1.3
dset: 3.1.4
is-docker: 3.0.0
is-docker: 4.0.0
is-wsl: 3.1.1
which-pm-runs: 1.1.0
transitivePeerDependencies:
- supports-color
'@babel/helper-string-parser@7.27.1': {}
@@ -2293,12 +2295,12 @@ snapshots:
array-iterate@2.0.1: {}
astro@6.1.4(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(typescript@5.9.3)(yaml@2.8.3):
astro@6.1.9(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(typescript@5.9.3)(yaml@2.8.3):
dependencies:
'@astrojs/compiler': 3.0.1
'@astrojs/internal-helpers': 0.8.0
'@astrojs/markdown-remark': 7.1.0
'@astrojs/telemetry': 3.3.0
'@astrojs/internal-helpers': 0.9.0
'@astrojs/markdown-remark': 7.1.1
'@astrojs/telemetry': 3.3.1
'@capsizecss/unpack': 4.0.0
'@clack/prompts': 1.2.0
'@oslojs/encoding': 1.1.0
@@ -2311,7 +2313,6 @@ snapshots:
cookie: 1.1.1
devalue: 5.7.0
diff: 8.0.4
dlv: 1.1.3
dset: 3.1.4
es-module-lexer: 2.0.0
esbuild: 0.27.7
@@ -2700,6 +2701,8 @@ snapshots:
is-docker@3.0.0: {}
is-docker@4.0.0: {}
is-inside-container@1.0.0:
dependencies:
is-docker: 3.0.0
+122 -63
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import type { BoardDef } from "../lib/types";
import { boards } from "../lib/boards";
import { boards, badgeText, badgeColor } from "../lib/boards";
interface Props {
selected: BoardDef | null;
@@ -9,84 +9,143 @@
let { selected, onselect }: Props = $props();
const espBoards = boards.filter((b) => b.family === "esp32s3");
const rpBoards = boards.filter((b) => b.family === "rp2040");
const nrfBoards = boards.filter((b) => b.family === "nrf52840");
let query = $state("");
function badgeText(board: BoardDef): string {
switch (board.flashMethod) {
case "web-serial":
return "Web Flash";
case "uf2":
return "UF2 Download";
case "download-only":
return "CLI Only";
}
function matches(board: BoardDef, q: string): boolean {
const needle = q.trim().toLowerCase();
if (!needle) return true;
const haystack = [
board.name,
board.mcu,
board.radio,
board.family,
badgeText(board),
board.notes ?? "",
]
.join(" ")
.toLowerCase();
return haystack.includes(needle);
}
function badgeColor(board: BoardDef): string {
switch (board.flashMethod) {
case "web-serial":
return "text-accent";
case "uf2":
return "text-info";
case "download-only":
return "text-warn";
const esp32Boards = $derived(boards.filter((b) => b.family === "esp32" && matches(b, query)));
const espBoards = $derived(boards.filter((b) => b.family === "esp32s3" && matches(b, query)));
const nrfBoards = $derived(boards.filter((b) => b.family === "nrf52840" && matches(b, query)));
const rpBoards = $derived(boards.filter((b) => b.family === "rp2040" && matches(b, query)));
const totalVisible = $derived(
esp32Boards.length + espBoards.length + nrfBoards.length + rpBoards.length,
);
const isFiltering = $derived(query.trim().length > 0);
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape" && query !== "") {
query = "";
e.preventDefault();
}
}
</script>
{#snippet boardCard(board: BoardDef)}
<button
class="flex flex-col h-full text-left bg-bg-card border p-4 rounded transition-all cursor-pointer
{selected?.id === board.id
? 'border-accent shadow-[0_0_12px_oklch(from_var(--color-accent)_l_c_h_/_0.15)]'
: 'border-border hover:border-border-bright'}"
onclick={() => onselect(board)}
>
<div class="font-mono font-medium text-text text-sm">{board.name}</div>
<div class="text-xs text-text-muted mt-1">{board.mcu} &middot; {board.radio}</div>
<div class="text-xs {badgeColor(board)} mt-auto pt-2 font-mono">{badgeText(board)}</div>
</button>
{/snippet}
{#snippet familySection(label: string, list: BoardDef[])}
{#if list.length > 0}
<p class="text-xs text-text-dim mb-2 font-mono">{label}</p>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
{#each list as board (board.id)}
{@render boardCard(board)}
{/each}
</div>
{/if}
{/snippet}
<div>
<h3 class="font-mono text-sm text-text-muted mb-3 uppercase tracking-wider">Select Board</h3>
<p class="text-xs text-text-dim mb-2 font-mono">ESP32-S3</p>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4">
{#each espBoards as board}
<button
class="text-left bg-bg-card border p-4 rounded transition-all cursor-pointer
{selected?.id === board.id
? 'border-accent shadow-[0_0_12px_oklch(from_var(--color-accent)_l_c_h_/_0.15)]'
: 'border-border hover:border-border-bright'}"
onclick={() => onselect(board)}
>
<div class="font-mono font-medium text-text text-sm">{board.name}</div>
<div class="text-xs text-text-muted mt-1">{board.mcu} &middot; {board.radio}</div>
<div class="text-xs {badgeColor(board)} mt-2 font-mono">{badgeText(board)}</div>
</button>
{/each}
<div class="flex items-baseline justify-between mb-3 gap-4">
<h3 class="font-mono text-sm text-text-muted uppercase tracking-wider">Select Board</h3>
{#if isFiltering}
<p class="text-xs text-text-dim font-mono">
{totalVisible} of {boards.length}
</p>
{/if}
</div>
<p class="text-xs text-text-dim mb-2 font-mono">RP2040</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-4">
{#each rpBoards as board}
<div class="relative mb-6">
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-dim pointer-events-none"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="11" cy="11" r="7"></circle>
<path d="m20 20-3.5-3.5"></path>
</svg>
<input
type="text"
bind:value={query}
onkeydown={handleKeydown}
placeholder="Filter by name, chip, radio…"
aria-label="Filter boards"
class="w-full bg-bg-card border border-border rounded pl-10 pr-10 py-2 font-mono text-sm text-text placeholder:text-text-dim focus:outline-none focus:border-accent transition-colors"
/>
{#if query}
<button
class="text-left bg-bg-card border p-4 rounded transition-all cursor-pointer
{selected?.id === board.id
? 'border-accent shadow-[0_0_12px_oklch(from_var(--color-accent)_l_c_h_/_0.15)]'
: 'border-border hover:border-border-bright'}"
onclick={() => onselect(board)}
type="button"
onclick={() => {
query = "";
}}
aria-label="Clear filter"
class="absolute right-2 top-1/2 -translate-y-1/2 text-text-dim hover:text-text cursor-pointer p-1 rounded"
>
<div class="font-mono font-medium text-text text-sm">{board.name}</div>
<div class="text-xs text-text-muted mt-1">{board.mcu} &middot; {board.radio}</div>
<div class="text-xs {badgeColor(board)} mt-2 font-mono">{badgeText(board)}</div>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M18 6 6 18M6 6l12 12"></path>
</svg>
</button>
{/each}
{/if}
</div>
<p class="text-xs text-text-dim mb-2 font-mono">nRF52840</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
{#each nrfBoards as board}
{#if totalVisible === 0}
<div class="bg-bg-card border border-border rounded p-6 text-center">
<p class="text-text-muted text-sm mb-3">
No boards match "<span class="text-text font-mono">{query}</span>"
</p>
<button
class="text-left bg-bg-card border p-4 rounded transition-all cursor-pointer
{selected?.id === board.id
? 'border-accent shadow-[0_0_12px_oklch(from_var(--color-accent)_l_c_h_/_0.15)]'
: 'border-border hover:border-border-bright'}"
onclick={() => onselect(board)}
type="button"
onclick={() => {
query = "";
}}
class="text-accent hover:text-accent-bright text-sm font-mono cursor-pointer"
>
<div class="font-mono font-medium text-text text-sm">{board.name}</div>
<div class="text-xs text-text-muted mt-1">{board.mcu} &middot; {board.radio}</div>
<div class="text-xs {badgeColor(board)} mt-2 font-mono">{badgeText(board)}</div>
Clear filter
</button>
{/each}
</div>
</div>
{:else}
{@render familySection("ESP32", esp32Boards)}
{@render familySection("ESP32-S3", espBoards)}
{@render familySection("nRF52840", nrfBoards)}
{@render familySection("RP2040", rpBoards)}
{/if}
</div>
+2 -2
View File
@@ -38,7 +38,7 @@
let session;
try {
session = await connect(callbacks);
session = await connect(callbacks, board.family === "esp32" ? "esp32" : "esp32s3");
} catch (err) {
const msg = err instanceof DOMException && err.name === "NotFoundError"
? "Port selection cancelled."
@@ -227,7 +227,7 @@
<p class="text-warn text-sm mb-2">
.bin file not available for this release.
<a
href="https://github.com/donglora/firmware/releases"
href="https://git.swaits.com/donglora/firmware/releases"
class="text-accent hover:text-accent-bright"
target="_blank"
rel="noopener noreferrer"
+11 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import type { BoardDef, Release } from "../lib/types";
import { boards } from "../lib/boards";
import { fetchReleases } from "../lib/github";
import BoardSelector from "./BoardSelector.svelte";
import VersionSelector from "./VersionSelector.svelte";
@@ -29,6 +30,14 @@
});
});
// Pre-select board from ?board=<id> deep link (e.g. from the homepage grid)
$effect(() => {
const boardId = new URLSearchParams(window.location.search).get("board");
if (!boardId) return;
const found = boards.find((b) => b.id === boardId);
if (found) selectedBoard = found;
});
function handleBoardSelect(board: BoardDef) {
selectedBoard = board;
}
@@ -59,7 +68,7 @@
{#if selectedBoard && selectedRelease}
<div class="border-t border-border pt-8">
<h3 class="font-mono text-sm text-text-muted mb-3 uppercase tracking-wider">
{#if selectedBoard.family === "esp32s3"}
{#if (selectedBoard.family === "esp32" || selectedBoard.family === "esp32s3")}
Flash Firmware
{:else}
Download Firmware
@@ -72,7 +81,7 @@
</div>
{/if}
{#if selectedBoard.family === "esp32s3"}
{#if (selectedBoard.family === "esp32" || selectedBoard.family === "esp32s3")}
<EspFlashFlow board={selectedBoard} release={selectedRelease} />
{:else}
<Uf2DownloadFlow board={selectedBoard} release={selectedRelease} />
+6 -4
View File
@@ -19,9 +19,11 @@
? "RAK4631"
: board.id === "wio_tracker_l1"
? "WIO-L1"
: board.id === "waveshare_rp2040_lora"
? "RPI-RP2"
: "UF2BOOT",
: board.id === "heltec_mesh_node_t114"
? "T114BOOT"
: board.id === "waveshare_rp2040_lora"
? "RPI-RP2"
: "UF2BOOT",
);
function handleDownload() {
@@ -44,7 +46,7 @@
<p class="text-warn text-sm">
UF2 file not found for {board.name} v{release.version}.
<a
href="https://github.com/donglora/firmware/releases"
href="https://git.swaits.com/donglora/firmware/releases"
class="text-accent hover:text-accent-bright"
target="_blank"
rel="noopener noreferrer"
+17 -42
View File
@@ -12,7 +12,17 @@
let { releases, selected, onselect, loading, error }: Props = $props();
let changelogHtml = $derived(selected?.body ? marked.parse(selected.body) : "");
// Tag Keep-a-Changelog section headings so CSS can color-code them
// (Added → green, Changed → yellow, Fixed → blue, etc.).
function renderBody(body: string): string {
const html = marked.parse(body) as string;
return html.replace(
/<h3>(Added|Changed|Fixed|Removed|Deprecated|Security)<\/h3>/g,
(_, text) => `<h3 data-section="${text.toLowerCase()}">${text}</h3>`,
);
}
let changelogHtml = $derived(selected?.body ? renderBody(selected.body) : "");
function handleChange(e: Event) {
const tag = (e.target as HTMLSelectElement).value;
@@ -30,7 +40,7 @@
<div class="text-error text-sm">
{error}
<a
href="https://github.com/donglora/firmware/releases"
href="https://git.swaits.com/donglora/firmware/releases"
class="text-accent hover:text-accent-bright ml-2"
target="_blank"
rel="noopener noreferrer"
@@ -61,49 +71,14 @@
</div>
{#if selected && changelogHtml}
<div class="mt-4 bg-bg-card border border-border rounded p-4">
<h4 class="font-mono text-xs text-text-dim uppercase tracking-wider mb-3">Changelog</h4>
<div class="prose-dark text-sm text-text-muted leading-relaxed">
<div class="mt-4 bg-bg-card border border-border rounded p-5">
<h4 class="font-mono text-xs text-text-dim uppercase tracking-wider mb-4">Changelog</h4>
<!-- Styles live in src/styles/global.css — Svelte component <style>
scoping wasn't being emitted into the prod CSS bundle. -->
<div class="changelog text-sm text-text-muted">
{@html changelogHtml}
</div>
</div>
{/if}
{/if}
</div>
<style>
.prose-dark :global(h1),
.prose-dark :global(h2),
.prose-dark :global(h3) {
color: var(--color-text);
font-family: var(--font-mono);
font-size: 0.875rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.prose-dark :global(ul) {
list-style-type: disc;
padding-left: 1.25rem;
}
.prose-dark :global(li) {
margin-bottom: 0.25rem;
}
.prose-dark :global(code) {
font-family: var(--font-mono);
font-size: 0.8em;
color: var(--color-accent);
background: var(--color-bg-elevated);
padding: 0.1em 0.3em;
border-radius: 2px;
}
.prose-dark :global(a) {
color: var(--color-accent);
}
.prose-dark :global(a:hover) {
color: var(--color-accent-bright);
}
.prose-dark :global(p) {
margin-bottom: 0.5rem;
}
</style>
+3 -3
View File
@@ -34,7 +34,7 @@ const { title, description = "Protocol-agnostic LoRa over USB" } = Astro.props;
<a href="/" class="text-text-muted hover:text-text transition-colors">Home</a>
<a href="/flash" class="text-text-muted hover:text-text transition-colors">Flasher</a>
<a
href="https://github.com/donglora"
href="https://git.swaits.com/donglora"
class="text-text-muted hover:text-text transition-colors"
target="_blank"
rel="noopener noreferrer"
@@ -58,12 +58,12 @@ const { title, description = "Protocol-agnostic LoRa over USB" } = Astro.props;
<p>
MIT License &middot;
<a
href="https://github.com/donglora"
href="https://git.swaits.com/donglora"
class="text-text-muted hover:text-accent transition-colors"
target="_blank"
rel="noopener noreferrer"
>
github.com/donglora
git.swaits.com/donglora
</a>
</p>
</div>
+86 -17
View File
@@ -1,15 +1,49 @@
import type { BoardDef } from "./types";
export const boards: BoardDef[] = [
{
id: "lilygo_tbeam",
name: "LilyGo T-Beam (classic)",
mcu: "ESP32",
radio: "SX1276",
family: "esp32",
flashMethod: "web-serial",
assetPattern: "donglora-lilygo_tbeam-v{version}.bin",
notes:
"Original T-Beam with ESP32 + SX1276 + SSD1306 OLED + AXP192 PMIC. Flashes over the on-board CP2102 USB-to-UART bridge. Clients connect via serial (BLE planned).",
},
{
id: "elecrow_thinknode_m2",
name: "ELECROW ThinkNode-M2",
mcu: "ESP32-S3",
radio: "SX1262",
family: "esp32s3",
flashMethod: "web-serial",
assetPattern: "donglora-elecrow_thinknode_m2-v{version}.bin",
notes:
"Elecrow Meshtastic handset with SH1106 OLED. Flashes over the built-in CP2102 USB-to-UART bridge — no native USB, so clients connect via serial (BLE planned).",
},
{
id: "heltec_v3_uart",
name: "Heltec V3 (UART) — stock",
mcu: "ESP32-S3",
radio: "SX1262",
family: "esp32s3",
flashMethod: "web-serial",
assetPattern: "donglora-heltec_v3_uart-v{version}.bin",
notes:
"Default Heltec V3 hardware — pick this one if you bought a stock V3. Flashes over the board's CP2102 USB-to-UART bridge. Clients connect via serial (BLE planned), not native USB.",
},
{
id: "heltec_v3",
name: "Heltec V3",
name: "Heltec V3 (USB CDC) — modded",
mcu: "ESP32-S3",
radio: "SX1262",
family: "esp32s3",
flashMethod: "web-serial",
assetPattern: "donglora-heltec_v3-v{version}.bin",
notes: "Native USB CDC-ACM (hardware mod may be required on some units)",
notes:
"Only choose this if you've hardware-modded your V3 for native USB CDC-ACM. Most stock V3s should use the UART variant above.",
},
{
id: "heltec_v4",
@@ -21,33 +55,37 @@ export const boards: BoardDef[] = [
assetPattern: "donglora-heltec_v4-v{version}.bin",
},
{
id: "heltec_v3_uart",
name: "Heltec V3 (UART)",
id: "heltec_mesh_node_t114",
name: "Heltec Mesh Node T114",
mcu: "nRF52840",
radio: "SX1262",
family: "nrf52840",
flashMethod: "uf2",
assetPattern: "donglora-heltec_mesh_node_t114-v{version}.uf2",
notes:
"nRF52840 + SX1262 with a 1.14\" 240×135 ST7789 color TFT — the first donglora board with a color display. Flashes over native USB CDC-ACM via the Adafruit nRF52 UF2 bootloader — double-tap RESET to expose the drive.",
},
{
id: "lilygo_tbeam_supreme",
name: "LilyGo T-Beam S3 Supreme",
mcu: "ESP32-S3",
radio: "SX1262",
family: "esp32s3",
flashMethod: "download-only",
assetPattern: "donglora-heltec_v3_uart-v{version}.bin",
flashMethod: "web-serial",
assetPattern: "donglora-lilygo_tbeam_supreme-v{version}.bin",
notes:
"Uses CP2102 USB-to-UART bridge. Web flashing is unreliable — download the .bin and flash with espflash CLI.",
"T-Beam Supreme with ESP32-S3 + SX1262 + SH1106 OLED + AXP2101 PMIC. Flashes over native USB CDC-ACM — no UART bridge.",
},
{
id: "rak_wisblock_4631",
name: "RAK WisBlock 4631",
name: "RAK4631 WisBlock",
mcu: "nRF52840",
radio: "SX1262",
family: "nrf52840",
flashMethod: "uf2",
assetPattern: "donglora-rak_wisblock_4631-v{version}.uf2",
},
{
id: "waveshare_rp2040_lora",
name: "Waveshare RP2040-LoRa",
mcu: "RP2040",
radio: "SX1262",
family: "rp2040",
flashMethod: "uf2",
assetPattern: "donglora-waveshare_rp2040_lora-v{version}.uf2",
notes:
"RAK4631 Core (nRF52840 + SX1262) on a RAK19007 base, optionally with the RAK1921 OLED. Flashes over native USB CDC-ACM via the Adafruit nRF52 UF2 bootloader — double-tap RESET to expose the RAK4631 drive.",
},
{
id: "wio_tracker_l1",
@@ -58,12 +96,43 @@ export const boards: BoardDef[] = [
flashMethod: "uf2",
assetPattern: "donglora-wio_tracker_l1-v{version}.uf2",
},
{
id: "waveshare_rp2040_lora",
name: "Waveshare RP2040-LoRa",
mcu: "RP2040",
radio: "SX1262",
family: "rp2040",
flashMethod: "uf2",
assetPattern: "donglora-waveshare_rp2040_lora-v{version}.uf2",
},
];
export function getAssetName(board: BoardDef, version: string): string {
return board.assetPattern.replace("{version}", version);
}
export function badgeText(board: BoardDef): string {
switch (board.flashMethod) {
case "web-serial":
return "Web Flash";
case "uf2":
return "UF2 Download";
case "download-only":
return "CLI Only";
}
}
export function badgeColor(board: BoardDef): string {
switch (board.flashMethod) {
case "web-serial":
return "text-accent";
case "uf2":
return "text-info";
case "download-only":
return "text-warn";
}
}
import type { ReleaseAsset } from "./types";
export interface AssetUrls {
+20 -4
View File
@@ -1,5 +1,7 @@
import type { FlashProgress } from "./types";
export type EspChipFamily = "esp32" | "esp32s3";
export function isWebSerialSupported(): boolean {
return typeof navigator !== "undefined" && "serial" in navigator;
}
@@ -19,7 +21,20 @@ async function loadEsptool() {
return { ESPLoader: mod.ESPLoader, Transport: mod.Transport };
}
export async function connect(callbacks: FlashCallbacks): Promise<FlashSession> {
function chipFamilyLabel(family: EspChipFamily): string {
return family === "esp32s3" ? "ESP32-S3" : "ESP32";
}
function isChipCompatible(detected: string, expected: EspChipFamily): boolean {
if (expected === "esp32s3") return detected.includes("ESP32-S3");
// Classic ESP32: esptool-js reports "ESP32". Exclude S2/S3/C3/C6/H2/P4 variants.
return /^ESP32(?![-\s]?(S|C|H|P)\d)/i.test(detected);
}
export async function connect(
callbacks: FlashCallbacks,
expectedChip: EspChipFamily,
): Promise<FlashSession> {
if (!isWebSerialSupported()) {
throw new Error("Web Serial API is not supported in this browser. Use Chrome, Edge, or Opera.");
}
@@ -45,16 +60,17 @@ export async function connect(callbacks: FlashCallbacks): Promise<FlashSession>
},
});
callbacks.onLog("Connecting to ESP32...");
const expectedLabel = chipFamilyLabel(expectedChip);
callbacks.onLog(`Connecting to ${expectedLabel}...`);
callbacks.onProgress({ phase: "Connecting", percent: 0, message: "Detecting chip..." });
await loader.main();
const chip = loader.chip.CHIP_NAME;
callbacks.onLog(`Detected: ${chip}`);
if (!chip.includes("ESP32-S3")) {
if (!isChipCompatible(chip, expectedChip)) {
await transport.disconnect();
throw new Error(`Expected ESP32-S3 but detected ${chip}. Make sure you selected the correct board.`);
throw new Error(`Expected ${expectedLabel} but detected ${chip}. Make sure you selected the correct board.`);
}
return { transport, loader };
+1 -1
View File
@@ -1,4 +1,4 @@
export type BoardFamily = "esp32s3" | "nrf52840" | "rp2040";
export type BoardFamily = "esp32" | "esp32s3" | "nrf52840" | "rp2040";
export type FlashMethod = "web-serial" | "uf2" | "download-only";
export interface BoardDef {
+1 -1
View File
@@ -6,7 +6,7 @@ import Flasher from "../components/Flasher.svelte";
<Base title="DongLoRa Flasher" description="Flash DongLoRa firmware onto your LoRa board from the browser">
<h1 class="font-mono text-3xl font-bold mb-2">Firmware Flasher</h1>
<p class="text-text-muted mb-8">
Flash DongLoRa firmware directly from your browser using Web Serial, or download UF2 files for nRF boards.
Flash DongLoRa firmware directly from your browser using Web Serial, or download UF2 files for nRF52840 and RP2040 boards.
</p>
<Flasher client:load />
</Base>
+28 -34
View File
@@ -1,6 +1,11 @@
---
import Base from "../layouts/Base.astro";
import { Code } from "astro:components";
import { boards, badgeText, badgeColor } from "../lib/boards";
const sortedBoards = [...boards].sort((a, b) =>
a.name.localeCompare(b.name, "en", { sensitivity: "base" }),
);
const quickStartCode = `import donglora as dl
@@ -223,32 +228,21 @@ your code ──► client lib ──┘</pre>
<!-- Supported Boards -->
<section class="py-12">
<h2 class="font-mono text-2xl font-bold mb-6 text-text">Supported Boards</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
<div class="bg-bg-card border border-border p-5 rounded">
<h3 class="font-mono font-medium text-text mb-2">Heltec V3</h3>
<p class="text-sm text-text-muted">ESP32-S3 &middot; SX1262</p>
<p class="text-xs text-accent mt-2 font-mono">Web Flash</p>
</div>
<div class="bg-bg-card border border-border p-5 rounded">
<h3 class="font-mono font-medium text-text mb-2">Heltec V4</h3>
<p class="text-sm text-text-muted">ESP32-S3 &middot; SX1262</p>
<p class="text-xs text-accent mt-2 font-mono">Web Flash</p>
</div>
<div class="bg-bg-card border border-border p-5 rounded">
<h3 class="font-mono font-medium text-text mb-2">RAK WisBlock 4631</h3>
<p class="text-sm text-text-muted">nRF52840 &middot; SX1262</p>
<p class="text-xs text-info mt-2 font-mono">UF2 Download</p>
</div>
<div class="bg-bg-card border border-border p-5 rounded">
<h3 class="font-mono font-medium text-text mb-2">Waveshare RP2040-LoRa</h3>
<p class="text-sm text-text-muted">RP2040 &middot; SX1262</p>
<p class="text-xs text-info mt-2 font-mono">UF2 Download</p>
</div>
<div class="bg-bg-card border border-border p-5 rounded">
<h3 class="font-mono font-medium text-text mb-2">Wio Tracker L1</h3>
<p class="text-sm text-text-muted">nRF52840 &middot; SX1262</p>
<p class="text-xs text-info mt-2 font-mono">UF2 Download</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{
sortedBoards.map((board) => (
<a
href={`/flash?board=${board.id}`}
class="block bg-bg-card border border-border p-5 rounded transition-colors hover:border-border-bright hover:bg-bg-elevated no-underline"
>
<h3 class="font-mono font-medium text-text mb-2">{board.name}</h3>
<p class="text-sm text-text-muted">
{board.mcu} &middot; {board.radio}
</p>
<p class={`text-xs ${badgeColor(board)} mt-2 font-mono`}>{badgeText(board)}</p>
</a>
))
}
</div>
</section>
@@ -263,7 +257,7 @@ your code ──► client lib ──┘</pre>
<p class="text-sm text-text-muted mb-3">The code that runs on the LoRa board itself. Owns the radio hardware, speaks USB.</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-8">
<a
href="https://github.com/donglora/firmware"
href="https://git.swaits.com/donglora/firmware"
class="bg-bg-card border border-border p-4 rounded hover:border-border-bright transition-colors block"
target="_blank"
rel="noopener noreferrer"
@@ -278,7 +272,7 @@ your code ──► client lib ──┘</pre>
<p class="text-sm text-text-muted mb-3">Run on your computer. Handle USB discovery, protocol framing, and encoding so you just call <code class="font-mono text-xs text-accent">send()</code> and <code class="font-mono text-xs text-accent">receive()</code>.</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-8">
<a
href="https://github.com/donglora/client-rs"
href="https://git.swaits.com/donglora/client-rs"
class="bg-bg-card border border-border p-4 rounded hover:border-border-bright transition-colors block"
target="_blank"
rel="noopener noreferrer"
@@ -287,7 +281,7 @@ your code ──► client lib ──┘</pre>
<p class="text-sm text-text-muted mt-1">Rust client library &mdash; <code class="font-mono text-xs text-accent">donglora-client</code> on crates.io</p>
</a>
<a
href="https://github.com/donglora/client-py"
href="https://git.swaits.com/donglora/client-py"
class="bg-bg-card border border-border p-4 rounded hover:border-border-bright transition-colors block"
target="_blank"
rel="noopener noreferrer"
@@ -302,7 +296,7 @@ your code ──► client lib ──┘</pre>
<p class="text-sm text-text-muted mb-3">Optional daemon that lets multiple programs share a single dongle at the same time.</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-8">
<a
href="https://github.com/donglora/mux-rs"
href="https://git.swaits.com/donglora/mux-rs"
class="bg-bg-card border border-border p-4 rounded hover:border-border-bright transition-colors block"
target="_blank"
rel="noopener noreferrer"
@@ -311,7 +305,7 @@ your code ──► client lib ──┘</pre>
<p class="text-sm text-text-muted mt-1">USB multiplexer daemon (Rust)</p>
</a>
<a
href="https://github.com/donglora/mux-py"
href="https://git.swaits.com/donglora/mux-py"
class="bg-bg-card border border-border p-4 rounded hover:border-border-bright transition-colors block"
target="_blank"
rel="noopener noreferrer"
@@ -326,7 +320,7 @@ your code ──► client lib ──┘</pre>
<p class="text-sm text-text-muted mb-3">Real programs built on the layers above. Examples of what you can do with a DongLoRa dongle.</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<a
href="https://github.com/donglora/bridge"
href="https://git.swaits.com/donglora/bridge"
class="bg-bg-card border border-border p-4 rounded hover:border-border-bright transition-colors block"
target="_blank"
rel="noopener noreferrer"
@@ -335,7 +329,7 @@ your code ──► client lib ──┘</pre>
<p class="text-sm text-text-muted mt-1">P2P LoRa bridge &mdash; relays encrypted packets across the internet (Rust)</p>
</a>
<a
href="https://github.com/donglora/ai-bot"
href="https://git.swaits.com/donglora/ai-bot"
class="bg-bg-card border border-border p-4 rounded hover:border-border-bright transition-colors block"
target="_blank"
rel="noopener noreferrer"
@@ -344,7 +338,7 @@ your code ──► client lib ──┘</pre>
<p class="text-sm text-text-muted mt-1">MeshCore AI chatbot &mdash; mesh name "Orac" (Python)</p>
</a>
<a
href="https://github.com/donglora/examples"
href="https://git.swaits.com/donglora/examples"
class="bg-bg-card border border-border p-4 rounded hover:border-border-bright transition-colors block"
target="_blank"
rel="noopener noreferrer"
+146
View File
@@ -70,4 +70,150 @@
padding: 1rem;
font-size: 0.875rem;
}
/* Changelog typography (markdown rendered from GitHub release bodies).
Kept here rather than in VersionSelector.svelte because the Astro +
Svelte 5 build pipeline wasn't emitting the component's <style>
block into any CSS file, leaving the prod changelog unstyled. */
.changelog {
line-height: 1.65;
}
/* Top-level heading (e.g. `## [0.4.0] - 2026-04-11`) */
.changelog h1,
.changelog h2 {
font-family: var(--font-mono);
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
margin-top: 1.5rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border);
}
.changelog h1:first-child,
.changelog h2:first-child {
margin-top: 0;
}
/* Section heading (e.g. `### Added`) */
.changelog h3 {
font-family: var(--font-mono);
font-size: 0.8125rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text);
margin-top: 1.25rem;
margin-bottom: 0.5rem;
}
.changelog h3:first-child {
margin-top: 0;
}
/* Color-code Keep-a-Changelog sections */
.changelog h3[data-section="added"] { color: var(--color-accent); }
.changelog h3[data-section="changed"] { color: var(--color-warn); }
.changelog h3[data-section="fixed"] { color: var(--color-info); }
.changelog h3[data-section="removed"],
.changelog h3[data-section="deprecated"] { color: var(--color-error); }
.changelog h3[data-section="security"] { color: var(--color-warn); }
.changelog h4,
.changelog h5,
.changelog h6 {
font-family: var(--font-mono);
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text);
margin-top: 0.75rem;
margin-bottom: 0.25rem;
}
.changelog p {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
/* Lists */
.changelog ul,
.changelog ol {
margin-top: 0.25rem;
margin-bottom: 0.5rem;
padding-left: 1.4rem;
}
.changelog ul { list-style-type: disc; }
.changelog ol { list-style-type: decimal; }
.changelog li { margin-bottom: 0.35rem; }
.changelog li::marker { color: var(--color-text-dim); }
.changelog li > p { margin: 0; }
/* Nested lists: different marker, tighter spacing */
.changelog ul ul {
list-style-type: circle;
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
.changelog ul ul ul { list-style-type: square; }
/* Inline code */
.changelog code {
font-family: var(--font-mono);
font-size: 0.85em;
color: var(--color-accent);
background: var(--color-bg-elevated);
padding: 0.1em 0.35em;
border-radius: 3px;
}
/* Fenced code blocks */
.changelog pre {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.75rem 1rem;
margin: 0.75rem 0;
font-size: 0.8125rem;
overflow-x: auto;
}
.changelog pre code {
background: none;
padding: 0;
color: var(--color-text);
font-size: inherit;
}
/* Links */
.changelog a {
color: var(--color-accent);
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--color-accent) 40%, transparent);
text-underline-offset: 2px;
}
.changelog a:hover {
color: var(--color-accent-bright);
text-decoration-color: var(--color-accent-bright);
}
/* Horizontal rule (version separator) */
.changelog hr {
border: 0;
border-top: 1px solid var(--color-border);
margin: 1.5rem 0;
}
.changelog strong {
color: var(--color-text);
font-weight: 600;
}
.changelog em {
color: var(--color-text);
font-style: italic;
}
.changelog blockquote {
border-left: 3px solid var(--color-border-bright);
margin: 0.5rem 0;
padding: 0.25rem 0 0.25rem 1rem;
color: var(--color-text-dim);
}
}