astro-better-release-notes 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,158 @@
1
+ # astro-better-release-notes
2
+
3
+ Configurable release notes components for Astro with category chip filtering, sticky version navigation, and deep-linkable release note items.
4
+
5
+ ## Installation
6
+
7
+ ```
8
+ npm install astro-better-release-notes
9
+ ```
10
+
11
+ Import the stylesheet once in your root layout:
12
+
13
+ ```astro
14
+ ---
15
+ import 'astro-better-release-notes/style.css';
16
+ ---
17
+ ```
18
+
19
+ ## Components
20
+
21
+ - `ReleaseNotes` - renders the filter chips and the full version list
22
+ - `ReleaseNotesItem` - a single categorized release note row (used inside MDX content)
23
+ - `ReleaseNotesSelector` - a sticky version jump dropdown
24
+
25
+ ## Usage
26
+
27
+ ### Content collection setup
28
+
29
+ Your content collection entries must have at minimum:
30
+
31
+ ```ts
32
+ // src/content.config.ts
33
+ const releases = defineCollection({
34
+ schema: z.object({
35
+ version: z.string(),
36
+ date: z.coerce.date(),
37
+ name: z.string().optional(),
38
+ }),
39
+ });
40
+ ```
41
+
42
+ Each `.mdx` file in the collection uses `ReleaseNotesItem`:
43
+
44
+ ```mdx
45
+ ---
46
+ version: "2.1.0"
47
+ date: 2024-05-01
48
+ ---
49
+ import { ReleaseNotesItem } from 'astro-better-release-notes';
50
+
51
+ <ReleaseNotesItem category="new-feature">Added dark mode.</ReleaseNotesItem>
52
+ <ReleaseNotesItem category="fix" issue="42" issueBaseUrl="https://github.com/org/repo/issues/">Fixed login redirect loop.</ReleaseNotesItem>
53
+ ```
54
+
55
+ ### Page component
56
+
57
+ ```astro
58
+ ---
59
+ import { getCollection, render } from 'astro:content';
60
+ import {
61
+ ReleaseNotes,
62
+ ReleaseNotesSelector,
63
+ DEFAULT_CATEGORIES,
64
+ } from 'astro-better-release-notes';
65
+
66
+ const allUpdates = await getCollection('releases');
67
+ const updates = allUpdates
68
+ .sort((a, b) => {
69
+ const d = b.data.date.valueOf() - a.data.date.valueOf();
70
+ return d !== 0 ? d : b.data.version.localeCompare(a.data.version, undefined, { numeric: true });
71
+ });
72
+
73
+ const rendered = await Promise.all(
74
+ updates.map(async item => {
75
+ const { Content } = await render(item);
76
+ return { data: item.data, Content };
77
+ })
78
+ );
79
+ ---
80
+
81
+ <ReleaseNotesSelector updates={rendered} />
82
+ <ReleaseNotes updates={rendered} categories={DEFAULT_CATEGORIES} />
83
+ ```
84
+
85
+ ## Custom categories
86
+
87
+ Pass a `categories` array to `ReleaseNotes` to define your own set:
88
+
89
+ ```js
90
+ const MY_CATEGORIES = [
91
+ { id: 'new-feature', label: 'New Feature', color: '#16a34a' },
92
+ { id: 'fix', label: 'Fix', color: '#d97706' },
93
+ { id: 'security', label: 'Security', color: '#566f98' },
94
+ ];
95
+ ```
96
+
97
+ Category IDs must match the `category` prop values used in your MDX files.
98
+
99
+ ## Custom ReleaseNotesItem
100
+
101
+ To inject project-specific behavior (e.g. a fixed issue base URL), pass a wrapper component via the `components` prop:
102
+
103
+ ```astro
104
+ ---
105
+ // src/components/MyReleaseNotesItem.astro
106
+ import { ReleaseNotesItem } from 'astro-better-release-notes';
107
+ const { issue, bonusIssue, ...rest } = Astro.props;
108
+ const base = 'https://github.com/org/repo/issues/';
109
+ ---
110
+ <ReleaseNotesItem
111
+ {...rest}
112
+ issue={issue}
113
+ bonusIssue={bonusIssue}
114
+ issueBaseUrl={base}
115
+ categoryColor={rest.categoryColor}
116
+ categoryLabel={rest.categoryLabel}
117
+ ><slot /></ReleaseNotesItem>
118
+ ```
119
+
120
+ ```astro
121
+ <ReleaseNotes
122
+ updates={rendered}
123
+ categories={MY_CATEGORIES}
124
+ components={{ ReleaseNotesItem: MyReleaseNotesItem }}
125
+ />
126
+ ```
127
+
128
+ Note: for the `components` injection to work with categories, you also need to pass `categoryColor` and `categoryLabel` in your wrapper. Use the exported helpers:
129
+
130
+ ```js
131
+ import { getCategoryColor, getCategoryLabel, DEFAULT_CATEGORIES } from 'astro-better-release-notes';
132
+ ```
133
+
134
+ Or provide these values in your own wrapper by computing them from your category list.
135
+
136
+ ## ReleaseNotesItem props
137
+
138
+ | Prop | Type | Description |
139
+ |------|------|-------------|
140
+ | `category` | `string` | Category ID; must match a configured category |
141
+ | `issue` | `string` | Issue number shown as a link at row end |
142
+ | `bonusIssue` | `string` | Secondary issue number |
143
+ | `thanks` | `string` | GitHub username to credit |
144
+ | `bonusThanks` | `string` | Secondary GitHub username |
145
+ | `since` | `string` | Version this was introduced |
146
+ | `resolvedIn` | `string` | Version a known issue was resolved in |
147
+ | `viaIssue` | `string` | Issue that resolved a known issue |
148
+ | `issueBaseUrl` | `string` | Base URL for issue links (no default) |
149
+ | `categoryColor` | `string` | CSS color for the badge (default `#71717a`) |
150
+ | `categoryLabel` | `string` | Human label for the badge (default: generated from id) |
151
+
152
+ ## Injecting category color and label automatically
153
+
154
+ `ReleaseNotes.astro` does not automatically inject `categoryColor`/`categoryLabel` into the MDX-rendered items -- those props come from the MDX markup. To automate this, create a wrapper that looks up the category in your list and injects the values, then pass it via `components`.
155
+
156
+ ## Dark mode
157
+
158
+ The stylesheet supports both `@media (prefers-color-scheme: dark)` and a `.dark` ancestor class.
@@ -0,0 +1,98 @@
1
+ ---
2
+ import ReleaseNotesItem from './ReleaseNotesItem.astro';
3
+
4
+ export interface Category {
5
+ id: string;
6
+ label: string;
7
+ color: string;
8
+ }
9
+
10
+ export const DEFAULT_CATEGORIES: Category[] = [
11
+ { id: 'new-feature', label: 'New Feature', color: '#16a34a' },
12
+ { id: 'enhancement', label: 'Enhancement', color: '#2563eb' },
13
+ { id: 'fix', label: 'Fix', color: '#d97706' },
14
+ { id: 'security', label: 'Security', color: '#566f98' },
15
+ { id: 'deprecated', label: 'Deprecated', color: '#ffcc48' },
16
+ { id: 'breaking-change', label: 'Breaking Change', color: '#aaaa33' },
17
+ { id: 'known-issue', label: 'Known Issue', color: '#bb4448' },
18
+ { id: 'internal', label: 'Internal', color: '#71717a' },
19
+ ];
20
+
21
+ export const getCategoryColor = (id: string, categories: Category[]) =>
22
+ categories.find(c => c.id === id)?.color ?? '#71717a';
23
+
24
+ export const getCategoryLabel = (id: string, categories: Category[]) => {
25
+ const cat = categories.find(c => c.id === id);
26
+ if (cat) return cat.label;
27
+ return id.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
28
+ };
29
+
30
+ export interface ReleaseEntry {
31
+ data: {
32
+ version: string;
33
+ date: Date;
34
+ name?: string;
35
+ };
36
+ Content: any;
37
+ }
38
+
39
+ export interface Props {
40
+ updates: ReleaseEntry[];
41
+ categories?: Category[];
42
+ // pass { ReleaseNotesItem: YourWrapper } to inject a custom item component
43
+ components?: Record<string, any>;
44
+ }
45
+
46
+ const {
47
+ updates,
48
+ categories = DEFAULT_CATEGORIES,
49
+ components: extraComponents = {},
50
+ } = Astro.props as Props;
51
+
52
+ const mergedComponents = { ReleaseNotesItem, ...extraComponents };
53
+
54
+ // generate filter CSS from the configured categories
55
+ const filterCSS = [
56
+ '.release-container:has(.filter-input:checked) .issue-row { display: none !important; }',
57
+ ...categories.map(cat =>
58
+ `.release-container:has(#toggle-${cat.id}:checked) [data-category="${cat.id}"] { display: flex !important; }`
59
+ ),
60
+ ].join('\n');
61
+ ---
62
+
63
+ <Fragment set:html={`<style>${filterCSS}</style>`} />
64
+
65
+ <div class="release-container">
66
+ <nav class="filter-controls" aria-label="Filter release notes by category">
67
+ {categories.map(cat => (
68
+ <label class="filter-chip" style={`--cat-color: ${cat.color}`}>
69
+ <input type="checkbox" id={`toggle-${cat.id}`} class="filter-input" aria-label={`Show ${cat.label}`} />
70
+ <span aria-hidden="true">{cat.label}</span>
71
+ </label>
72
+ ))}
73
+ </nav>
74
+
75
+ <div class="version-list">
76
+ {updates.map((item) => {
77
+ const { Content } = item;
78
+ const versionId = `version-${item.data.version.replaceAll('.', '-')}`;
79
+ return (
80
+ <article id={versionId} class="version">
81
+ <header class="version-header">
82
+ <h2 class="release-note group">
83
+ Version {item.data.version} {item.data.name && `(${item.data.name})`}
84
+ <a class="anchor-link" title="copy header link" href={`#${versionId}`} aria-label={`Permalink to version ${item.data.version}`}>#</a>
85
+ </h2>
86
+ <time class="version-date" datetime={item.data.date.toISOString()}>
87
+ {item.data.date.toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })}
88
+ </time>
89
+ </header>
90
+
91
+ <div class="version-content">
92
+ <Content components={mergedComponents} />
93
+ </div>
94
+ </article>
95
+ );
96
+ })}
97
+ </div>
98
+ </div>
@@ -0,0 +1,117 @@
1
+ ---
2
+ export interface Props {
3
+ category: string;
4
+ issue?: string;
5
+ bonusIssue?: string;
6
+ thanks?: string;
7
+ bonusThanks?: string;
8
+ since?: string;
9
+ resolvedIn?: string;
10
+ viaIssue?: string;
11
+ // base URL for issue links, e.g. "https://github.com/org/repo/issues/"
12
+ // if omitted, issue numbers are shown as plain text
13
+ issueBaseUrl?: string;
14
+ // color for this item's badge; if omitted, uses a neutral gray
15
+ categoryColor?: string;
16
+ // human-readable category label; if omitted, generated from id
17
+ categoryLabel?: string;
18
+ }
19
+
20
+ const {
21
+ category = 'fix',
22
+ issue,
23
+ bonusIssue,
24
+ thanks,
25
+ bonusThanks,
26
+ since,
27
+ resolvedIn,
28
+ viaIssue,
29
+ issueBaseUrl = '',
30
+ categoryColor = '#71717a',
31
+ categoryLabel,
32
+ } = Astro.props as Props;
33
+
34
+ const label = categoryLabel
35
+ ?? category.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
36
+
37
+ const versionPart = resolvedIn
38
+ ? `v-${resolvedIn.replaceAll('.', '-')}`
39
+ : (since ? `since-${since.replaceAll('.', '-')}` : '');
40
+
41
+ const issuePart = issue
42
+ ? `issue-${issue}`
43
+ : (viaIssue ? `via-${viaIssue}` : '');
44
+
45
+ const thanksPart = thanks
46
+ ? `thanks-${thanks.toLowerCase().replace(/[^a-z0-9]/g, '-')}`
47
+ : '';
48
+
49
+ const baseId = [versionPart, category, issuePart, thanksPart].filter(Boolean).join('--');
50
+
51
+ // deterministic content hash for unique deep-link IDs across builds
52
+ const slotContent = await Astro.slots.render('default');
53
+ let hash = 0;
54
+ for (let i = 0; i < slotContent.length; i++) {
55
+ hash = (hash << 5) - hash + slotContent.charCodeAt(i);
56
+ hash |= 0;
57
+ }
58
+ const contentSlug = Math.abs(hash).toString(36).substring(0, 4);
59
+ const rowId = baseId ? `${baseId}--${contentSlug}` : contentSlug;
60
+ ---
61
+
62
+ <div
63
+ id={rowId}
64
+ class="issue-row"
65
+ data-category={category}
66
+ style={`--cat-color: ${categoryColor}`}
67
+ >
68
+ <a
69
+ href={`#${rowId}`}
70
+ class="issue-badge"
71
+ aria-label={`${label} - permalink to this release note`}
72
+ >
73
+ {label}
74
+ </a>
75
+
76
+ <div class="issue-content">
77
+ <div class="rni-body">
78
+ <slot />
79
+ {resolvedIn && viaIssue && (
80
+ <p class="rni-meta">
81
+ Resolved in version{' '}
82
+ <a href={`#version-${resolvedIn.replaceAll('.', '-')}`}><code>{resolvedIn}</code></a>
83
+ {' '}via{' '}
84
+ {issueBaseUrl
85
+ ? <a target="_blank" rel="noopener noreferrer" href={`${issueBaseUrl}${viaIssue}`}>#{viaIssue}</a>
86
+ : <span>#{viaIssue}</span>
87
+ }.
88
+ </p>
89
+ )}
90
+ {since && (
91
+ <p class="rni-meta">
92
+ Since: <a href={`#version-${since.replaceAll('.', '-')}`}><code>{since}</code></a>
93
+ </p>
94
+ )}
95
+ {thanks && (
96
+ <p class="rni-meta rni-thanks">
97
+ Thanks to{' '}
98
+ <a href={`https://github.com/${thanks}`}>@{thanks}</a>
99
+ {bonusThanks && (
100
+ <span> and <a href={`https://github.com/${bonusThanks}`}>@{bonusThanks}</a></span>
101
+ )}!
102
+ </p>
103
+ )}
104
+ </div>
105
+ </div>
106
+
107
+ {issue && (
108
+ issueBaseUrl
109
+ ? <a class="github-link" target="_blank" rel="noopener noreferrer" href={`${issueBaseUrl}${issue}`}>#{issue}</a>
110
+ : <span class="github-link">#{issue}</span>
111
+ )}
112
+ {bonusIssue && (
113
+ issueBaseUrl
114
+ ? <a class="github-link" target="_blank" rel="noopener noreferrer" href={`${issueBaseUrl}${bonusIssue}`}>#{bonusIssue}</a>
115
+ : <span class="github-link">#{bonusIssue}</span>
116
+ )}
117
+ </div>
@@ -0,0 +1,109 @@
1
+ ---
2
+ export interface SelectorEntry {
3
+ data: {
4
+ version: string;
5
+ date: Date;
6
+ name?: string;
7
+ };
8
+ }
9
+
10
+ export interface Props {
11
+ updates: SelectorEntry[];
12
+ }
13
+
14
+ const { updates } = Astro.props as Props;
15
+ ---
16
+
17
+ <div class="release-selector">
18
+ <label for="release-notes-version" class="sr-only">Jump to a release version</label>
19
+ <select
20
+ id="release-notes-version"
21
+ class="release-selector-select"
22
+ >
23
+ <option value="">Jump to a version...</option>
24
+ {updates.map((item) => {
25
+ const versionId = `version-${item.data.version.replaceAll('.', '-')}`;
26
+ const label = `Version ${item.data.version}${item.data.name ? ` (${item.data.name})` : ''}`;
27
+ return <option value={versionId}>{label}</option>;
28
+ })}
29
+ </select>
30
+ </div>
31
+
32
+ <script is:inline>
33
+ const initVersionSpy = () => {
34
+ const selectEl = document.getElementById('release-notes-version');
35
+ if (!selectEl) return;
36
+
37
+ const placeholderOption = selectEl.querySelector('option[value=""]');
38
+ const versions = Array.from(document.querySelectorAll('article.version[id]'));
39
+ if (versions.length === 0) return;
40
+
41
+ const HEADER_OFFSET = 210;
42
+ let currentActiveId = null;
43
+ let isTicking = false;
44
+ let hasUserInteracted = false;
45
+
46
+ const disablePlaceholder = () => {
47
+ if (placeholderOption && !placeholderOption.disabled) {
48
+ placeholderOption.disabled = true;
49
+ placeholderOption.hidden = true;
50
+ }
51
+ };
52
+
53
+ const updateScrollSpy = () => {
54
+ if (window.scrollY === 0 && !hasUserInteracted) return;
55
+
56
+ let activeArticle = versions[0];
57
+ for (let i = versions.length - 1; i >= 0; i--) {
58
+ if (versions[i].getBoundingClientRect().top < HEADER_OFFSET) {
59
+ activeArticle = versions[i];
60
+ break;
61
+ }
62
+ }
63
+
64
+ if (activeArticle && activeArticle.id !== currentActiveId) {
65
+ currentActiveId = activeArticle.id;
66
+ selectEl.value = activeArticle.id;
67
+ disablePlaceholder();
68
+ }
69
+ };
70
+
71
+ const onScroll = () => {
72
+ if (window.scrollY > 0) hasUserInteracted = true;
73
+ if (!isTicking) {
74
+ window.requestAnimationFrame(() => {
75
+ updateScrollSpy();
76
+ isTicking = false;
77
+ });
78
+ isTicking = true;
79
+ }
80
+ };
81
+
82
+ // navigate on change; aria-live on the region announces the jump for screen readers
83
+ selectEl.addEventListener('change', () => {
84
+ hasUserInteracted = true;
85
+ disablePlaceholder();
86
+ if (selectEl.value) window.location.hash = selectEl.value;
87
+ });
88
+
89
+ const filterControls = document.querySelector('.filter-controls');
90
+ if (filterControls) {
91
+ filterControls.addEventListener('change', () => {
92
+ const targetVersion = versions.find(a => a.id === currentActiveId) || versions[0];
93
+ window.requestAnimationFrame(() => {
94
+ if (targetVersion && hasUserInteracted) {
95
+ const adjustment = targetVersion.getBoundingClientRect().top - HEADER_OFFSET;
96
+ window.scrollBy({ top: adjustment, behavior: 'instant' });
97
+ }
98
+ updateScrollSpy();
99
+ });
100
+ });
101
+ }
102
+
103
+ window.addEventListener('scroll', onScroll, { passive: true });
104
+ updateScrollSpy();
105
+ };
106
+
107
+ document.addEventListener('DOMContentLoaded', initVersionSpy);
108
+ document.addEventListener('astro:page-load', initVersionSpy);
109
+ </script>
package/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { default as ReleaseNotes, DEFAULT_CATEGORIES, getCategoryColor, getCategoryLabel } from './ReleaseNotes.astro';
2
+ export { default as ReleaseNotesItem } from './ReleaseNotesItem.astro';
3
+ export { default as ReleaseNotesSelector } from './ReleaseNotesSelector.astro';
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "astro-better-release-notes",
3
+ "version": "0.1.0",
4
+ "description": "Configurable release notes components with category filtering and version navigation for Astro",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./index.js",
8
+ "./style.css": "./style.css",
9
+ "./ReleaseNotes.astro": "./ReleaseNotes.astro",
10
+ "./ReleaseNotesItem.astro": "./ReleaseNotesItem.astro",
11
+ "./ReleaseNotesSelector.astro": "./ReleaseNotesSelector.astro"
12
+ },
13
+ "files": [
14
+ "*.js",
15
+ "*.astro",
16
+ "*.css"
17
+ ],
18
+ "keywords": ["astro", "release-notes", "changelog", "filter", "categories"],
19
+ "license": "MIT"
20
+ }
package/style.css ADDED
@@ -0,0 +1,234 @@
1
+ /*
2
+ astro-better-release-notes/style.css
3
+
4
+ Import this in your project root layout to enable release notes styles.
5
+ No external dependencies -- no Tailwind, no framework.
6
+
7
+ Dark mode: supports both @media (prefers-color-scheme: dark) and a .dark
8
+ ancestor class (e.g. Tailwind's darkMode: 'class').
9
+
10
+ Category colors are driven by --cat-color, set inline by ReleaseNotesItem.
11
+ */
12
+
13
+ /* ---------------------------------------------------------------
14
+ Filter controls (category chips)
15
+ --------------------------------------------------------------- */
16
+
17
+ .filter-controls {
18
+ display: flex;
19
+ flex-wrap: wrap;
20
+ gap: 0.75rem;
21
+ }
22
+
23
+ .filter-input {
24
+ display: none;
25
+ }
26
+
27
+ .filter-chip {
28
+ padding: 0.5rem 1rem;
29
+ font-size: 0.875rem;
30
+ font-weight: 500;
31
+ border-radius: 9999px;
32
+ cursor: pointer;
33
+ transition: filter 0.15s ease-in-out;
34
+ opacity: 0.7;
35
+ filter: grayscale(100%);
36
+ color: #334155;
37
+ background-color: #f1f5f9;
38
+ border: 1px solid #cbd5e1;
39
+ }
40
+
41
+ @media (prefers-color-scheme: dark) {
42
+ .filter-chip {
43
+ color: #cbd5e1;
44
+ background-color: #1e293b;
45
+ border-color: #475569;
46
+ }
47
+ }
48
+
49
+ .dark .filter-chip {
50
+ color: #cbd5e1;
51
+ background-color: #1e293b;
52
+ border-color: #475569;
53
+ }
54
+
55
+ .filter-chip:hover {
56
+ filter: grayscale(100%) brightness(0.95);
57
+ }
58
+
59
+ @media (prefers-color-scheme: dark) {
60
+ .filter-chip:hover {
61
+ filter: grayscale(100%) brightness(1.1);
62
+ }
63
+ }
64
+
65
+ .dark .filter-chip:hover {
66
+ filter: grayscale(100%) brightness(1.1);
67
+ }
68
+
69
+ .filter-chip:has(input:checked) {
70
+ filter: grayscale(0);
71
+ opacity: 1;
72
+ background-color: var(--cat-color) !important;
73
+ border-color: var(--cat-color) !important;
74
+ color: #fff !important;
75
+ }
76
+
77
+ /* ---------------------------------------------------------------
78
+ Release note rows
79
+ Note: per-category show/hide rules are generated dynamically
80
+ by ReleaseNotes.astro based on the configured categories.
81
+ --------------------------------------------------------------- */
82
+
83
+ .issue-row {
84
+ display: flex;
85
+ align-items: baseline;
86
+ gap: 1rem;
87
+ margin-bottom: 2.5rem;
88
+ border-radius: 6px;
89
+ transition: border-color 0.2s;
90
+ padding: 6px 8px;
91
+ border: 2px solid transparent;
92
+ scroll-margin-top: 5rem;
93
+ }
94
+
95
+ @media (min-width: 768px) {
96
+ .issue-row {
97
+ margin-bottom: 1rem;
98
+ }
99
+ }
100
+
101
+ .issue-row:target {
102
+ border-color: rgba(148, 163, 184, 0.5);
103
+ }
104
+
105
+ .issue-badge {
106
+ width: 7rem;
107
+ padding: 4px 8px;
108
+ flex-shrink: 0;
109
+ text-align: center;
110
+ font-size: 0.75rem;
111
+ font-weight: 700;
112
+ border-radius: 2px;
113
+ text-transform: uppercase;
114
+ text-decoration: none;
115
+ cursor: pointer;
116
+ user-select: none;
117
+ transition: transform 0.15s ease-out;
118
+ transform-origin: left center;
119
+ background-color: color-mix(in srgb, var(--cat-color) 13%, transparent);
120
+ color: var(--cat-color);
121
+ border: 1px solid color-mix(in srgb, var(--cat-color) 27%, transparent);
122
+ }
123
+
124
+ .issue-badge:hover { transform: scale(1.05); }
125
+ .issue-badge:active { transform: scale(0.95); }
126
+
127
+ .issue-content {
128
+ flex: 1;
129
+ min-width: 0;
130
+ }
131
+
132
+ .rni-body {
133
+ max-width: 100%;
134
+ overflow-x: auto;
135
+ }
136
+
137
+ .rni-meta {
138
+ font-size: 0.875rem;
139
+ margin-top: 0.25rem;
140
+ margin-bottom: 0;
141
+ }
142
+
143
+ .rni-thanks {
144
+ font-style: italic;
145
+ color: #475569;
146
+ }
147
+
148
+ @media (prefers-color-scheme: dark) {
149
+ .rni-thanks { color: #94a3b8; }
150
+ }
151
+
152
+ .dark .rni-thanks { color: #94a3b8; }
153
+
154
+ .github-link {
155
+ flex-shrink: 0;
156
+ font-size: 0.875rem;
157
+ text-decoration: none;
158
+ opacity: 0.7;
159
+ color: inherit;
160
+ }
161
+
162
+ .github-link:hover { opacity: 1; text-decoration: underline; }
163
+
164
+ /* ---------------------------------------------------------------
165
+ Version articles
166
+ --------------------------------------------------------------- */
167
+
168
+ .version {
169
+ content-visibility: auto;
170
+ contain-intrinsic-size: 0 800px;
171
+ }
172
+
173
+ .version-header {
174
+ display: flex;
175
+ flex-wrap: wrap;
176
+ align-items: baseline;
177
+ justify-content: space-between;
178
+ padding-top: 0.75rem;
179
+ padding-bottom: 0.75rem;
180
+ margin-bottom: 0.5rem;
181
+ border-bottom: 1px solid #e2e8f0;
182
+ }
183
+
184
+ @media (prefers-color-scheme: dark) {
185
+ .version-header { border-bottom-color: #1e293b; }
186
+ }
187
+
188
+ .dark .version-header { border-bottom-color: #1e293b; }
189
+
190
+ .version-date {
191
+ font-size: 0.875rem;
192
+ color: #64748b;
193
+ }
194
+
195
+ /* ---------------------------------------------------------------
196
+ Version selector (jump dropdown)
197
+ --------------------------------------------------------------- */
198
+
199
+ .release-selector {
200
+ position: sticky;
201
+ top: 2.75rem;
202
+ z-index: 10;
203
+ padding: 0.75rem 0;
204
+ }
205
+
206
+ .release-selector-select {
207
+ width: 100%;
208
+ height: 2.5rem;
209
+ padding: 0 0.5rem;
210
+ border-radius: 6px;
211
+ border: 2px solid #d1d5db;
212
+ background-color: #f3f4f6;
213
+ color: #111827;
214
+ cursor: pointer;
215
+ }
216
+
217
+ .release-selector-select:focus {
218
+ outline: 2px solid #6366f1;
219
+ outline-offset: 1px;
220
+ }
221
+
222
+ @media (prefers-color-scheme: dark) {
223
+ .release-selector-select {
224
+ border-color: #4b5563;
225
+ background-color: #1e293b;
226
+ color: #cbd5e1;
227
+ }
228
+ }
229
+
230
+ .dark .release-selector-select {
231
+ border-color: #4b5563;
232
+ background-color: #1e293b;
233
+ color: #cbd5e1;
234
+ }