Skip to content

Commit a6b333b

Browse files
authored
Merge pull request #113 from KMX415/feat/pluck-full-release-notes
Full release-notes modal on Updates
2 parents ad8b93c + a3210f4 commit a6b333b

6 files changed

Lines changed: 176 additions & 0 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/* Full release-notes modal (Updates panel). */
2+
3+
.update-release-notes__more {
4+
margin-top: 10px;
5+
background: none;
6+
border: none;
7+
padding: 0;
8+
color: var(--accent-cyan, #22d3ee);
9+
font: inherit;
10+
font-size: 12px;
11+
cursor: pointer;
12+
text-decoration: underline;
13+
}
14+
15+
.update-release-notes__more:hover {
16+
color: var(--text-primary, #e2e8f0);
17+
}
18+
19+
.rn-modal-overlay {
20+
position: fixed;
21+
inset: 0;
22+
z-index: 1000;
23+
background: rgba(3, 8, 20, 0.6);
24+
display: flex;
25+
align-items: center;
26+
justify-content: center;
27+
padding: 24px;
28+
}
29+
30+
.rn-modal {
31+
background: var(--bg-secondary, #12161f);
32+
border: 1px solid var(--border-color, #232a38);
33+
border-radius: 12px;
34+
width: min(760px, 100%);
35+
max-height: 84vh;
36+
display: flex;
37+
flex-direction: column;
38+
box-shadow: 0 24px 60px rgba(3, 8, 20, 0.5);
39+
}
40+
41+
.rn-modal__head {
42+
display: flex;
43+
align-items: center;
44+
justify-content: space-between;
45+
gap: 12px;
46+
padding: 16px 20px;
47+
border-bottom: 1px solid var(--border-color, #232a38);
48+
}
49+
50+
.rn-modal__title {
51+
margin: 0;
52+
font-size: 15px;
53+
color: var(--text-primary, #e2e8f0);
54+
}
55+
56+
.rn-modal__close {
57+
background: none;
58+
border: none;
59+
color: var(--text-muted, #64748b);
60+
font-size: 22px;
61+
line-height: 1;
62+
cursor: pointer;
63+
}
64+
65+
.rn-modal__close:hover {
66+
color: var(--text-primary, #e2e8f0);
67+
}
68+
69+
.rn-modal__list {
70+
margin: 0;
71+
padding: 16px 20px;
72+
overflow-y: auto;
73+
}

frontend/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
<link rel="stylesheet" href="css/radio_readout.css" />
3737
<link rel="stylesheet" href="css/stats.css" />
3838
<link rel="stylesheet" href="css/settings.css" />
39+
<link rel="stylesheet" href="css/update_release_notes_modal.css" />
3940
<link rel="stylesheet" href="css/configuration.css" />
4041
<link rel="stylesheet" href="css/gps.css" />
4142
<link rel="stylesheet" href="css/packet_detail_modal.css" />

frontend/js/settings/release_notes_view.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ class ReleaseNotesView {
5050
return;
5151
}
5252
const section = body.preview_section;
53+
// Full (un-truncated) notes for the "Read full release notes" modal.
54+
this._fullSection = body.full_section || null;
5355
const eyebrow = section.is_unreleased
5456
? "What's coming"
5557
: "What's new";
@@ -59,6 +61,9 @@ class ReleaseNotesView {
5961
const installed = body.current_installed_version
6062
? `<p class="update-release-notes__date">Installed: v${this._escape(body.current_installed_version)}</p>`
6163
: '';
64+
const moreBtn = (this._fullSection && (this._fullSection.bullets || []).length)
65+
? '<button type="button" class="update-release-notes__more" data-rn-more>Read full release notes</button>'
66+
: '';
6267
this.root.dataset.state = 'ready';
6368
this.root.innerHTML = `
6469
<header class="update-release-notes__head">
@@ -70,7 +75,44 @@ class ReleaseNotesView {
7075
<ul class="update-release-notes__list">
7176
${bullets || '<li class="update-release-notes__empty">No bullets in this section.</li>'}
7277
</ul>
78+
${moreBtn}
79+
`;
80+
this.root.querySelector('[data-rn-more]')
81+
?.addEventListener('click', () => this._openFullModal());
82+
}
83+
84+
_openFullModal() {
85+
if (!this._fullSection) return;
86+
const s = this._fullSection;
87+
const title = s.header || s.version || 'Release notes';
88+
const overlay = document.createElement('div');
89+
overlay.className = 'rn-modal-overlay';
90+
overlay.setAttribute('role', 'dialog');
91+
overlay.setAttribute('aria-modal', 'true');
92+
overlay.setAttribute('aria-label', 'Full release notes');
93+
overlay.innerHTML = `
94+
<div class="rn-modal">
95+
<header class="rn-modal__head">
96+
<h3 class="rn-modal__title">${this._escape(title)}</h3>
97+
<button type="button" class="rn-modal__close" aria-label="Close">&times;</button>
98+
</header>
99+
<ul class="rn-modal__list update-release-notes__list">
100+
${this._renderBullets(s.bullets || [])}
101+
</ul>
102+
</div>
73103
`;
104+
const close = () => {
105+
overlay.remove();
106+
document.removeEventListener('keydown', onKey);
107+
};
108+
const onKey = (e) => { if (e.key === 'Escape') close(); };
109+
overlay.addEventListener('click', (e) => {
110+
if (e.target === overlay) close();
111+
});
112+
overlay.querySelector('.rn-modal__close').addEventListener('click', close);
113+
document.addEventListener('keydown', onKey);
114+
document.body.appendChild(overlay);
115+
overlay.querySelector('.rn-modal__close').focus();
74116
}
75117

76118
_renderBullets(bullets) {

src/api/routes/update_routes.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from src.api.update.release_notes import (
4040
ChangelogParser,
4141
format_section_for_preview,
42+
format_section_full,
4243
select_preview_section,
4344
)
4445
from src.version import __version__ as INSTALLED_VERSION
@@ -167,6 +168,9 @@ async def release_notes(
167168
"preview_section": (
168169
format_section_for_preview(preview) if preview is not None else None
169170
),
171+
"full_section": (
172+
format_section_full(preview) if preview is not None else None
173+
),
170174
}
171175

172176

src/api/update/release_notes.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,15 @@ def sanitize_detail_for_preview(detail: str, *, max_len: int = _PREVIEW_DETAIL_M
154154
return cut.rstrip(".,;") + "…"
155155

156156

157+
def sanitize_detail_full(detail: str) -> str:
158+
"""De-markdown detail text for the full-notes modal (no truncation)."""
159+
if not detail:
160+
return ""
161+
text = _LINK_RE.sub(r"\1", detail)
162+
text = text.replace("`", "")
163+
return re.sub(r"\s+", " ", text).strip()
164+
165+
157166
def format_bullet_for_preview(bullet: ChangelogBullet) -> dict:
158167
"""Serialize one bullet for the dashboard (truncated detail)."""
159168
return {
@@ -163,6 +172,15 @@ def format_bullet_for_preview(bullet: ChangelogBullet) -> dict:
163172
}
164173

165174

175+
def format_bullet_full(bullet: ChangelogBullet) -> dict:
176+
"""Serialize one bullet with its full (un-truncated) detail."""
177+
return {
178+
"headline": bullet.headline,
179+
"detail": sanitize_detail_full(bullet.detail),
180+
"category": bullet.category,
181+
}
182+
183+
166184
def format_section_for_preview(section: ChangelogSection) -> dict:
167185
"""Serialize a section with operator-friendly bullets."""
168186
return {
@@ -174,6 +192,17 @@ def format_section_for_preview(section: ChangelogSection) -> dict:
174192
}
175193

176194

195+
def format_section_full(section: ChangelogSection) -> dict:
196+
"""Serialize a section with full-text bullets for the modal."""
197+
return {
198+
"header": section.header,
199+
"version": section.version,
200+
"date": section.date,
201+
"is_unreleased": section.is_unreleased,
202+
"bullets": [format_bullet_full(b) for b in section.bullets],
203+
}
204+
205+
177206
def _version_tuple(version: str) -> tuple[int, ...]:
178207
parts: list[int] = []
179208
for piece in version.split("."):

tests/test_update_release_notes.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
ChangelogParser,
1414
ChangelogSection,
1515
format_section_for_preview,
16+
format_section_full,
1617
sanitize_detail_for_preview,
18+
sanitize_detail_full,
1719
select_preview_section,
1820
)
1921

@@ -306,5 +308,30 @@ def test_format_section_truncates_bullet_details(self) -> None:
306308
self.assertTrue(payload["bullets"][1]["detail"].endswith("…"))
307309

308310

311+
class TestFullFormatting(unittest.TestCase):
312+
def test_sanitize_detail_full_keeps_long_text(self) -> None:
313+
long = "A" * 300
314+
self.assertEqual(sanitize_detail_full(long), long)
315+
316+
def test_format_section_full_does_not_truncate(self) -> None:
317+
from src.api.update.release_notes import ChangelogBullet
318+
319+
detail = "See [docs](https://example.com) and `code` path. " + ("B" * 200)
320+
section = ChangelogSection(
321+
header="v0.7.3.1",
322+
version="0.7.3.1",
323+
date=None,
324+
is_unreleased=False,
325+
bullets=[ChangelogBullet(headline="Long", detail=detail)],
326+
)
327+
payload = format_section_full(section)
328+
out = payload["bullets"][0]["detail"]
329+
self.assertNotIn("…", out)
330+
self.assertIn("docs", out)
331+
self.assertNotIn("https://", out)
332+
self.assertNotIn("`", out)
333+
self.assertGreater(len(out), 200)
334+
335+
309336
if __name__ == "__main__":
310337
unittest.main()

0 commit comments

Comments
 (0)