From ed60d7ed6b4ab351ea0386f09506e96ad96984e1 Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Sat, 1 Aug 2026 13:15:59 +0200 Subject: [PATCH 1/2] docs(sips): add live SIP index page sourced from sei-protocol/sips Five pages link out to individual SIPs, but the docs never listed them, so readers had to browse the repository to see what exists or what state a proposal is in. This adds a Learn page under Governance that indexes every proposal. The listing is a snippet rather than a copied table. It reads sei-protocol/sips at page load, so status, type, authorship and the section outline reflect the source instead of drifting from it. - snippets/sip-index.jsx: fetches the sips/ directory listing, parses each proposal's header table and headings, and renders a card per SIP with links to the source file and its discussion thread. - learn/sips.mdx: the page. Around the live list it documents the three proposal types, five categories and eleven lifecycle statuses, each row linking into the matching SIP-1 section rather than restating it. - docs.json: add learn/sips to the Learn > Governance group. Implementation notes: - Header parsing normalizes field names because they differ between SIPs (SIP Number vs SIP-Number, Comments vs Comments-URI, Author vs Authors, Category folded into Type vs its own row), and stops at the first section heading so body tables cannot leak into the metadata. - Outline links reproduce GitHub's heading-anchor algorithm. All 121 anchors were diffed against GitHub's rendered output for the five current SIPs and match exactly. - The GitHub contents API is rate limited to 60 requests per hour per visitor IP, so the snippet falls back to walking sip-N.md off the raw CDN and caches the parsed index in localStorage for an hour. - No redirect entry, since this is a new path rather than a moved one. Co-Authored-By: Claude Opus 5 Co-authored-by: Cursor --- docs.json | 1 + learn/sips.mdx | 121 +++++++++++++++ snippets/sip-index.jsx | 330 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 452 insertions(+) create mode 100644 learn/sips.mdx create mode 100644 snippets/sip-index.jsx diff --git a/docs.json b/docs.json index a582267..94d6179 100644 --- a/docs.json +++ b/docs.json @@ -78,6 +78,7 @@ "pages": [ "learn/general-governance", "learn/proposals", + "learn/sips", "learn/general-staking" ] }, diff --git a/learn/sips.mdx b/learn/sips.mdx new file mode 100644 index 0000000..ec8480d --- /dev/null +++ b/learn/sips.mdx @@ -0,0 +1,121 @@ +--- +title: 'Sei Improvement Proposals' +sidebarTitle: 'SIPs' +description: 'Browse every Sei Improvement Proposal (SIP) with status, type, authorship, and section outlines pulled live from the sei-protocol/sips repository.' +keywords: ['SIP', 'Sei Improvement Proposal', 'sei governance', 'protocol proposals', 'SIP process', 'SIP status', 'SIP-1', 'SIP-3', 'sei-protocol/sips'] +--- + +import { SipIndex } from '/snippets/sip-index.jsx'; + +A Sei Improvement Proposal (SIP) is a design document for a change to Sei. It might cover a new protocol feature, a change to how the project runs, or a convention for the wider ecosystem. Writing one is how you put an idea in front of the community, and how the reasoning behind a decision gets recorded. + +Every SIP lives in the [sei-protocol/sips](https://github.com/sei-protocol/sips) repository, and the list below reads from it directly. Titles, statuses, authors, and section outlines come from the source files rather than a copy that can fall behind. + + + +A SIP is not the same as an on-chain governance proposal. The SIP holds the design and the reasoning; the on-chain proposal is the vote that enacts it. One SIP can take several governance proposals to carry out, as SIP-3 did. For the on-chain process, see [Governance](/learn/general-governance) and [Proposals](/learn/proposals). + + + +## All proposals + + + +## Proposal types + +Every SIP declares a type, and the type decides how much community consensus it needs. [SIP-1](https://github.com/sei-protocol/sips/blob/main/sips/sip-1.md#sip-type) defines all three. + +| Type | Scope | Needs consensus | +| --- | --- | --- | +| [Standard](https://github.com/sei-protocol/sips/blob/main/sips/sip-1.md#standard) | Changes that affect most implementations of Sei, such as consensus, networking, RPC, EVM and CosmWasm behavior, and contract standards. Must also declare a category. | Yes | +| [Process](https://github.com/sei-protocol/sips/blob/main/sips/sip-1.md#process) | Changes around Sei rather than inside it, such as developer tooling, node and validator procedures, and the SIP process itself. | Yes | +| [Informational](https://github.com/sei-protocol/sips/blob/main/sips/sip-1.md#informational) | Guidelines or information for the community, with no change to the protocol or development environment. | No | + +### Categories + +A Standard SIP must also declare one of five [categories](https://github.com/sei-protocol/sips/blob/main/sips/sip-1.md#sip-category). + +| Category | Covers | +| --- | --- | +| Core | Consensus, execution, storage, and account signatures | +| Networking | Mempool and network protocols | +| Interface | RPC specifications and lower-level naming conventions | +| Framework | EVM and CosmWasm contracts and primitives included in the Sei codebase | +| Application | New EVM or CosmWasm standards and primitives that sit outside the Sei codebase | + +## Status lifecycle + +A successful SIP usually moves through Draft, Review, Last Call, Final, Implemented, and Activated. Each card above shows the status recorded in that proposal's own source file. + +| Status | What it means | +| --- | --- | +| [Idea](https://github.com/sei-protocol/sips/blob/main/sips/sip-1.md#idea) | Written, but not yet admitted to the process and not yet numbered. | +| [Draft](https://github.com/sei-protocol/sips/blob/main/sips/sip-1.md#draft) | An editor has accepted the formatting and assigned a number. The author now gathers community feedback. | +| [Review](https://github.com/sei-protocol/sips/blob/main/sips/sip-1.md#review) | The author has worked through early feedback. Assigned reviewers now give a formal review, and it takes at least three of them. | +| [Fast Track](https://github.com/sei-protocol/sips/blob/main/sips/sip-1.md#fast-track) | For proposals that already reached consensus outside the SIP process. Runs the same way as Review. | +| [Last Call](https://github.com/sei-protocol/sips/blob/main/sips/sip-1.md#last-call) | A final window for objections, usually at least one week. | +| [Final](https://github.com/sei-protocol/sips/blob/main/sips/sip-1.md#final) | Accepted, and the specification is now frozen. | +| [Implemented](https://github.com/sei-protocol/sips/blob/main/sips/sip-1.md#implemented) | Development is complete, but the feature is not yet live on mainnet. | +| [Activated](https://github.com/sei-protocol/sips/blob/main/sips/sip-1.md#activated) | Live on Sei mainnet. | +| [Stagnant](https://github.com/sei-protocol/sips/blob/main/sips/sip-1.md#stagnant) | Inactive for four weeks while in Draft, Review, or Last Call. | +| [Withdrawn](https://github.com/sei-protocol/sips/blob/main/sips/sip-1.md#withdrawn) | The author has withdrawn the proposal. | +| [Living](https://github.com/sei-protocol/sips/blob/main/sips/sip-1.md#living) | A continuously updated proposal that never reaches Final, such as SIP-1 itself. | + + + +Once a SIP reaches Final, nobody can withdraw or edit it. To change an accepted specification, submit a new SIP that extends or replaces the original. + + + +## Guides for specific proposals + +SIP-3 and SIP-4 both affect how you use and build on Sei. These pages cover what they mean in practice. + + + + Move assets off Cosmos-native addresses and IBC tokens before Sei becomes EVM-only. + + + Integration changes for exchanges and custodians supporting Sei deposits and withdrawals. + + + How Sei's EIP-1559 implementation works, including the parameters SIP-4 revises. + + + Legacy Cosmos SDK and CosmWasm documentation, deprecated under SIP-3. + + + +## Submit a proposal + +Anyone can author a SIP. Talk the idea over with the community before you write it, so you can gauge whether the proposal is needed at all. + + + + +[SIP-1](https://github.com/sei-protocol/sips/blob/main/sips/sip-1.md) defines the process, the header fields your file needs, and what authors and editors are each responsible for. + + + +Open a thread in [GitHub Discussions](https://github.com/sei-protocol/sips/discussions) to collect early feedback, especially from the people who would implement the change. + + + +Fork [sei-protocol/sips](https://github.com/sei-protocol/sips) and create your proposal at `sips/sip-temporary_title.md`. Put any images in `assets/sip-temporary_title/`. + + + +Start the file with the two-column header table and follow the section structure described in [SIP format](https://github.com/sei-protocol/sips/blob/main/sips/sip-1.md#sip-format). Keep the title under 64 characters and don't end it with a period. + + + +Submit the pull request from your fork. A [SIP editor](https://github.com/sei-protocol/sips/blob/main/sips/sip-1.md#sip-editor) reviews the formatting, assigns a number, moves the status to Draft, and opens the official comments thread. + + + + + + +From Draft onward, keeping the proposal moving is your job. An editor will help you find the right people to talk to, but won't chase the process for you. + + diff --git a/snippets/sip-index.jsx b/snippets/sip-index.jsx new file mode 100644 index 0000000..87d08d0 --- /dev/null +++ b/snippets/sip-index.jsx @@ -0,0 +1,330 @@ +export const SipIndex = () => { + const REPO = 'sei-protocol/sips'; + const BRANCH = 'main'; + const LIST_URL = `https://api.github.com/repos/${REPO}/contents/sips?ref=${BRANCH}`; + const CACHE_KEY = 'sei-sip-index'; + const CACHE_TTL = 3600000; + + const rawUrl = (file) => `https://raw.githubusercontent.com/${REPO}/${BRANCH}/sips/${file}`; + const blobUrl = (file) => `https://github.com/${REPO}/blob/${BRANCH}/sips/${file}`; + + // --- Header-field parsing ------------------------------------------------- + // Each SIP opens with a two-column markdown table. Field names drifted + // between SIPs ("SIP-Number" vs "SIP Number", "Comments" vs "Comments-URI", + // "Author" vs "Authors"), so keys are normalized to bare alphanumerics. + const normKey = (key) => key.toLowerCase().replace(/[^a-z0-9]/g, ''); + + const pick = (meta, ...keys) => { + for (const key of keys) { + const value = meta[normKey(key)]; + if (value) return value; + } + return ''; + }; + + // Turn a table cell into plain text: `
` becomes a separator, markdown + // links collapse to their label, and contact emails are dropped so the + // Author row reads as names rather than mailto noise. + const toPlainText = (value) => + value + .replace(//gi, ', ') + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/\\([[\]])/g, '$1') + .replace(/\[[^\]]*@[^\]]*\]/g, '') + .replace(/`/g, '') + .replace(/\s+/g, ' ') + .replace(/\s+,/g, ',') + .replace(/[,\s]+$/, '') + .trim(); + + const firstUrl = (value) => { + const match = value.match(/https?:\/\/[^\s)|]+/); + return match ? match[0] : ''; + }; + + // GitHub builds heading anchors by lowercasing the rendered text, dropping + // punctuation and mapping whitespace to hyphens. Mirroring that lets the + // outline deep-link straight into the source file. + const slugify = (text) => + text + .toLowerCase() + .replace(/[^\w\s-]/g, '') + .trim() + .replace(/\s/g, '-'); + + const parseSip = (file, raw) => { + const number = Number((file.match(/sip-(\d+)\.md$/i) || [])[1]); + // Null-prototype maps: field names and heading slugs come from the SIP + // text, so a heading like "Constructor" must not collide with + // Object.prototype members. + const meta = Object.create(null); + + // Several SIPs contain further tables in their body (editor registers, + // parameter lists). Only the block above the first section heading is + // the proposal header, so metadata parsing stops there. + const header = raw.split(/^##\s/m)[0]; + + for (const line of header.split('\n')) { + const row = line.match(/^\s*\|([^|]+)\|(.*)\|\s*$/); + if (!row) continue; + const key = row[1].trim(); + const value = row[2].replace(/\|\s*$/, '').trim(); + // Skip the table's alignment row (| ----- | :---- |). + if (/^:?-{2,}:?$/.test(key)) continue; + if (!key || meta[normKey(key)]) continue; + meta[normKey(key)] = value; + } + + const headline = (header.match(/^\s*\*\*SIP[-\s]?\d+:\s*(.+?)\*\*/m) || [])[1]; + const title = toPlainText(headline || pick(meta, 'Title') || file); + + const seen = Object.create(null); + const sections = []; + const headingPattern = /^(#{2,4})\s+(.+?)\s*$/gm; + let match; + while ((match = headingPattern.exec(raw)) !== null) { + const text = match[2].replace(/\*\*/g, '').replace(/`/g, '').trim(); + if (!text) continue; + let anchor = slugify(text); + // GitHub disambiguates repeated headings with a numeric suffix. + if (seen[anchor] !== undefined) { + seen[anchor] += 1; + anchor = `${anchor}-${seen[anchor]}`; + } else { + seen[anchor] = 0; + } + sections.push({ level: match[1].length, text, anchor }); + } + + const type = toPlainText(pick(meta, 'Type')); + const category = toPlainText(pick(meta, 'Category')); + + return { + number, + file, + title, + description: toPlainText(pick(meta, 'Description')), + // SIP-3/4/5 fold the category into Type as "Standard (Core)"; SIP-2 + // keeps it in its own row. Present both the same way. + type: type && category && !type.includes(category) ? `${type} (${category})` : type || category, + status: toPlainText(pick(meta, 'Status')) || 'Unknown', + author: toPlainText(pick(meta, 'Author', 'Authors')), + reviewer: toPlainText(pick(meta, 'Reviewer', 'Editor')), + created: toPlainText(pick(meta, 'Created')), + discussion: firstUrl(pick(meta, 'Comments', 'Comments-URI', 'CommentsURI')), + sections + }; + }; + + // --- Data loading --------------------------------------------------------- + const [sips, setSips] = useState([]); + const [state, setState] = useState('loading'); + + useEffect(() => { + let cancelled = false; + + const readCache = () => { + try { + const cached = JSON.parse(localStorage.getItem(CACHE_KEY) || 'null'); + if (cached && Date.now() - cached.time < CACHE_TTL && Array.isArray(cached.sips)) { + return cached.sips; + } + } catch { + // Corrupt or unavailable storage just means we fetch again. + } + return null; + }; + + // Primary path: one directory listing, then the files it names. + const loadFromApi = async () => { + const res = await fetch(LIST_URL, { headers: { Accept: 'application/vnd.github+json' } }); + if (!res.ok) throw new Error(`GitHub API ${res.status}`); + const files = (await res.json()).filter((entry) => entry.type === 'file' && /^sip-\d+\.md$/i.test(entry.name)).map((entry) => entry.name); + if (!files.length) throw new Error('No SIP files listed'); + return Promise.all( + files.map(async (file) => { + const fileRes = await fetch(rawUrl(file)); + if (!fileRes.ok) throw new Error(`${file}: ${fileRes.status}`); + return parseSip(file, await fileRes.text()); + }) + ); + }; + + // Fallback for when the unauthenticated API quota is exhausted: walk + // sip-1.md upwards off the raw CDN and stop after a run of misses. + const loadByProbing = async () => { + const found = []; + let misses = 0; + for (let n = 1; n <= 40 && misses < 3; n++) { + const file = `sip-${n}.md`; + try { + const res = await fetch(rawUrl(file)); + if (res.ok) { + found.push(parseSip(file, await res.text())); + misses = 0; + } else { + misses += 1; + } + } catch { + misses += 1; + } + } + if (!found.length) throw new Error('No SIPs reachable'); + return found; + }; + + const cached = readCache(); + if (cached) { + setSips(cached); + setState('ready'); + return; + } + + loadFromApi() + .catch(loadByProbing) + .then((parsed) => { + if (cancelled) return; + const ordered = parsed.sort((a, b) => a.number - b.number); + setSips(ordered); + setState('ready'); + try { + localStorage.setItem(CACHE_KEY, JSON.stringify({ time: Date.now(), sips: ordered })); + } catch { + // Cache is an optimization; ignore quota or privacy-mode errors. + } + }) + .catch(() => { + if (cancelled) return; + setState('error'); + }); + + return () => { + cancelled = true; + }; + }, []); + + // --- Presentation --------------------------------------------------------- + // Colors follow the SIP-1 lifecycle: in-progress states are neutral/amber, + // accepted and shipped states are green, ended states are muted or red. + const statusStyles = { + idea: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300', + draft: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-300', + review: 'bg-blue-100 text-blue-800 dark:bg-blue-500/15 dark:text-blue-300', + 'fast track': 'bg-blue-100 text-blue-800 dark:bg-blue-500/15 dark:text-blue-300', + 'last call': 'bg-purple-100 text-purple-800 dark:bg-purple-500/15 dark:text-purple-300', + final: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300', + implemented: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300', + activated: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300', + living: 'bg-teal-100 text-teal-800 dark:bg-teal-500/15 dark:text-teal-300', + stagnant: 'bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400', + withdrawn: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-300' + }; + + const statusClass = (status) => statusStyles[status.toLowerCase()] || statusStyles.idea; + + const cardClass = 'rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/40 p-5'; + const linkClass = + 'inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium no-underline transition-colors ' + + 'bg-white text-neutral-800 border border-neutral-200 hover:bg-neutral-100 ' + + 'dark:bg-neutral-800 dark:text-neutral-100 dark:border-neutral-700 dark:hover:bg-neutral-700'; + + const ExternalLinkIcon = () => ( + + ); + + const Field = ({ label, value }) => + value ? ( +
+
{label}
+
{value}
+
+ ) : null; + + if (state === 'loading') { + return ( +
+ Loading proposals from{' '} + + {REPO} + + … +
+ ); + } + + if (state === 'error') { + return ( +
+ Could not reach the SIPs repository. Browse the proposals directly at{' '} + + github.com/{REPO} + + . +
+ ); + } + + return ( +
+ {sips.map((sip) => ( +
+
+ + SIP-{sip.number} + + {sip.status} + {sip.type ? {sip.type} : null} +
+ +

{sip.title}

+ + {sip.description ?

{sip.description}

: null} + +
+ + + +
+ + {sip.sections.length ? ( +
+ Contents ({sip.sections.length} sections) + +
+ ) : null} + +
+ + Read SIP-{sip.number} + + {sip.discussion ? ( + + Discussion + + ) : null} +
+
+ ))} + +

+ Pulled live from{' '} + + {REPO} + + . Statuses and metadata reflect the {BRANCH} branch. +

+
+ ); +}; From 2507acd155cf76da594386daa49b877829f60a20 Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Sat, 1 Aug 2026 15:03:12 +0200 Subject: [PATCH 2/2] Revamp SIP index parsing and theming Improve robustness and styling of the SIP index snippet. Parsing: add Unicode-safe slugify and headingText, strip fenced code blocks, relax table parsing (optional closing pipe), and deduplicate anchors. Fetching: introduce readSip wrapper (safe fetch), use API listing with per-file resilience, and a deterministic probe fallback up to 40 files. Cache: bump CACHE_KEY to invalidate older caches. Theming/UI: replace fragile Tailwind-only utilities with inline styles driven by dark-mode detection, introduce palette/tones, hover handlers, and more accessible layout and controls. --- snippets/sip-index.jsx | 355 +++++++++++++++++++++++++++++++---------- 1 file changed, 270 insertions(+), 85 deletions(-) diff --git a/snippets/sip-index.jsx b/snippets/sip-index.jsx index 87d08d0..62c4242 100644 --- a/snippets/sip-index.jsx +++ b/snippets/sip-index.jsx @@ -2,8 +2,12 @@ export const SipIndex = () => { const REPO = 'sei-protocol/sips'; const BRANCH = 'main'; const LIST_URL = `https://api.github.com/repos/${REPO}/contents/sips?ref=${BRANCH}`; - const CACHE_KEY = 'sei-sip-index'; + // Suffix is bumped whenever parseSip's output shape changes, so a cached + // entry from an older build is discarded instead of rendered. + const CACHE_KEY = 'sei-sip-index-v1'; const CACHE_TTL = 3600000; + // Highest sip-N.md the fallback path will look for. + const MAX_PROBE = 40; const rawUrl = (file) => `https://raw.githubusercontent.com/${REPO}/${BRANCH}/sips/${file}`; const blobUrl = (file) => `https://github.com/${REPO}/blob/${BRANCH}/sips/${file}`; @@ -44,14 +48,26 @@ export const SipIndex = () => { // GitHub builds heading anchors by lowercasing the rendered text, dropping // punctuation and mapping whitespace to hyphens. Mirroring that lets the - // outline deep-link straight into the source file. + // outline deep-link straight into the source file. Letters and digits are + // matched by Unicode property so accented headings keep their characters, + // and underscores survive because GitHub keeps them in snake_case names. const slugify = (text) => text .toLowerCase() - .replace(/[^\w\s-]/g, '') + .replace(/[^\p{L}\p{N}_\s-]/gu, '') .trim() .replace(/\s/g, '-'); + // Reduce a heading to the text GitHub would render, since the anchor is + // derived from that rather than from the markdown source. + const headingText = (markdown) => + markdown + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/\*\*/g, '') + .replace(/\*/g, '') + .replace(/`/g, '') + .trim(); + const parseSip = (file, raw) => { const number = Number((file.match(/sip-(\d+)\.md$/i) || [])[1]); // Null-prototype maps: field names and heading slugs come from the SIP @@ -65,10 +81,15 @@ export const SipIndex = () => { const header = raw.split(/^##\s/m)[0]; for (const line of header.split('\n')) { - const row = line.match(/^\s*\|([^|]+)\|(.*)\|\s*$/); - if (!row) continue; - const key = row[1].trim(); - const value = row[2].replace(/\|\s*$/, '').trim(); + const trimmed = line.trim(); + if (!trimmed.startsWith('|')) continue; + // The closing pipe is optional in GitHub-flavored markdown, so drop + // it only when present rather than requiring it. + const cells = trimmed.slice(1).split('|'); + if (cells.length && cells[cells.length - 1].trim() === '') cells.pop(); + if (cells.length < 2) continue; + const key = cells[0].trim(); + const value = cells.slice(1).join('|').trim(); // Skip the table's alignment row (| ----- | :---- |). if (/^:?-{2,}:?$/.test(key)) continue; if (!key || meta[normKey(key)]) continue; @@ -80,10 +101,14 @@ export const SipIndex = () => { const seen = Object.create(null); const sections = []; + // Fenced blocks can contain lines that look like headings (SIP-1's format + // section quotes a template), and those produce anchors GitHub never + // renders, so strip the fences before scanning. + const prose = raw.replace(/^```[\s\S]*?^```/gm, ''); const headingPattern = /^(#{2,4})\s+(.+?)\s*$/gm; let match; - while ((match = headingPattern.exec(raw)) !== null) { - const text = match[2].replace(/\*\*/g, '').replace(/`/g, '').trim(); + while ((match = headingPattern.exec(prose)) !== null) { + const text = headingText(match[2]); if (!text) continue; let anchor = slugify(text); // GitHub disambiguates repeated headings with a numeric suffix. @@ -135,40 +160,40 @@ export const SipIndex = () => { return null; }; + // Fetch and parse one file, returning null instead of throwing so a + // single failure never takes down the whole listing. + const readSip = async (file) => { + try { + const res = await fetch(rawUrl(file)); + if (!res.ok) return null; + return parseSip(file, await res.text()); + } catch { + return null; + } + }; + // Primary path: one directory listing, then the files it names. const loadFromApi = async () => { const res = await fetch(LIST_URL, { headers: { Accept: 'application/vnd.github+json' } }); if (!res.ok) throw new Error(`GitHub API ${res.status}`); const files = (await res.json()).filter((entry) => entry.type === 'file' && /^sip-\d+\.md$/i.test(entry.name)).map((entry) => entry.name); if (!files.length) throw new Error('No SIP files listed'); - return Promise.all( - files.map(async (file) => { - const fileRes = await fetch(rawUrl(file)); - if (!fileRes.ok) throw new Error(`${file}: ${fileRes.status}`); - return parseSip(file, await fileRes.text()); - }) - ); + // One unreadable file should cost that single card, not send the whole + // listing down the fallback path and refetch everything. + const fetched = await Promise.all(files.map(readSip)); + const parsed = fetched.filter(Boolean); + if (!parsed.length) throw new Error('No SIP files could be read'); + return parsed; }; - // Fallback for when the unauthenticated API quota is exhausted: walk - // sip-1.md upwards off the raw CDN and stop after a run of misses. + // Fallback for when the unauthenticated API quota is exhausted: probe + // sip-1.md through sip-40.md on the raw CDN. Every number is requested + // so a withdrawn or reserved number cannot truncate the index the way + // stopping after a run of misses would. const loadByProbing = async () => { - const found = []; - let misses = 0; - for (let n = 1; n <= 40 && misses < 3; n++) { - const file = `sip-${n}.md`; - try { - const res = await fetch(rawUrl(file)); - if (res.ok) { - found.push(parseSip(file, await res.text())); - misses = 0; - } else { - misses += 1; - } - } catch { - misses += 1; - } - } + const numbers = Array.from({ length: MAX_PROBE }, (_, i) => i + 1); + const fetched = await Promise.all(numbers.map((n) => readSip(`sip-${n}.md`))); + const found = fetched.filter(Boolean); if (!found.length) throw new Error('No SIPs reachable'); return found; }; @@ -203,30 +228,127 @@ export const SipIndex = () => { }; }, []); - // --- Presentation --------------------------------------------------------- - // Colors follow the SIP-1 lifecycle: in-progress states are neutral/amber, - // accepted and shipped states are green, ended states are muted or red. - const statusStyles = { - idea: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300', - draft: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-300', - review: 'bg-blue-100 text-blue-800 dark:bg-blue-500/15 dark:text-blue-300', - 'fast track': 'bg-blue-100 text-blue-800 dark:bg-blue-500/15 dark:text-blue-300', - 'last call': 'bg-purple-100 text-purple-800 dark:bg-purple-500/15 dark:text-purple-300', - final: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300', - implemented: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300', - activated: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300', - living: 'bg-teal-100 text-teal-800 dark:bg-teal-500/15 dark:text-teal-300', - stagnant: 'bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400', - withdrawn: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-300' + // --- Theming -------------------------------------------------------------- + // Mintlify serves a prebuilt stylesheet, so Tailwind utilities that appear + // only inside a snippet aren't guaranteed to exist in it. Variants carrying + // an opacity modifier (`dark:bg-neutral-900/40`) drop out that way, which is + // what left these cards white on a black page. The other data snippets here + // watch the `dark` class on and style inline from the style.css + // tokens instead, so this follows the same approach. + const [isDark, setIsDark] = useState(false); + + useEffect(() => { + const root = document.documentElement; + const sync = () => setIsDark(root.classList.contains('dark')); + sync(); + const observer = new MutationObserver(sync); + observer.observe(root, { attributes: true, attributeFilter: ['class'] }); + return () => observer.disconnect(); + }, []); + + const byMode = (light, dark) => (isDark ? dark : light); + + const theme = { + cardBg: byMode('var(--sei-card-bg-light)', 'var(--sei-card-bg-dark)'), + border: byMode('var(--sei-card-border-light)', 'var(--sei-card-border-dark)'), + strong: byMode('var(--sei-grey-600)', 'var(--sei-white)'), + body: byMode('var(--sei-grey-300)', 'var(--sei-grey-50)'), + muted: byMode('var(--sei-grey-200)', 'var(--sei-grey-75)'), + label: byMode('var(--sei-gold-100)', 'var(--sei-gold-25)'), + accent: byMode('var(--sei-maroon-100)', 'var(--sei-maroon-25)'), + hoverBg: byMode('rgba(96, 0, 20, 0.02)', 'rgba(96, 0, 20, 0.08)'), + hoverBorder: byMode('rgba(96, 0, 20, 0.35)', 'rgba(185, 155, 161, 0.25)') }; - const statusClass = (status) => statusStyles[status.toLowerCase()] || statusStyles.idea; + // Tones map the SIP-1 lifecycle onto the Sei palette: gold while a proposal + // is still moving, green once accepted or shipped, grey for dormant states, + // red for withdrawn. These mirror the style.css tokens as literal hex + // because each badge tints its own fill, and the alpha channels can't be + // derived from a var() reference. `--sei-live` and `--sei-error` are pitched + // for dark surfaces and drop under 2:1 as text on white, so light mode takes + // a darkened counterpart. + const TONES = { + live: byMode('#1a7a00', '#38df00'), + error: byMode('#c20a00', '#fa0c00'), + gold: byMode('#966f22', '#d6c9ac'), + maroon: byMode('#600014', '#b99ba1'), + grey: byMode('#666666', '#999999') + }; - const cardClass = 'rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/40 p-5'; - const linkClass = - 'inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium no-underline transition-colors ' + - 'bg-white text-neutral-800 border border-neutral-200 hover:bg-neutral-100 ' + - 'dark:bg-neutral-800 dark:text-neutral-100 dark:border-neutral-700 dark:hover:bg-neutral-700'; + const STATUS_TONES = { + idea: 'grey', + draft: 'gold', + review: 'gold', + 'fast track': 'gold', + 'last call': 'gold', + final: 'live', + implemented: 'live', + activated: 'live', + living: 'maroon', + stagnant: 'grey', + withdrawn: 'error' + }; + + const channels = (hex) => { + const n = parseInt(hex.slice(1), 16); + return `${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}`; + }; + + const eyebrow = { + fontFamily: 'var(--sei-font-mono)', + fontSize: '10px', + letterSpacing: '0.04em', + textTransform: 'uppercase' + }; + + const statusStyle = (status) => { + const hex = TONES[STATUS_TONES[status.toLowerCase()] || 'grey']; + const rgb = channels(hex); + return { + ...eyebrow, + color: hex, + backgroundColor: `rgba(${rgb}, ${byMode(0.09, 0.14)})`, + border: `1px solid rgba(${rgb}, ${byMode(0.28, 0.32)})`, + borderRadius: 'var(--sei-radius-sm)', + padding: '3px 6px', + whiteSpace: 'nowrap' + }; + }; + + const cardStyle = { + background: theme.cardBg, + border: `1px solid ${theme.border}`, + borderRadius: 'var(--sei-radius-sm)', + padding: '20px' + }; + + const buttonStyle = { + display: 'inline-flex', + alignItems: 'center', + gap: '6px', + padding: '6px 12px', + fontFamily: 'var(--sei-font-mono)', + fontSize: '12px', + letterSpacing: '0.02em', + color: theme.strong, + background: 'transparent', + border: `1px solid ${theme.border}`, + borderRadius: 'var(--sei-radius-sm)', + textDecoration: 'none', + transition: 'background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease' + }; + + // Inline styles can't express :hover, so the maroon card hover from + // style.css is reproduced with handlers. + const hoverSwap = (hovering) => (event) => { + event.currentTarget.style.backgroundColor = hovering ? theme.hoverBg : 'transparent'; + event.currentTarget.style.borderColor = hovering ? theme.hoverBorder : theme.border; + event.currentTarget.style.color = hovering ? theme.accent : theme.strong; + }; + + const tintSwap = (hovering) => (event) => { + event.currentTarget.style.color = hovering ? theme.accent : theme.body; + }; const ExternalLinkIcon = () => (
-
{label}
-
{value}
+
{label}
+
{value}
) : null; + const statusMessage = { ...cardStyle, fontSize: '14px', color: theme.body }; + if (state === 'loading') { return ( -
- Loading proposals from{' '} - - {REPO} - - … +
+ Loading proposals from {REPO}
); } if (state === 'error') { return ( -
+
Could not reach the SIPs repository. Browse the proposals directly at{' '} - + github.com/{REPO} . @@ -269,34 +394,75 @@ export const SipIndex = () => { } return ( -
+
{sips.map((sip) => ( -
-
- +
+
+ SIP-{sip.number} - {sip.status} - {sip.type ? {sip.type} : null} + {sip.status} + {sip.type ? {sip.type} : null}
-

{sip.title}

- - {sip.description ?

{sip.description}

: null} +

+ {sip.title} +

+ + {sip.description ? ( +

{sip.description}

+ ) : null} -
+
+ {/* style.css frames every
as the shared accordion with + !important, so the outline is inset by the summary's own 16px + padding rather than given a competing border here. */} {sip.sections.length ? ( -
- Contents ({sip.sections.length} sections) -
    +
    + + Contents ({sip.sections.length} sections) + +
      {sip.sections.map((section) => ( -
    • - +
    • + {section.text}
    • @@ -305,12 +471,26 @@ export const SipIndex = () => {
    ) : null} -
    - +
    + Read SIP-{sip.number} {sip.discussion ? ( - + Discussion ) : null} @@ -318,9 +498,14 @@ export const SipIndex = () => {
    ))} -

    +

    Pulled live from{' '} - + {REPO} . Statuses and metadata reflect the {BRANCH} branch.