snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,86 @@
import React from 'react';
import Link from '@docusaurus/Link';
import styles from './styles.module.css';
// Compliance / authorization status badge for framework pages
// (FedRAMP, DoD IL, DISA STIG, HIPAA, FINRA, CMMC, Common Criteria…).
//
// Status semantics:
// authorized — formally authorized / certified; ATO or equivalent in place.
// in-process — actively pursuing authorization; documented gap exists.
// roadmap — committed for future authorization; no work in flight yet.
// not-pursued — not on the roadmap; framework documented for reference only.
//
// Usage:
// <AttestationStatus framework="FedRAMP Moderate" status="in-process" />
// <AttestationStatus framework="FIPS 140-3" status="authorized" certificate="CMVP #4735" />
// <AttestationStatus framework="Common Criteria" status="not-pursued" />
type Status = 'authorized' | 'in-process' | 'roadmap' | 'not-pursued';
const STATUS_LABEL: Record<Status, string> = {
'authorized': 'Authorized',
'in-process': 'In Process',
'roadmap': 'Roadmap',
'not-pursued': 'Not Pursued',
};
const STATUS_CLASS: Record<Status, string> = {
'authorized': 'authorized',
'in-process': 'inProcess',
'roadmap': 'roadmap',
'not-pursued': 'notPursued',
};
const KNOWN_STATUSES = Object.keys(STATUS_LABEL) as Status[];
export default function AttestationStatus({
framework,
status,
certificate,
asOf,
detailsHref,
}: {
framework: string;
status: string;
certificate?: string;
asOf?: string;
detailsHref?: string;
}) {
if (!framework) {
return (
<aside className={`${styles.box} ${styles.unknown}`} role="note">
<code>&lt;AttestationStatus/&gt;</code> requires a <code>framework</code> prop.
</aside>
);
}
if (!(KNOWN_STATUSES as string[]).includes(status)) {
return (
<aside className={`${styles.box} ${styles.unknown}`} role="note">
Unknown attestation status: <code>{status}</code>. Valid:{' '}
<code>{KNOWN_STATUSES.join(', ')}</code>.
</aside>
);
}
const s = status as Status;
return (
<aside
className={`${styles.box} ${styles[STATUS_CLASS[s]]}`}
role="note"
aria-label={`${framework} attestation status`}
>
<div className={styles.header}>
<span className={styles.framework}>{framework}</span>
<span className={styles.status} data-status={s}>{STATUS_LABEL[s]}</span>
</div>
{(certificate || asOf || detailsHref) && (
<div className={styles.meta}>
{certificate && <span><strong>Certificate:</strong> {certificate}</span>}
{asOf && <span><strong>As of:</strong> {asOf}</span>}
{detailsHref && <Link href={detailsHref}>Attestation details</Link>}
</div>
)}
</aside>
);
}

View File

@@ -0,0 +1,77 @@
/* AttestationStatus — framework header + status pill + optional metadata.
* Distinct from edition/deployment badges: this is a full-width block at
* the top of compliance-framework pages, not an inline chip. */
.box {
display: block;
margin: 1rem 0;
padding: 0.75rem 1rem;
border-radius: 6px;
background: var(--mm-denim-50, rgba(30, 50, 92, 0.05));
border-left: 4px solid var(--mm-color-denim, #1E325C);
color: var(--mm-text-primary);
}
[data-theme='dark'] .box {
background: rgba(255, 255, 255, 0.04);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.framework {
font-weight: 700;
font-size: 1rem;
letter-spacing: 0.01em;
}
.status {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 0.75rem;
padding: 0.18rem 0.55rem;
border-radius: 999px;
border: 1px solid currentColor;
white-space: nowrap;
}
.status[data-status='authorized'] { color: #1d6f42; background: rgba(29, 111, 66, 0.08); }
.status[data-status='in-process'] { color: #8a5d00; background: rgba(255, 188, 31, 0.18); }
.status[data-status='roadmap'] { color: #1E325C; background: rgba(30, 50, 92, 0.08); }
.status[data-status='not-pursued'] { color: #6c757d; background: rgba(108, 117, 125, 0.10); }
[data-theme='dark'] .status[data-status='authorized'] { color: #6dd49b; }
[data-theme='dark'] .status[data-status='in-process'] { color: #FFBC1F; }
[data-theme='dark'] .status[data-status='roadmap'] { color: #9eb1da; }
[data-theme='dark'] .status[data-status='not-pursued'] { color: #b0b6bd; }
/* Variant left-borders per status, for at-a-glance scanning. */
.authorized { border-left-color: #1d6f42; }
.inProcess { border-left-color: #FFBC1F; }
.roadmap { border-left-color: #1E325C; }
.notPursued { border-left-color: #6c757d; }
.meta {
margin-top: 0.55rem;
font-size: 0.88rem;
display: flex;
gap: 1.25rem;
flex-wrap: wrap;
color: var(--mm-text-secondary, var(--mm-text-primary));
}
.meta strong {
font-weight: 700;
}
.unknown {
background: #fff3cd;
color: #664d03;
border-left-color: #ffc107;
}

View File

@@ -0,0 +1,42 @@
import React from 'react';
import styles from './styles.module.css';
type Kind = 'note' | 'tip' | 'important' | 'attention' | 'warning' | 'security';
const COPY: Record<Kind, {label: string; icon: string}> = {
note: {label: 'Note', icon: 'i'},
tip: {label: 'Tip', icon: '+'},
important: {label: 'Important', icon: '!'},
attention: {label: 'Attention', icon: '!'},
warning: {label: 'Warning', icon: '!'},
security: {label: 'Security', icon: 'S'},
};
export default function Callout({
kind = 'note',
title,
children,
}: {
kind?: Kind;
title?: string;
children: React.ReactNode;
}) {
const meta = COPY[kind];
return (
<aside className={`${styles.callout} ${styles[kind]}`} role="note">
<div className={styles.header}>
<span className={styles.icon} aria-hidden>{meta.icon}</span>
<span className={styles.label}>{title ?? meta.label}</span>
</div>
<div className={styles.content}>{children}</div>
</aside>
);
}
// Convenience wrappers used like <Note>...</Note> in MDX.
export const Note = (p: {title?: string; children: React.ReactNode}) => <Callout kind="note" {...p} />;
export const Tip = (p: {title?: string; children: React.ReactNode}) => <Callout kind="tip" {...p} />;
export const Important = (p: {title?: string; children: React.ReactNode}) => <Callout kind="important" {...p} />;
export const Attention = (p: {title?: string; children: React.ReactNode}) => <Callout kind="attention" {...p} />;
export const Warning = (p: {title?: string; children: React.ReactNode}) => <Callout kind="warning" {...p} />;
export const Security = (p: {title?: string; children: React.ReactNode}) => <Callout kind="security" {...p} />;

View File

@@ -0,0 +1,121 @@
/* Branded callouts, styled to match the compact Docusaurus admonitions used
* elsewhere in the docs: a tinted panel with a left accent bar and a small
* icon + label header. */
.callout {
margin: 1.5rem 0;
padding: 0.9rem 1.1rem 1rem;
border: 1px solid var(--callout-border);
border-left: 4px solid var(--callout-accent);
border-radius: 4px;
background: var(--callout-bg);
color: var(--mm-text-primary);
}
.header {
display: flex;
align-items: center;
gap: 0.45rem;
margin-bottom: 0.5rem;
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 1.05rem;
height: 1.05rem;
font-family: var(--mm-font-sans);
font-weight: 700;
font-size: 0.7rem;
line-height: 1;
color: var(--callout-accent);
border: 1.5px solid currentColor;
border-radius: 50%;
}
.label {
font-family: var(--mm-font-sans);
font-weight: 700;
font-size: 0.8125rem;
letter-spacing: 0.05em;
text-transform: uppercase;
line-height: 1.2;
color: var(--callout-accent);
}
.content > :first-child { margin-top: 0; }
.content > :last-child { margin-bottom: 0; }
.content p { margin: 0.5rem 0; line-height: 1.6; }
/* Kind variants: each sets an accent color plus a tint of the same hue for
* the panel background. */
.note {
--callout-accent: #2E4B87;
--callout-bg: var(--mm-denim-50);
--callout-border: var(--mm-denim-100);
}
[data-theme='dark'] .note {
--callout-accent: var(--mm-denim-200);
--callout-bg: var(--mm-denim-700);
--callout-border: var(--mm-denim-400);
}
.tip {
--callout-accent: #1F5A39;
--callout-bg: #EAF5EE;
--callout-border: #C3E0CE;
}
[data-theme='dark'] .tip {
--callout-accent: #7FCC9E;
--callout-bg: #14442C;
--callout-border: #2C7A4F;
}
.important {
--callout-accent: #8A6200;
--callout-bg: var(--mm-marigold-100);
--callout-border: var(--mm-marigold-300);
}
[data-theme='dark'] .important {
--callout-accent: var(--mm-marigold-300);
--callout-bg: #4A3A10;
--callout-border: #7A6220;
}
/* Attention outranks Important: the reader loses something by skipping it.
* Brighter than Security's maroon so the two stay tellable apart. */
.attention {
--callout-accent: #C8102E;
--callout-bg: #FDECEF;
--callout-border: #F5C6CE;
}
[data-theme='dark'] .attention {
--callout-accent: #F4A0AB;
--callout-bg: #4E0F1B;
--callout-border: #85202F;
}
.warning {
--callout-accent: #6B4F2E;
--callout-bg: #F6F1E9;
--callout-border: #E0D5C1;
}
[data-theme='dark'] .warning {
--callout-accent: var(--mm-color-sand);
--callout-bg: #3A2F22;
--callout-border: var(--mm-color-bark);
}
.security {
--callout-accent: #8B1F1F;
--callout-bg: #FBEDED;
--callout-border: #EFCFCF;
}
[data-theme='dark'] .security {
--callout-accent: #E9A0A0;
--callout-bg: #4A1F1F;
--callout-border: #7A3535;
}

View File

@@ -0,0 +1,79 @@
import React from 'react';
import Link from '@docusaurus/Link';
import styles from './styles.module.css';
type Card = {
title: string;
description?: string;
to: string;
icon?: string; // CompassIcon name
meta?: string; // small annotation (e.g., "549 endpoints")
};
/**
* Grid of navigation cards for landing pages. Replaces bulleted lists
* of links — each card is a discrete navigation surface with title,
* description, and optional Compass icon + meta.
*
* The `compact` variant renders the same cards as a wrapping row of
* low-weight chips, for secondary destinations that need to stay
* scannable without competing with a primary card grid above them.
*
* Usage:
* <CardGrid cards={[
* {title: 'Examples', icon: 'channels', to: '/api/examples',
* description: 'Quick-start with curl.', meta: '6 examples'},
* {title: 'Reference', icon: 'boards', to: '/api/reference/login',
* description: 'Every endpoint, grouped by resource.', meta: '549 endpoints'},
* ]} />
*
* <CardGrid variant="compact" cards={[
* {title: 'Compliance Officers', to: '/for/compliance-officer',
* description: 'Audit evidence, exports.'},
* ]} />
*/
export default function CardGrid({
cards,
columns = 3,
variant = 'default',
}: {
cards: Card[];
columns?: 2 | 3 | 4;
variant?: 'default' | 'compact';
}) {
if (variant === 'compact') {
return (
<div className={styles.chipRow}>
{cards.map((c, i) => (
<Link key={`${c.to}-${i}`} to={c.to} className={styles.chip}>
<span className={styles.chipBody}>
<span className={styles.chipTitle}>{c.title}</span>
{c.description && <span className={styles.chipDescription}>{c.description}</span>}
</span>
<span className={styles.chipArrow} aria-hidden>→</span>
</Link>
))}
</div>
);
}
return (
// Wrapper establishes a size query container for the column-count
// breakpoints in styles.module.css.
<div className={styles.gridContainer}>
<div className={`${styles.grid} ${styles[`cols${columns}`]}`}>
{cards.map((c, i) => (
<Link key={`${c.to}-${i}`} to={c.to} className={styles.card}>
{c.icon && <span className={`${styles.icon} mm-compass-${c.icon}`} aria-hidden />}
<div className={styles.body}>
<div className={styles.title}>{c.title}</div>
<p className={styles.description}>{c.description}</p>
{c.meta && <span className={styles.meta}>{c.meta}</span>}
</div>
<span className={styles.arrow} aria-hidden>→</span>
</Link>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,261 @@
.gridContainer {
container-type: inline-size;
}
.grid {
display: grid;
gap: 1rem;
margin: 1.5rem 0 2.5rem;
}
/* `repeat(auto-fit, minmax(200px, 1fr))` packed 4 cards into one tall, narrow
* row at typical article widths. Step the column count down explicitly
* instead, halving 4 to 2x2 and 3 to 2 before dropping to a single column. */
.cols2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.cols3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.cols4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
@container (max-width: 1080px) {
.cols4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@container (max-width: 820px) {
.cols3 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@container (max-width: 540px) {
.cols2,
.cols3,
.cols4 { grid-template-columns: minmax(0, 1fr); }
}
/* Fallback for browsers without container query support — viewport width is
* a reasonable stand-in since the doc sidebar collapses below 997px. */
@supports not (container-type: inline-size) {
@media (max-width: 996px) {
.cols3,
.cols4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 600px) {
.cols2,
.cols3,
.cols4 { grid-template-columns: minmax(0, 1fr); }
}
}
.card {
position: relative;
display: flex;
align-items: flex-start;
gap: 0.9rem;
padding: 1.4rem 1.6rem;
background: var(--mm-bg-surface);
border: 1px solid var(--mm-border-subtle);
border-radius: 8px;
text-decoration: none;
color: inherit;
box-shadow: 0 1px 2px rgba(30, 50, 92, 0.05);
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease,
background 0.15s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 16px 32px -18px rgba(30, 50, 92, 0.3);
border-color: var(--mm-border-strong);
background: var(--mm-bg-subtle);
text-decoration: none;
color: inherit;
}
/* --mm-focus-ring (marigold) is ~1.7:1 against the white card surface,
* well under the 3:1 WCAG 1.4.11 minimum for non-text UI indicators.
* Use denim instead, which passes on both the light and dark surface. */
.card:focus-visible {
outline: 2px solid var(--mm-color-denim);
outline-offset: 2px;
}
[data-theme='dark'] .card:focus-visible {
outline-color: var(--mm-denim-300, #8499C5);
}
[data-theme='dark'] .card {
background: rgba(255, 255, 255, 0.035);
border-color: rgba(255, 255, 255, 0.14);
box-shadow: none;
}
[data-theme='dark'] .card:hover {
background: rgba(255, 255, 255, 0.06);
border-color: var(--mm-denim-300);
}
.icon {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
width: 2.1rem;
height: 2.1rem;
background: var(--mm-bg-subtle);
color: var(--mm-color-denim);
border-radius: 50%;
transition: background 0.15s ease, color 0.15s ease;
}
.card:hover .icon {
background: var(--mm-color-denim);
color: var(--mm-color-white);
}
[data-theme='dark'] .icon {
background: rgba(255, 255, 255, 0.06);
color: var(--mm-denim-200);
}
[data-theme='dark'] .card:hover .icon {
background: var(--mm-color-white);
color: var(--mm-color-denim);
}
.body {
flex: 1 1 auto;
min-width: 0;
padding-right: 1.5rem;
}
.title {
/* Mixed-case, multi-word labels (e.g. "For End Users") read as too
* heavy/shouty in Archivo Black at 900 — same issue already fixed for
* h1-h4 — so use the sans face at a lighter bold weight instead. */
font-family: var(--mm-font-sans);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: -0.01em;
margin-bottom: 0.35rem;
color: var(--mm-text-primary);
}
.description {
font-size: 0.88rem;
line-height: 1.5;
margin: 0 0 0.4rem;
color: var(--mm-text-secondary);
}
.meta {
display: inline-block;
font-family: var(--mm-font-mono);
font-size: 0.72rem;
letter-spacing: 0.02em;
color: var(--mm-marigold-700);
background: var(--mm-marigold-100);
padding: 0.1rem 0.45rem;
border-radius: 2px;
}
[data-theme='dark'] .meta {
color: var(--mm-color-marigold);
background: rgba(255, 188, 31, 0.12);
}
.arrow {
position: absolute;
top: 1.4rem;
right: 1.4rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.6rem;
height: 1.6rem;
border-radius: 50%;
color: var(--mm-text-secondary);
font-size: 0.95rem;
transition: transform 0.15s ease, color 0.15s ease, background 0.15s ease;
}
.card:hover .arrow {
background: var(--mm-bg-page);
color: var(--mm-color-denim);
transform: translateX(3px);
}
[data-theme='dark'] .card:hover .arrow {
background: rgba(255, 255, 255, 0.08);
color: var(--mm-color-white);
}
/* Compact variant — secondary destinations that sit under a primary
* card grid. Same affordances (surface, hover, arrow) at roughly half
* the visual weight, so they read as a second tier rather than a
* competing one. */
.chipRow {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 0.6rem;
margin: 0.75rem 0 2.5rem;
}
.chip {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.85rem;
background: var(--mm-bg-surface);
border: 1px solid var(--mm-border-subtle);
border-radius: 6px;
text-decoration: none;
color: inherit;
transition: background 0.15s ease, border-color 0.15s ease;
}
.chip:hover {
background: var(--mm-bg-subtle);
border-color: var(--mm-border-strong);
text-decoration: none;
color: inherit;
}
.chip:focus-visible {
outline: 2px solid var(--mm-color-denim);
outline-offset: 2px;
}
[data-theme='dark'] .chip {
background: rgba(255, 255, 255, 0.035);
border-color: rgba(255, 255, 255, 0.14);
}
[data-theme='dark'] .chip:hover {
background: rgba(255, 255, 255, 0.06);
border-color: var(--mm-denim-300);
}
[data-theme='dark'] .chip:focus-visible {
outline-color: var(--mm-denim-300, #8499C5);
}
.chipBody {
flex: 1 1 auto;
min-width: 0;
}
.chipTitle {
display: block;
font-family: var(--mm-font-sans);
font-weight: 700;
font-size: 0.92rem;
letter-spacing: -0.01em;
color: var(--mm-text-primary);
}
.chipDescription {
display: block;
font-size: 0.8rem;
line-height: 1.4;
color: var(--mm-text-secondary);
}
.chipArrow {
flex: 0 0 auto;
color: var(--mm-text-secondary);
font-size: 0.85rem;
transition: transform 0.15s ease, color 0.15s ease;
}
.chip:hover .chipArrow {
color: var(--mm-color-denim);
transform: translateX(3px);
}
[data-theme='dark'] .chip:hover .chipArrow {
color: var(--mm-color-white);
}

View File

@@ -0,0 +1,34 @@
import React from 'react';
import useBaseUrl from '@docusaurus/useBaseUrl';
import styles from './styles.module.css';
/**
* Brand-aligned icon. Ports the Hugo `compass-icon` shortcode used in
* mattermost-developer-documentation. Backed by SVGs in
* docs-site/static/img/icons/. During phase 4 the full Compass icon
* library is bulk-imported (see PLAN.md §11.2 Week 1).
*
* Usage:
* <CompassIcon name="channels" size="md" tone="denim" />
*/
export default function CompassIcon({
name,
size = 'sm',
tone = 'denim',
alt = '',
}: {
name: string;
size?: 'xs' | 'sm' | 'md' | 'lg';
tone?: 'denim' | 'marigold' | 'inherit';
alt?: string;
}) {
const src = useBaseUrl(`/img/icons/${name}.svg`);
return (
<span
className={`${styles.icon} ${styles[size]} ${styles[tone]}`}
role={alt ? 'img' : 'presentation'}
aria-label={alt || undefined}
style={{maskImage: `url(${src})`, WebkitMaskImage: `url(${src})`}}
/>
);
}

View File

@@ -0,0 +1,20 @@
.icon {
display: inline-block;
vertical-align: -0.15em;
background-color: currentColor;
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-size: contain;
-webkit-mask-size: contain;
mask-position: center;
-webkit-mask-position: center;
}
.xs { width: 0.85em; height: 0.85em; }
.sm { width: 1em; height: 1em; }
.md { width: 1.4em; height: 1.4em; }
.lg { width: 2em; height: 2em; }
.denim { color: var(--mm-color-denim); }
.marigold { color: var(--mm-color-marigold); }
.inherit { color: inherit; }

View File

@@ -0,0 +1,369 @@
// Data tables for the Deployment Architecture builder.
//
// Tier sizing follows the Mattermost scaling guide. Provider SKUs are
// illustrative starting points — verify with current pricing and any
// regional / compliance constraints before committing to a procurement
// decision.
export type Tier = {
id: string;
label: string;
seats: string;
description: string;
appNodes: number;
appCpu: number;
appRam: number;
dbReplicas: number;
redis: boolean;
search: 'none' | 'optional' | 'recommended' | 'required';
searchNodes: number;
loadBalancer: 'none' | 'single' | 'cluster';
storage: 'local' | 'object';
ha: boolean;
};
export const TIERS: Tier[] = [
{
id: 'trial',
label: 'Trial / Pilot',
seats: '≤ 200 users',
description: 'Single host for evaluation. Mattermost and PostgreSQL co-located; no separate proxy or cache.',
appNodes: 1,
appCpu: 2,
appRam: 4,
dbReplicas: 0,
redis: false,
search: 'none',
searchNodes: 0,
loadBalancer: 'none',
storage: 'local',
ha: false,
},
{
id: 'small',
label: 'Small',
seats: '≤ 2,000 users',
description: 'Separate app and database hosts. NGINX reverse proxy. Single Mattermost node — restart causes a brief outage.',
appNodes: 1,
appCpu: 4,
appRam: 16,
dbReplicas: 0,
redis: false,
search: 'optional',
searchNodes: 1,
loadBalancer: 'single',
storage: 'object',
ha: false,
},
{
id: 'medium',
label: 'Medium',
seats: '≤ 10,000 users',
description: 'High availability: 2-node Mattermost cluster behind a load balancer. PostgreSQL with read replica.',
appNodes: 2,
appCpu: 8,
appRam: 32,
dbReplicas: 1,
redis: false,
search: 'recommended',
searchNodes: 1,
loadBalancer: 'single',
storage: 'object',
ha: true,
},
{
id: 'large',
label: 'Large',
seats: '≤ 25,000 users',
description: '4-node Mattermost cluster. PostgreSQL primary + 2 read replicas. Dedicated Elasticsearch / OpenSearch cluster for message search.',
appNodes: 4,
appCpu: 16,
appRam: 64,
dbReplicas: 2,
redis: false,
search: 'required',
searchNodes: 3,
loadBalancer: 'cluster',
storage: 'object',
ha: true,
},
{
id: 'xlarge',
label: 'Extra Large',
seats: '≤ 50,000 users',
description: '8-node Mattermost cluster. PostgreSQL primary + 2 replicas with connection pooler. OpenSearch cluster (5 nodes).',
appNodes: 8,
appCpu: 16,
appRam: 64,
dbReplicas: 2,
redis: false,
search: 'required',
searchNodes: 5,
loadBalancer: 'cluster',
storage: 'object',
ha: true,
},
{
id: 'mega',
label: 'Mega',
seats: '≤ 100,000 users',
description: '16-node Mattermost cluster. PostgreSQL with connection pooler + multiple read replicas. OpenSearch cluster (5+ data nodes). CDN in front of static assets.',
appNodes: 16,
appCpu: 32,
appRam: 128,
dbReplicas: 3,
redis: false,
search: 'required',
searchNodes: 5,
loadBalancer: 'cluster',
storage: 'object',
ha: true,
},
{
id: 'hyperscale',
label: 'Hyperscale',
seats: '≤ 200,000 users',
description: '32-node Mattermost cluster. Sharded PostgreSQL via connection pooler. Redis cluster (5+ nodes). OpenSearch cluster (10+ data nodes). Multi-region CDN.',
appNodes: 32,
appCpu: 32,
appRam: 128,
dbReplicas: 4,
redis: true,
search: 'required',
searchNodes: 10,
loadBalancer: 'cluster',
storage: 'object',
ha: true,
},
];
export type DeploymentType = 'vm' | 'kubernetes';
export type Provider = 'aws' | 'azure' | 'gcp' | 'oci' | 'on-prem';
export const PROVIDERS: {id: Provider; label: string}[] = [
{id: 'aws', label: 'AWS'},
{id: 'azure', label: 'Azure'},
{id: 'gcp', label: 'Google Cloud'},
{id: 'oci', label: 'Oracle Cloud (OCI)'},
{id: 'on-prem', label: 'On-prem / Other'},
];
/* ============================ Brand short names ============================
* Used in the diagram boxes — proper service product name per provider.
* Longer descriptive labels live in the BOM under "Recommended size / SKU". */
export const DB_BRAND: Record<Provider, string> = {
aws: 'Amazon RDS (PostgreSQL)',
azure: 'Azure Database (PostgreSQL)',
gcp: 'Cloud SQL (PostgreSQL)',
oci: 'OCI Database (PostgreSQL)',
'on-prem': 'PostgreSQL (self-managed)',
};
export const REDIS_BRAND: Record<Provider, string> = {
aws: 'Amazon ElastiCache (Redis)',
azure: 'Azure Cache for Redis',
gcp: 'Memorystore (Redis)',
oci: 'OCI Cache (Redis)',
'on-prem': 'Redis (self-managed)',
};
export const SEARCH_BRAND: Record<Provider, string> = {
aws: 'Amazon OpenSearch',
azure: 'Self-hosted OpenSearch',
gcp: 'Self-hosted OpenSearch',
oci: 'OCI Search (OpenSearch)',
'on-prem': 'OpenSearch (self-managed)',
};
export const STORAGE_BRAND: Record<Provider, string> = {
aws: 'Amazon S3',
azure: 'Azure Blob Storage',
gcp: 'Cloud Storage',
oci: 'OCI Object Storage',
'on-prem': 'MinIO (S3-compatible)',
};
export const LB_BRAND_VM: Record<Provider, string> = {
aws: 'AWS Application Load Balancer',
azure: 'Azure Application Gateway',
gcp: 'Google Cloud HTTPS LB',
oci: 'OCI Flexible Load Balancer',
'on-prem': 'NGINX / HAProxy',
};
export const LB_BRAND_K8S: Record<Provider, string> = {
aws: 'NGINX Ingress + AWS NLB',
azure: 'NGINX Ingress + Azure LB',
gcp: 'NGINX Ingress + GCP LB',
oci: 'NGINX Ingress + OCI LB',
'on-prem': 'NGINX Ingress Controller',
};
/* ============================ DB instance sizing ============================ */
const DB_INSTANCE = {
aws: {trial: 'co-located', small: 'db.m5.large', medium: 'db.m5.xlarge', large: 'db.m5.2xlarge', xlarge: 'db.m5.4xlarge', mega: 'db.r5.4xlarge', hyperscale: 'db.r5.8xlarge'},
azure: {trial: 'co-located', small: 'D4ds_v4', medium: 'D8ds_v4', large: 'D16ds_v4', xlarge: 'D32ds_v4', mega: 'E32ds_v4', hyperscale: 'E48ds_v4'},
gcp: {trial: 'co-located', small: 'db-n1-standard-4', medium: 'db-n1-standard-8', large: 'db-n1-standard-16', xlarge: 'db-n1-standard-32', mega: 'db-n1-highmem-32', hyperscale: 'db-n1-highmem-64'},
oci: {trial: 'co-located', small: 'PostgreSQL.E4.4', medium: 'PostgreSQL.E4.8', large: 'PostgreSQL.E4.16', xlarge: 'PostgreSQL.E4.32', mega: 'PostgreSQL.E4.64', hyperscale: 'PostgreSQL.E4.96'},
'on-prem': {trial: 'co-located', small: '4 vCPU / 16 GB', medium: '8 vCPU / 32 GB', large: '16 vCPU / 64 GB', xlarge: '32 vCPU / 128 GB', mega: '32 vCPU / 256 GB', hyperscale: '64 vCPU / 384 GB'},
} as const;
const APP_SKU = {
aws: {'2-4': 't3.medium', '4-16': 'm5.xlarge', '8-32': 'm5.2xlarge', '16-64': 'm5.4xlarge', '32-128': 'm6i.8xlarge'},
azure: {'2-4': 'B2ms', '4-16': 'D4s_v3', '8-32': 'D8s_v3', '16-64': 'D16s_v3', '32-128': 'D32s_v3'},
gcp: {'2-4': 'e2-medium', '4-16': 'e2-standard-4', '8-32': 'e2-standard-8', '16-64': 'n2-standard-16', '32-128': 'n2-standard-32'},
oci: {'2-4': 'VM.Standard.E4.Flex (2 OCPU)', '4-16': 'VM.Standard.E4.Flex (4 OCPU)', '8-32': 'VM.Standard.E4.Flex (8 OCPU)', '16-64': 'VM.Standard.E4.Flex (16 OCPU)', '32-128': 'VM.Standard.E4.Flex (32 OCPU)'},
'on-prem': {'2-4': '2 vCPU / 4 GB VM', '4-16': '4 vCPU / 16 GB VM', '8-32': '8 vCPU / 32 GB VM', '16-64': '16 vCPU / 64 GB VM', '32-128': '32 vCPU / 128 GB VM'},
} as const;
function bucketFor(cpu: number): keyof typeof APP_SKU['aws'] {
if (cpu <= 2) return '2-4';
if (cpu <= 4) return '4-16';
if (cpu <= 8) return '8-32';
if (cpu <= 16) return '16-64';
return '32-128';
}
/* ============================ Bill of materials ============================ */
export type BomRow = {
component: string;
quantity: string;
spec: string;
notes?: string;
};
export type BomOptions = {
calls: boolean;
push: boolean;
};
export function bomFor(
tier: Tier,
deploymentType: DeploymentType,
provider: Provider,
opts: BomOptions,
): BomRow[] {
const rows: BomRow[] = [];
const onPrem = provider === 'on-prem';
const k8s = deploymentType === 'kubernetes';
const bucket = bucketFor(tier.appCpu);
const tierId = tier.id as keyof typeof DB_INSTANCE['aws'];
// Load balancer / ingress
if (tier.loadBalancer !== 'none') {
rows.push({
component: k8s ? 'Ingress controller' : 'Load balancer',
quantity: tier.loadBalancer === 'cluster' ? 'HA pair' : '1',
spec: k8s ? LB_BRAND_K8S[provider] : LB_BRAND_VM[provider],
notes: 'TLS terminated at the proxy. Mattermost behind, listening on HTTP/8065.',
});
}
// App / Mattermost
rows.push({
component: k8s ? 'Mattermost (Operator)' : 'Mattermost app server',
quantity: `${tier.appNodes} node${tier.appNodes > 1 ? 's' : ''}`,
spec: k8s
? `${tier.appCpu} vCPU / ${tier.appRam} GB RAM per pod — worker nodes: ${APP_SKU[provider][bucket]}`
: `${APP_SKU[provider][bucket]} (${tier.appCpu} vCPU / ${tier.appRam} GB RAM)`,
notes: tier.ha
? 'High availability — restarts and upgrades are non-disruptive.'
: 'Single node — restarts cause a brief outage.',
});
// Database
rows.push({
component: 'PostgreSQL (primary)',
quantity: '1',
spec: `${DB_BRAND[provider]} — ${DB_INSTANCE[provider][tierId]}`,
notes: tier.id === 'trial' ? 'Co-located with the app for evaluation only.' : 'Separate host. PostgreSQL 14 minimum. App connects on TCP/5432.',
});
if (tier.dbReplicas > 0) {
rows.push({
component: 'PostgreSQL read replicas',
quantity: `${tier.dbReplicas}`,
spec: onPrem
? 'Streaming replication, same size as primary'
: `${DB_BRAND[provider]} read replica`,
notes: 'Read queries (search, dashboards) offloaded from the primary.',
});
}
// Redis
if (tier.redis) {
rows.push({
component: 'Redis cache',
quantity: tier.appNodes >= 16 ? 'Cluster (5+ nodes)' : tier.appNodes >= 8 ? 'Cluster (3 nodes)' : '1 node',
spec: REDIS_BRAND[provider],
notes: 'Required for HA — coordinates sessions and cluster state across app nodes. App connects on TCP/6379.',
});
}
// Search
if (tier.search !== 'none') {
rows.push({
component: 'Search index',
quantity: `${tier.searchNodes} node${tier.searchNodes > 1 ? 's' : ''}`,
spec: SEARCH_BRAND[provider],
notes:
tier.search === 'optional'
? 'Optional at this size — message search will work via PostgreSQL but is slower above ~5,000 users. App connects on HTTPS/9200.'
: tier.search === 'recommended'
? 'Recommended — message search performance degrades on the DB alone at this scale. App connects on HTTPS/9200.'
: 'Required — message search at this scale cannot run on PostgreSQL alone. App connects on HTTPS/9200.',
});
}
// File storage
rows.push({
component: 'File storage',
quantity: '—',
spec: tier.storage === 'local' ? 'Local disk on app host (trial only)' : STORAGE_BRAND[provider],
notes: tier.storage === 'local'
? 'Acceptable for trial only. Move to object storage before any HA or multi-node config.'
: 'S3-compatible bucket. App connects on HTTPS/443. Backup + versioning recommended.',
});
// Calls (RTCD) — optional
if (opts.calls) {
rows.push({
component: 'Calls / RTCD service',
quantity: tier.appNodes >= 8 ? '2-3 nodes (HA)' : '1 node',
spec: 'Mattermost RTCD (real-time communications daemon) — small container or VM (2 vCPU / 4 GB)',
notes:
'Standalone service for audio + screen-share. Clients connect on UDP/8443 (and TCP/8443 fallback). Mattermost talks to RTCD on TCP/8045 for the API.',
});
rows.push({
component: 'Calls plugin',
quantity: '—',
spec: 'Bundled Calls plugin enabled in System Console',
notes: 'Required to drive the RTCD service. Configure provider URL pointing at RTCD endpoint.',
});
}
// Push notification proxy — optional
if (opts.push) {
rows.push({
component: 'Push notification proxy',
quantity: '1',
spec: 'Mattermost Push Proxy (open source) — small single host (1 vCPU / 2 GB) or container',
notes:
'Mediates APNs (api.push.apple.com) and FCM (fcm.googleapis.com) on HTTPS/443. Use HPNS instead if you can reach Mattermost-managed; self-host for air-gapped / sovereign deployments.',
});
}
// Kubernetes-specific: cluster sizing note
if (k8s) {
rows.push({
component: 'Kubernetes cluster',
quantity: tier.appNodes <= 2 ? '3 worker nodes (min)' : `${tier.appNodes + 2}+ worker nodes`,
spec: provider === 'aws' ? 'Amazon EKS' : provider === 'azure' ? 'Azure AKS' : provider === 'gcp' ? 'Google GKE' : provider === 'oci' ? 'Oracle OKE' : 'self-managed K8s',
notes: 'Sized for app pods + system overhead. Mattermost Operator manages the app lifecycle.',
});
}
return rows;
}

View File

@@ -0,0 +1,600 @@
import React, {useState, useMemo} from 'react';
import styles from './styles.module.css';
import {
TIERS,
PROVIDERS,
bomFor,
DB_BRAND,
REDIS_BRAND,
SEARCH_BRAND,
STORAGE_BRAND,
LB_BRAND_VM,
LB_BRAND_K8S,
type Tier,
type DeploymentType,
type Provider,
} from './data';
/**
* Interactive deployment-architecture builder.
*
* Pick a scale tier, deployment type, hosting provider, and optional
* services (Calls / Push). Renders an architecture diagram with explicit
* per-connection port labels, plus a bill of materials and a network
* flows table sized to those choices.
*/
/* ============================ Diagram primitives ============================ */
function FlowDown({port}: {port: string}) {
return (
<div className={styles.flow} aria-hidden>
<span className={styles.flowArrow}>↓</span>
<span className={styles.flowPorts}>{port}</span>
</div>
);
}
function MiniFlow({port}: {port: string}) {
return (
<div className={styles.miniFlow} aria-hidden>
<span className={styles.miniArrow}>↓</span> {port}
</div>
);
}
function HFlow({direction, port}: {direction: 'left' | 'right'; port: string}) {
const arrow = direction === 'left' ? '←' : '→';
return (
<div className={styles.hFlow} aria-hidden>
<span className={styles.hArrow}>{arrow}</span>
<span className={styles.hFlowPorts}>{port}</span>
</div>
);
}
function DataItem({
port,
accent,
label,
subtitle,
}: {
port: string;
accent: string;
label: string;
subtitle: string;
}) {
return (
<div className={styles.dataItem}>
<MiniFlow port={port} />
<div className={`${styles.box} ${accent}`}>
<div className={styles.boxLabel}>{label}</div>
<div className={styles.boxSub}>{subtitle}</div>
</div>
</div>
);
}
/* ============================ Architecture diagram ============================
*
* 3-column CSS grid: left side / center / right side.
* - Center column holds: clients, ALB, Mattermost, data tier (so they all
* align vertically — ALB is locked directly above Mattermost).
* - Left column holds RTCD when Calls is on.
* - Right column holds Push Proxy / HPNS.
*
* Connection labels are minimal (just ports). The Network Flows table
* below the BOM documents what each port is for.
*/
function ArchDiagram({
tier,
k8s,
provider,
showCalls,
pushMode,
}: {
tier: Tier;
k8s: boolean;
provider: Provider;
showCalls: boolean;
pushMode: 'self-hosted' | 'hpns';
}) {
const showLb = tier.loadBalancer !== 'none';
const showRedis = tier.redis;
const showSearch = tier.search !== 'none';
const showReplica = tier.dbReplicas > 0;
const lbName = k8s ? LB_BRAND_K8S[provider] : LB_BRAND_VM[provider];
return (
<div className={styles.diagram} aria-label="Architecture diagram">
{/* Client tier — labeled container, two cards (Web/Desktop + Mobile) */}
<div className={styles.gridCenter}>
<div className={styles.clientsBox}>
<div className={styles.clientsLabel}>Clients</div>
<div className={styles.clientsRow}>
<div className={styles.client}>
<div className={styles.clientLabel}>Web &amp; Desktop</div>
<div className={styles.clientSub}>Channels web app · Windows · macOS · Linux</div>
</div>
<div className={styles.client}>
<div className={styles.clientLabel}>Mobile</div>
<div className={styles.clientSub}>iOS · Android</div>
</div>
</div>
</div>
</div>
{/* Flow row: main HTTPS arrow from clients into LB / Mattermost */}
<div className={styles.gridCenter}>
<FlowDown port="TCP/443" />
</div>
{/* Edge tier (ALB) — center column, locked above Mattermost */}
{showLb && (
<>
<div className={styles.gridCenter}>
<div className={`${styles.box} ${styles.boxLb}`}>
<div className={styles.boxLabel}>{lbName}</div>
<div className={styles.boxSub}>
{tier.loadBalancer === 'cluster' ? 'HA pair · TLS termination' : 'TLS termination'}
</div>
</div>
</div>
<div className={styles.gridCenter}>
<FlowDown port="TCP/8065" />
</div>
</>
)}
{/* App row: RTCD (left) — Mattermost (center) — Push (right), all in one row.
* The DIRECT pill floats absolutely above the RTCD box so it doesn't claim
* its own grid row (which would create an empty band across the diagram). */}
{showCalls && (
<div className={styles.gridLeft}>
<div className={styles.sideGroup}>
<div className={styles.rtcdWrap}>
<div className={styles.flowDirectAbove} aria-label="Direct client media path to RTCD">
<span className={styles.directBadge}>direct</span>
<span className={styles.flowArrowDiag}>↓</span>
<span className={styles.flowPorts}>UDP+TCP/8443</span>
</div>
<div className={`${styles.box} ${styles.boxCalls}`}>
<div className={styles.boxLabel}>RTCD (Calls)</div>
<div className={styles.boxSub}>
{tier.appNodes >= 8 ? 'HA (2–3 nodes)' : '1 node'} · audio + screen-share
</div>
</div>
</div>
<HFlow direction="left" port="TCP/8045" />
</div>
</div>
)}
<div className={styles.gridCenter}>
<div className={`${styles.box} ${styles.boxApp}`}>
<div className={styles.boxLabel}>
Mattermost{k8s ? ' (Operator)' : ''}
<span className={styles.qty}>×{tier.appNodes}</span>
</div>
<div className={styles.boxSub}>
{tier.appCpu} vCPU · {tier.appRam} GB RAM{tier.ha ? ' · HA cluster' : ''}
</div>
</div>
</div>
<div className={styles.gridRight}>
<div className={styles.sideGroup}>
<HFlow direction="right" port="HTTPS/443" />
{pushMode === 'self-hosted' ? (
<div className={`${styles.box} ${styles.boxPush}`}>
<div className={styles.boxLabel}>Push Proxy</div>
<div className={styles.boxSub}>self-hosted</div>
</div>
) : (
<div className={`${styles.box} ${styles.boxPushManaged}`}>
<div className={styles.boxLabel}>Mattermost HPNS</div>
<div className={styles.boxSub}>
<span className={styles.managedBadge}>managed by Mattermost</span>
<br />
Hosted Push Notifications Service
</div>
</div>
)}
</div>
</div>
{/* Data tier — spans full width below center */}
<div className={`${styles.gridFull} ${styles.rowData}`}>
<DataItem
port="TCP/5432"
accent={styles.boxDb}
label={DB_BRAND[provider]}
subtitle={`primary${showReplica ? ` + ${tier.dbReplicas} replica${tier.dbReplicas > 1 ? 's' : ''}` : ''}`}
/>
{showRedis && (
<DataItem
port="TCP/6379"
accent={styles.boxRedis}
label={REDIS_BRAND[provider]}
subtitle={`${tier.appNodes >= 8 ? 'Cluster' : 'Single'} · sessions + cluster state`}
/>
)}
{showSearch && (
<DataItem
port="HTTPS/9200"
accent={styles.boxSearch}
label={SEARCH_BRAND[provider]}
subtitle={`${tier.searchNodes} node${tier.searchNodes > 1 ? 's' : ''} · message search`}
/>
)}
<DataItem
port="HTTPS/443"
accent={styles.boxStorage}
label={tier.storage === 'object' ? STORAGE_BRAND[provider] : 'Local disk'}
subtitle={tier.storage === 'object' ? 'S3-compatible · uploads + plugins' : 'host disk (trial)'}
/>
</div>
</div>
);
}
/* ============================ Network Flows table ============================
*
* Documents every connection in the architecture: source → destination,
* protocol, port, and purpose. Useful for firewall configuration.
* Rows are conditional on the user's choices. */
type NetworkFlow = {
from: string;
to: string;
protocol: string;
port: string;
direction: 'inbound' | 'internal' | 'egress';
purpose: string;
};
function networkFlowsFor(
tier: Tier,
provider: Provider,
showCalls: boolean,
pushMode: 'self-hosted' | 'hpns',
): NetworkFlow[] {
const flows: NetworkFlow[] = [];
flows.push({
from: 'Web / Desktop / Mobile clients',
to: tier.loadBalancer !== 'none' ? 'Load balancer' : 'Mattermost',
protocol: 'HTTPS + WSS',
port: 'TCP/443',
direction: 'inbound',
purpose: 'Channels API, WebSocket events, file uploads',
});
if (showCalls) {
flows.push({
from: 'Web / Desktop / Mobile clients',
to: 'RTCD',
protocol: 'UDP, TCP (fallback)',
port: '8443',
direction: 'inbound',
purpose: 'Calls media (audio, video, screen-share) — direct, bypasses LB',
});
}
if (tier.loadBalancer !== 'none') {
flows.push({
from: 'Load balancer',
to: 'Mattermost',
protocol: 'HTTP',
port: 'TCP/8065',
direction: 'internal',
purpose: 'TLS-terminated app traffic',
});
}
flows.push({
from: 'Mattermost',
to: `PostgreSQL (${DB_BRAND[provider]})`,
protocol: 'TCP',
port: '5432',
direction: 'internal',
purpose: 'Database queries',
});
if (tier.redis) {
flows.push({
from: 'Mattermost',
to: `Redis (${REDIS_BRAND[provider]})`,
protocol: 'TCP',
port: '6379',
direction: 'internal',
purpose: 'Sessions, cluster coordination, cache',
});
}
if (tier.search !== 'none') {
flows.push({
from: 'Mattermost',
to: `OpenSearch (${SEARCH_BRAND[provider]})`,
protocol: 'HTTPS',
port: '9200',
direction: 'internal',
purpose: 'Message search index reads + writes',
});
}
flows.push({
from: 'Mattermost',
to: tier.storage === 'object' ? `Object storage (${STORAGE_BRAND[provider]})` : 'Local disk',
protocol: 'HTTPS',
port: 'TCP/443',
direction: tier.storage === 'object' ? 'internal' : 'internal',
purpose: 'File uploads, plugin assets, compliance exports',
});
if (showCalls) {
flows.push({
from: 'Mattermost',
to: 'RTCD',
protocol: 'TCP',
port: '8045',
direction: 'internal',
purpose: 'Calls signaling and orchestration API',
});
}
flows.push({
from: 'Mattermost',
to: pushMode === 'self-hosted' ? 'Mattermost Push Proxy (self-hosted)' : 'Mattermost HPNS (managed)',
protocol: 'HTTPS',
port: 'TCP/443',
direction: pushMode === 'self-hosted' ? 'internal' : 'egress',
purpose: 'Push notification submission',
});
flows.push({
from: pushMode === 'self-hosted' ? 'Mattermost Push Proxy' : 'Mattermost HPNS',
to: 'Apple APNs (api.push.apple.com)',
protocol: 'HTTPS',
port: 'TCP/443',
direction: 'egress',
purpose: 'iOS push fan-out',
});
flows.push({
from: pushMode === 'self-hosted' ? 'Mattermost Push Proxy' : 'Mattermost HPNS',
to: 'Google FCM (fcm.googleapis.com)',
protocol: 'HTTPS',
port: 'TCP/443',
direction: 'egress',
purpose: 'Android push fan-out',
});
return flows;
}
function NetworkFlowsTable({flows}: {flows: NetworkFlow[]}) {
return (
<>
<h3 className={styles.bomHeading}>Network flows</h3>
<p className={styles.flowsIntro}>
Every connection in the architecture above — useful when configuring firewall
rules, security groups, or a CAP. <strong>Inbound</strong> = from clients;{' '}
<strong>internal</strong> = within the deployment boundary;{' '}
<strong>egress</strong> = out to external services.
</p>
<div className={styles.bomTableWrap}>
<table className={styles.bomTable}>
<thead>
<tr>
<th>From</th>
<th>To</th>
<th>Direction</th>
<th>Protocol</th>
<th>Port</th>
<th>Purpose</th>
</tr>
</thead>
<tbody>
{flows.map((f, i) => (
<tr key={`${f.from}-${f.to}-${i}`}>
<td className={styles.cellComponent}>{f.from}</td>
<td className={styles.cellComponent}>{f.to}</td>
<td className={styles.cellQty}>
<span className={`${styles.directionPill} ${styles[`dir_${f.direction}`]}`}>
{f.direction}
</span>
</td>
<td className={styles.cellSpec}>{f.protocol}</td>
<td className={styles.cellSpec}>{f.port}</td>
<td className={styles.cellNotes}>{f.purpose}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}
/* ============================ Main component ============================ */
export default function DeploymentArchitectureBuilder() {
const [tierId, setTierId] = useState<string>('medium');
const [deploymentType, setDeploymentType] = useState<DeploymentType>('vm');
const [provider, setProvider] = useState<Provider>('aws');
const [includeCalls, setIncludeCalls] = useState<boolean>(false);
const [selfHostPush, setSelfHostPush] = useState<boolean>(true);
const tier = useMemo(
() => TIERS.find((t) => t.id === tierId) ?? TIERS[0],
[tierId],
);
const bom = useMemo(
() => bomFor(tier, deploymentType, provider, {calls: includeCalls, push: selfHostPush}),
[tier, deploymentType, provider, includeCalls, selfHostPush],
);
const flows = useMemo(
() => networkFlowsFor(tier, provider, includeCalls, selfHostPush ? 'self-hosted' : 'hpns'),
[tier, provider, includeCalls, selfHostPush],
);
return (
<section className={styles.builder} aria-label="Deployment architecture builder">
<div className={styles.controls}>
<ControlGroup label="Scale tier">
<select
className={styles.select}
value={tierId}
onChange={(e) => setTierId(e.target.value)}
aria-label="Scale tier"
>
{TIERS.map((t) => (
<option key={t.id} value={t.id}>
{t.seats}
</option>
))}
</select>
</ControlGroup>
<ControlGroup label="Deployment type">
<SegmentedControl
name="deployment-type"
value={deploymentType}
onChange={(v) => setDeploymentType(v as DeploymentType)}
options={[
{value: 'vm', label: 'Virtual machines'},
{value: 'kubernetes', label: 'Kubernetes'},
]}
/>
</ControlGroup>
<ControlGroup label="Hosting">
<select
className={styles.select}
value={provider}
onChange={(e) => setProvider(e.target.value as Provider)}
aria-label="Hosting"
>
{PROVIDERS.map((p) => (
<option key={p.id} value={p.id}>{p.label}</option>
))}
</select>
</ControlGroup>
<ControlGroup label="Optional services">
<div className={styles.checkboxes}>
<label className={styles.checkbox}>
<input
type="checkbox"
checked={includeCalls}
onChange={(e) => setIncludeCalls(e.target.checked)}
/>
<span>Calls (RTCD)</span>
</label>
<label className={styles.checkbox}>
<input
type="checkbox"
checked={selfHostPush}
onChange={(e) => setSelfHostPush(e.target.checked)}
/>
<span>Self-host Push Proxy</span>
</label>
</div>
</ControlGroup>
</div>
<aside className={styles.summary} role="note">
<strong>{tier.seats}</strong> — {tier.description}
</aside>
<ArchDiagram
tier={tier}
k8s={deploymentType === 'kubernetes'}
provider={provider}
showCalls={includeCalls}
pushMode={selfHostPush ? 'self-hosted' : 'hpns'}
/>
<h3 className={styles.bomHeading}>Bill of materials</h3>
<div className={styles.bomTableWrap}>
<table className={styles.bomTable}>
<thead>
<tr>
<th>Component</th>
<th>Quantity</th>
<th>Recommended size / SKU</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{bom.map((row, i) => (
<tr key={`${row.component}-${i}`}>
<td className={styles.cellComponent}>{row.component}</td>
<td className={styles.cellQty}>{row.quantity}</td>
<td className={styles.cellSpec}>{row.spec}</td>
<td className={styles.cellNotes}>{row.notes}</td>
</tr>
))}
</tbody>
</table>
</div>
<NetworkFlowsTable flows={flows} />
<p className={styles.caveat}>
Sizing follows the Mattermost scaling guide; provider SKUs are illustrative
starting points. Verify against current pricing, regional availability, and any
compliance constraints before procurement.
</p>
</section>
);
}
/* ============================ Small UI bits ============================ */
function ControlGroup({label, children}: {label: string; children: React.ReactNode}) {
return (
<div className={styles.controlGroup}>
<div className={styles.controlLabel}>{label}</div>
{children}
</div>
);
}
function SegmentedControl<T extends string>({
name,
value,
onChange,
options,
}: {
name: string;
value: T;
onChange: (v: T) => void;
options: {value: T; label: string}[];
}) {
return (
<div className={styles.segmented} role="radiogroup" aria-label={name}>
{options.map((opt) => (
<label
key={opt.value}
className={`${styles.segment} ${value === opt.value ? styles.segmentActive : ''}`}
>
<input
type="radio"
name={name}
value={opt.value}
checked={value === opt.value}
onChange={() => onChange(opt.value)}
className={styles.segmentInput}
/>
{opt.label}
</label>
))}
</div>
);
}

View File

@@ -0,0 +1,862 @@
/* Deployment Architecture builder.
* Brand alignment: denim headers, marigold accents on boxes, white cards.
* Layout: controls → summary → diagram (stacked rows) → bill of materials. */
.builder {
margin: 1.5rem 0 2rem;
border: 1px solid var(--mm-border-subtle, rgba(30, 50, 92, 0.18));
border-radius: 8px;
overflow: hidden;
background: var(--mm-color-white);
box-shadow: 0 4px 18px -14px rgba(30, 50, 92, 0.4);
}
[data-theme='dark'] .builder {
background: var(--mm-denim-700, #121E37);
border-color: rgba(255, 255, 255, 0.1);
}
/* ============================ Controls ============================ */
.controls {
display: grid;
grid-template-columns: minmax(200px, 0.9fr) minmax(220px, 1fr) minmax(280px, 1.3fr) minmax(180px, 0.9fr);
gap: 1rem;
padding: 1rem 1.25rem;
background: var(--mm-color-denim, #1E325C);
color: var(--mm-color-white);
align-items: end;
}
@media (max-width: 1100px) {
.controls { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 720px) {
.controls { grid-template-columns: 1fr; }
}
.controlGroup {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.controlLabel {
font-family: var(--mm-font-sans);
font-weight: 700;
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.78);
}
.select {
appearance: none;
-webkit-appearance: none;
background-color: var(--mm-color-white);
color: var(--mm-text-primary);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 4px;
padding: 0.55rem 2.25rem 0.55rem 0.7rem;
font-size: 0.9rem;
font-family: var(--mm-font-sans);
cursor: pointer;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%231E325C' stroke-width='2'%3e%3cpolyline points='6 9 12 15 18 9'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 0.55rem center;
background-size: 1rem 1rem;
}
.segmented {
display: inline-flex;
flex-wrap: wrap;
gap: 0.3rem;
background: rgba(255, 255, 255, 0.07);
padding: 0.2rem;
border-radius: 4px;
}
.segment {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.4rem 0.7rem;
font-size: 0.82rem;
font-weight: 600;
font-family: var(--mm-font-sans);
color: rgba(255, 255, 255, 0.78);
cursor: pointer;
border-radius: 3px;
transition: background 120ms ease, color 120ms ease;
white-space: nowrap;
}
.segment:hover {
color: var(--mm-color-white);
}
.segmentActive {
background: var(--mm-color-marigold, #FFBC1F);
color: var(--mm-color-black, #1B1D22) !important;
}
.segmentInput {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
/* Optional services checkboxes */
.checkboxes {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.checkbox {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.92);
cursor: pointer;
user-select: none;
}
.checkbox input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 1rem;
height: 1rem;
border: 1.5px solid rgba(255, 255, 255, 0.55);
border-radius: 3px;
background: transparent;
cursor: pointer;
position: relative;
transition: border-color 120ms ease, background 120ms ease;
}
.checkbox input[type="checkbox"]:hover {
border-color: var(--mm-color-marigold, #FFBC1F);
}
.checkbox input[type="checkbox"]:checked {
background: var(--mm-color-marigold, #FFBC1F);
border-color: var(--mm-color-marigold, #FFBC1F);
}
.checkbox input[type="checkbox"]:checked::after {
content: '';
position: absolute;
left: 3px;
top: 0;
width: 4px;
height: 8px;
border: solid var(--mm-color-black, #1B1D22);
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
/* ============================ Summary ============================ */
.summary {
padding: 0.85rem 1.25rem;
background: var(--mm-denim-50, rgba(30, 50, 92, 0.05));
border-bottom: 1px solid var(--mm-border-subtle, rgba(30, 50, 92, 0.12));
font-size: 0.88rem;
line-height: 1.55;
color: var(--mm-text-primary);
}
[data-theme='dark'] .summary {
background: rgba(255, 255, 255, 0.03);
color: var(--mm-color-white);
}
.summary strong {
color: var(--mm-color-denim, #1E325C);
}
[data-theme='dark'] .summary strong {
color: var(--mm-color-marigold, #FFBC1F);
}
/* ============================ Diagram ============================ */
/* Diagram laid out as a 3-column grid:
* col 1 (1fr, justify-end): left-side components — RTCD when Calls is on
* col 2 (auto): center spine — clients, ALB, Mattermost, data tier
* col 3 (1fr, justify-start): right-side components — Push Proxy / HPNS
*
* The center column auto-sizes to Mattermost's box width, so the ALB sits
* directly above Mattermost regardless of whether RTCD/Push are present.
* Side columns fill remaining space and align toward the spine. */
.diagram {
display: grid;
/* 1fr | auto | 1fr — equal side columns guarantee Mattermost is dead-centred
* regardless of what's in the side columns (RTCD on/off, Push narrow/wide). */
grid-template-columns: 1fr auto 1fr;
align-items: center;
row-gap: 0.85rem; /* generous vertical rhythm */
column-gap: 1rem; /* visible breathing room around Mattermost-adjacent arrows */
padding: 1.5rem 1.25rem 1rem;
}
/* Center column — every spine element (clients, ALB, flows, Mattermost) lives here */
.gridCenter {
grid-column: 2;
justify-self: center;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
flex-wrap: wrap;
}
/* Left column — RTCD lives here when Calls is enabled */
.gridLeft {
grid-column: 1;
justify-self: end;
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Right column — Push Proxy / HPNS lives here */
.gridRight {
grid-column: 3;
justify-self: start;
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Full-width rows — data tier spans the whole diagram */
.gridFull {
grid-column: 1 / -1;
justify-self: center;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: center;
width: 100%;
}
/* Side group: box + horizontal connector together (used in left/right columns).
* Generous gap so the arrow has equal breathing room from its anchor box
* and from Mattermost (matched to .diagram column-gap). */
.sideGroup {
display: inline-flex;
align-items: center;
gap: 1rem;
}
/* Direct-flow pill sits in col 1, aligned to the right edge */
.directFlowCell {
align-self: center;
}
/* Legacy class kept for the few places using it (none active) */
.row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: center;
width: 100%;
position: relative;
}
.rowData {
margin-top: 0.2rem;
align-items: flex-start;
}
/* App cluster — 3 columns aligned horizontally with arrow connectors:
* [Push column] ← [Mattermost] → [RTCD column]
* Stretches center-aligned; columns stack vertically inside (e.g., Push has
* APNs/FCM below it). Collapses to vertical at narrow viewports. */
.appCluster {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.6rem;
margin: 0.3rem 0 0.6rem;
width: 100%;
}
.appCol {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
}
/* Horizontal flow connector between columns — bordered pill with arrow + port */
.hFlow {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.18rem 0.55rem;
font-family: var(--mm-font-mono, monospace);
font-size: 0.68rem;
font-weight: 600;
background: var(--mm-denim-50, rgba(30, 50, 92, 0.05));
border-bottom: 2px solid var(--mm-color-marigold, #FFBC1F);
border-radius: 2px;
white-space: nowrap;
align-self: center;
}
[data-theme='dark'] .hFlow {
background: rgba(255, 255, 255, 0.04);
}
.hArrow {
color: var(--mm-color-marigold, #FFBC1F);
font-weight: 700;
font-size: 0.9rem;
line-height: 1;
}
.hFlowText {
color: var(--mm-color-denim, #1E325C);
font-weight: 600;
}
[data-theme='dark'] .hFlowText {
color: var(--mm-color-white);
}
.hFlowPorts {
margin-left: 0.45rem;
font-weight: 500;
color: var(--mm-text-secondary);
opacity: 0.85;
}
[data-theme='dark'] .hFlowPorts {
color: rgba(255, 255, 255, 0.65);
}
/* Each data tier component gets its own arrow + port indicator above it */
.dataItem {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.miniFlow {
font-family: var(--mm-font-mono, monospace);
font-size: 0.68rem;
font-weight: 600;
color: var(--mm-color-denim, #1E325C);
background: var(--mm-denim-50, rgba(30, 50, 92, 0.05));
border-left: 2px solid var(--mm-color-denim, #1E325C);
padding: 0.15rem 0.45rem;
border-radius: 2px;
white-space: nowrap;
}
[data-theme='dark'] .miniFlow {
color: var(--mm-color-white);
background: rgba(255, 255, 255, 0.05);
border-left-color: var(--mm-denim-300, #8499C5);
}
.miniArrow {
color: var(--mm-color-marigold, #FFBC1F);
font-weight: 700;
margin-right: 0.15rem;
}
/* Flow label between rows — shows protocol + port */
.flow {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin: 0.15rem 0;
font-size: 0.7rem;
font-family: var(--mm-font-mono, monospace);
color: var(--mm-text-secondary, var(--mm-text-primary));
background: var(--mm-denim-50, rgba(30, 50, 92, 0.05));
padding: 0.18rem 0.55rem;
border-radius: 2px;
border-left: 2px solid var(--mm-color-denim, #1E325C);
}
[data-theme='dark'] .flow {
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.78);
border-left-color: var(--mm-denim-300, #8499C5);
}
.flowArrow {
color: var(--mm-color-marigold, #FFBC1F);
font-weight: 700;
font-size: 0.85rem;
line-height: 1;
}
.flowText {
font-weight: 600;
color: var(--mm-color-denim, #1E325C);
}
[data-theme='dark'] .flowText {
color: var(--mm-color-white);
}
.flowPorts {
margin-left: 0.5rem;
font-weight: 500;
color: var(--mm-text-secondary);
opacity: 0.85;
}
[data-theme='dark'] .flowPorts {
color: rgba(255, 255, 255, 0.65);
}
/* Client tier — wrapped in a labeled container "Clients" */
.clientsBox {
position: relative;
display: inline-block;
padding: 1.1rem 1rem 0.7rem;
border: 1.5px dashed var(--mm-border-subtle, rgba(30, 50, 92, 0.35));
border-radius: 6px;
background: transparent;
}
[data-theme='dark'] .clientsBox {
border-color: rgba(255, 255, 255, 0.25);
}
.clientsLabel {
position: absolute;
top: -0.55rem;
left: 0.85rem;
padding: 0.05rem 0.45rem;
font-family: var(--mm-font-sans);
font-weight: 800;
font-size: 0.62rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--mm-color-denim, #1E325C);
background: var(--mm-color-white);
}
[data-theme='dark'] .clientsLabel {
color: var(--mm-color-white);
background: var(--mm-denim-700, #121E37);
}
.clientsRow {
display: flex;
gap: 0.6rem;
justify-content: center;
flex-wrap: nowrap;
}
.client {
flex: 0 0 auto;
min-width: 12rem;
max-width: 16rem;
padding: 0.55rem 0.8rem;
border: 1px solid var(--mm-border-subtle, rgba(30, 50, 92, 0.2));
border-radius: 4px;
background: var(--mm-color-white);
color: var(--mm-text-primary);
text-align: center;
}
[data-theme='dark'] .client {
color: var(--mm-color-white);
background: var(--mm-denim-800, #0C1424);
border-color: rgba(255, 255, 255, 0.1);
}
.clientLabel {
font-family: var(--mm-font-sans);
font-weight: 700;
font-size: 0.82rem;
color: var(--mm-color-denim, #1E325C);
}
[data-theme='dark'] .clientLabel {
color: var(--mm-color-white);
}
.clientSub {
font-size: 0.7rem;
margin-top: 0.15rem;
color: var(--mm-text-secondary, var(--mm-text-primary));
opacity: 0.85;
}
/* Legacy class kept for backward compat — used by old .rowClients references */
.rowClients {
gap: 0.6rem;
}
.box {
flex: 0 0 auto;
min-width: 9rem;
max-width: 14rem;
padding: 0.55rem 0.85rem;
border: 1px solid var(--mm-border-subtle, rgba(30, 50, 92, 0.18));
border-radius: 4px;
background: var(--mm-color-white);
color: var(--mm-text-primary);
text-align: center;
position: relative;
}
[data-theme='dark'] .box {
background: var(--mm-denim-800, #0C1424);
color: var(--mm-color-white);
border-color: rgba(255, 255, 255, 0.12);
}
.boxLabel {
font-family: var(--mm-font-sans);
font-weight: 700;
font-size: 0.85rem;
line-height: 1.2;
color: var(--mm-color-denim, #1E325C);
}
[data-theme='dark'] .boxLabel {
color: var(--mm-color-white);
}
.qty {
display: inline-block;
margin-left: 0.3rem;
padding: 0.05rem 0.35rem;
font-size: 0.7rem;
font-weight: 700;
color: var(--mm-color-black);
background: var(--mm-color-marigold, #FFBC1F);
border-radius: 2px;
}
.boxSub {
margin-top: 0.2rem;
font-size: 0.72rem;
line-height: 1.35;
color: var(--mm-text-secondary, var(--mm-text-primary));
opacity: 0.85;
}
/* Per-role left-edge accents.
* App-row boxes (App / Calls / Push) all share min-height so they align
* horizontally in the grid row even when their subtitles wrap differently. */
.boxLb { border-left: 3px solid var(--mm-color-marigold, #FFBC1F); min-width: 16rem; }
.boxApp { border-left: 3px solid var(--mm-color-denim, #1E325C); min-width: 16rem; min-height: 4rem; }
.boxDb { border-left: 3px solid #1d6f42; min-width: 11rem; } /* postgres green */
.boxRedis { border-left: 3px solid #d4322c; min-width: 11rem; } /* redis red */
.boxSearch { border-left: 3px solid #5a3aa6; min-width: 11rem; } /* opensearch purple */
.boxStorage { border-left: 3px solid #4a5568; min-width: 11rem; } /* neutral */
.boxCalls { border-left: 3px solid #007a5a; min-width: 12rem; min-height: 4rem; } /* calls teal-green */
.boxPush { border-left: 3px solid #3a6ea5; min-width: 12rem; min-height: 4rem; } /* push blue (self-hosted) */
/* HPNS managed-by-Mattermost variant — dotted border + secondary background */
.boxPushManaged {
border: 1.5px dashed var(--mm-color-marigold, #FFBC1F);
background: rgba(255, 188, 31, 0.06);
min-width: 18rem;
min-height: 4rem;
}
[data-theme='dark'] .boxPushManaged {
background: rgba(255, 188, 31, 0.1);
}
.managedBadge {
display: inline-block;
margin-top: 0.2rem;
padding: 0.08rem 0.45rem;
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
background: var(--mm-color-marigold, #FFBC1F);
color: var(--mm-color-black, #1B1D22);
border-radius: 2px;
}
/* Flows-row container — sits between the client tier and the LB tier.
* When Calls is enabled, the direct client→RTCD flow renders on the LEFT
* (matching RTCD's column position below), and the main HTTPS+WSS path
* renders to its right. */
.flowsRow {
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
flex-wrap: wrap;
margin: 0.15rem 0;
}
.flowDirect {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.18rem 0.55rem;
background: rgba(0, 122, 90, 0.08);
border-left: 2px solid #007a5a;
border-radius: 2px;
font-family: var(--mm-font-mono, monospace);
font-size: 0.68rem;
font-weight: 600;
color: var(--mm-color-denim, #1E325C);
white-space: nowrap;
}
[data-theme='dark'] .flowDirect {
background: rgba(0, 122, 90, 0.18);
color: var(--mm-color-white);
}
/* RTCD column wrapper — relative anchor for the absolutely-positioned DIRECT pill */
.rtcdWrap {
position: relative;
}
/* Floating DIRECT pill: sits above the RTCD box without claiming a grid row */
.flowDirectAbove {
position: absolute;
bottom: calc(100% + 0.35rem);
left: 50%;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.18rem 0.55rem;
background: rgba(0, 122, 90, 0.08);
border-left: 2px solid #007a5a;
border-radius: 2px;
font-family: var(--mm-font-mono, monospace);
font-size: 0.68rem;
font-weight: 600;
color: var(--mm-color-denim, #1E325C);
white-space: nowrap;
pointer-events: none;
}
[data-theme='dark'] .flowDirectAbove {
background: rgba(0, 122, 90, 0.22);
color: var(--mm-color-white);
}
.directBadge {
display: inline-block;
padding: 0.05rem 0.35rem;
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
background: #007a5a;
color: var(--mm-color-white);
border-radius: 2px;
}
.flowArrowDiag {
color: #007a5a;
font-weight: 700;
font-size: 0.95rem;
line-height: 1;
}
.directText {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
/* Legacy inline-in-RTCD callout (no longer used after layout swap) */
.calloutDirect {
margin-top: 0.5rem;
padding: 0.4rem 0.55rem;
background: rgba(0, 122, 90, 0.08);
border-left: 2px solid #007a5a;
border-radius: 2px;
font-size: 0.7rem;
line-height: 1.45;
text-align: left;
color: var(--mm-text-primary);
}
[data-theme='dark'] .calloutDirect {
background: rgba(0, 122, 90, 0.18);
color: var(--mm-color-white);
}
.calloutDirect code {
font-family: var(--mm-font-mono, monospace);
font-size: 0.66rem;
background: rgba(255, 255, 255, 0.6);
padding: 0.05rem 0.3rem;
border-radius: 2px;
}
[data-theme='dark'] .calloutDirect code {
background: rgba(255, 255, 255, 0.1);
color: var(--mm-color-white);
}
.calloutBadge {
display: inline-block;
margin-right: 0.35rem;
padding: 0.05rem 0.35rem;
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
background: #007a5a;
color: var(--mm-color-white);
border-radius: 2px;
}
/* External services (APNs / FCM) — small, secondary visual treatment */
.external {
display: inline-flex;
flex-direction: column;
align-items: center;
padding: 0.45rem 0.95rem;
border: 1px dotted var(--mm-border-subtle, rgba(30, 50, 92, 0.3));
border-radius: 4px;
background: transparent;
text-align: center;
min-width: 12rem;
}
[data-theme='dark'] .external {
border-color: rgba(255, 255, 255, 0.25);
}
.externalLabel {
font-family: var(--mm-font-sans);
font-weight: 700;
font-size: 0.78rem;
color: var(--mm-text-secondary, var(--mm-text-primary));
letter-spacing: 0.04em;
}
.externalSub {
font-size: 0.66rem;
margin-top: 0.1rem;
color: var(--mm-text-secondary, var(--mm-text-primary));
opacity: 0.75;
}
/* ============================ Bill of materials ============================ */
.bomHeading {
margin: 1.25rem 1.25rem 0.6rem;
font-family: var(--mm-font-sans);
font-size: 1rem;
font-weight: 700;
color: var(--mm-color-denim, #1E325C);
display: flex;
align-items: center;
gap: 0.45rem;
}
.bomHeading::before {
content: '';
display: inline-block;
width: 0.85rem;
height: 0.85rem;
background: var(--mm-color-marigold, #FFBC1F);
}
[data-theme='dark'] .bomHeading {
color: var(--mm-color-white);
}
.bomTableWrap {
overflow-x: auto;
padding: 0 1.25rem;
}
.bomTable {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
min-width: 720px;
}
.bomTable th,
.bomTable td {
text-align: left;
padding: 0.55rem 0.75rem;
border-bottom: 1px solid var(--mm-border-subtle, rgba(30, 50, 92, 0.1));
vertical-align: top;
}
.bomTable th {
font-family: var(--mm-font-sans);
font-weight: 700;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--mm-text-secondary, var(--mm-text-primary));
border-bottom: 2px solid var(--mm-color-denim, #1E325C);
background: var(--mm-denim-50, rgba(30, 50, 92, 0.04));
}
[data-theme='dark'] .bomTable th {
background: rgba(255, 255, 255, 0.03);
color: var(--mm-color-white);
}
.cellComponent { font-weight: 600; width: 18%; min-width: 9rem; }
.cellQty { width: 12%; min-width: 5rem; color: var(--mm-text-secondary); }
.cellSpec { width: 30%; font-family: var(--mm-font-mono, monospace); font-size: 0.8rem; }
.cellNotes { width: 40%; color: var(--mm-text-secondary, var(--mm-text-primary)); opacity: 0.95; }
.caveat {
margin: 0.9rem 1.25rem 1.25rem;
font-size: 0.78rem;
line-height: 1.5;
color: var(--mm-text-secondary, var(--mm-text-primary));
opacity: 0.85;
}
/* Network flows table — intro paragraph + direction pills */
.flowsIntro {
margin: 0.4rem 1.25rem 0.75rem;
font-size: 0.82rem;
line-height: 1.5;
color: var(--mm-text-secondary, var(--mm-text-primary));
}
.directionPill {
display: inline-block;
padding: 0.1rem 0.45rem;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 2px;
color: var(--mm-color-white);
white-space: nowrap;
}
.dir_inbound { background: #3a6ea5; }
.dir_internal { background: #4a5568; }
.dir_egress { background: #b45309; }
@media (max-width: 720px) {
.bomTable { min-width: 0; }
.cellSpec, .cellNotes { font-size: 0.78rem; }
}

View File

@@ -0,0 +1,75 @@
import React from 'react';
import Link from '@docusaurus/Link';
import styles from './styles.module.css';
// Per-page deployment-mode badge. Paired with <EditionAvailability/>.
//
// Canonical mode slugs:
// cloud-shared — Mattermost Cloud (multi-tenant)
// cloud-dedicated — Mattermost Cloud Dedicated (single-tenant managed)
// cloud-government — Mattermost Cloud Government (FedRAMP Moderate)
// self-hosted — customer-managed, internet-connected
// air-gapped — customer-managed, network-isolated
// tactical-edge — DDIL forward-deployed
//
// Usage:
// <DeploymentAvailability modes="self-hosted,air-gapped,cloud-government" />
type Mode =
| 'cloud-shared'
| 'cloud-dedicated'
| 'cloud-government'
| 'self-hosted'
| 'air-gapped'
| 'tactical-edge';
const MODE_LABEL: Record<Mode, string> = {
'cloud-shared': 'Cloud',
'cloud-dedicated': 'Cloud Dedicated',
'cloud-government': 'Cloud Government',
'self-hosted': 'Self-Hosted',
'air-gapped': 'Air-Gapped',
'tactical-edge': 'Tactical Edge',
};
const KNOWN_MODES = Object.keys(MODE_LABEL) as Mode[];
function parseModes(value: string): {valid: Mode[]; invalid: string[]} {
const valid: Mode[] = [];
const invalid: string[] = [];
for (const raw of value.split(',').map((s) => s.trim()).filter(Boolean)) {
if ((KNOWN_MODES as string[]).includes(raw)) valid.push(raw as Mode);
else invalid.push(raw);
}
return {valid, invalid};
}
export default function DeploymentAvailability({modes}: {modes: string}) {
const {valid, invalid} = parseModes(modes || '');
if (invalid.length > 0) {
return (
<aside className={`${styles.badge} ${styles.unknown}`} role="note">
Unknown deployment mode(s): <code>{invalid.join(', ')}</code>. Valid:{' '}
<code>{KNOWN_MODES.join(', ')}</code>.
</aside>
);
}
if (valid.length === 0) {
return (
<aside className={`${styles.badge} ${styles.unknown}`} role="note">
<code>&lt;DeploymentAvailability/&gt;</code> requires a non-empty <code>modes</code> prop.
</aside>
);
}
const labels = valid.map((m) => MODE_LABEL[m]).join(', ');
return (
<aside className={`${styles.badge} ${styles.deployment}`} role="note" aria-label="Deployment availability">
<span className={styles.key}>Deployment</span>
<span className={styles.value}>
<Link href="/product-overview/editions-and-offerings">{labels}</Link>
</span>
</aside>
);
}

View File

@@ -0,0 +1,52 @@
/* Shares the visual contract with EditionAvailability. */
.badge {
display: inline-flex;
align-items: center;
gap: 0.45rem;
margin: 0.35rem 0.6rem 0.35rem 0;
padding: 0.35rem 0.7rem;
border-radius: 4px;
font-size: 0.85rem;
line-height: 1.2;
background: var(--mm-denim-50, rgba(30, 50, 92, 0.05));
border-left: 3px solid var(--mm-color-denim, #1E325C);
color: var(--mm-text-primary);
}
[data-theme='dark'] .badge {
background: rgba(255, 255, 255, 0.04);
}
.deployment {
border-left-color: var(--mm-color-denim, #1E325C);
}
.key {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 0.72rem;
opacity: 0.75;
}
.value {
font-weight: 600;
}
.value a {
color: inherit;
text-decoration: none;
border-bottom: 1px dotted currentColor;
}
.value a:hover {
text-decoration: none;
border-bottom-style: solid;
}
.unknown {
background: #fff3cd;
color: #664d03;
border-left-color: #ffc107;
}

View File

@@ -0,0 +1,76 @@
import React from 'react';
import BrowserOnly from '@docusaurus/BrowserOnly';
// Inline conditional rendering keyed off the `?scope=` URL parameter.
// Pattern adopted from Teleport (`?scope=enterprise`). Same canonical page,
// multiple rendered variants by deployment mode.
//
// Usage:
// <DeploymentOnly modes="self-hosted,air-gapped">
// This paragraph only renders when ?scope=self-hosted or ?scope=air-gapped.
// </DeploymentOnly>
//
// <DeploymentOnly modes="cloud-shared,cloud-dedicated,cloud-government" hideWhen="other">
// Cloud-specific content. Hidden when ?scope= is set to a non-Cloud mode.
// </DeploymentOnly>
//
// Behaviour:
// - No `?scope=` in URL: render the content (default = show everything).
// - `?scope=<mode>` present and `<mode>` is in `modes`: render content.
// - `?scope=<mode>` present and NOT in `modes`: hide content.
// - SSR: render content (matches "no scope" default — safe for SEO).
type Mode =
| 'cloud-shared'
| 'cloud-dedicated'
| 'cloud-government'
| 'self-hosted'
| 'air-gapped'
| 'tactical-edge';
const KNOWN_MODES: Mode[] = [
'cloud-shared',
'cloud-dedicated',
'cloud-government',
'self-hosted',
'air-gapped',
'tactical-edge',
];
function getScope(): string | null {
if (typeof window === 'undefined') return null;
try {
return new URLSearchParams(window.location.search).get('scope');
} catch {
return null;
}
}
function Inner({
modes,
children,
}: {
modes: string;
children: React.ReactNode;
}) {
const allowed = (modes || '')
.split(',')
.map((s) => s.trim())
.filter((s) => (KNOWN_MODES as string[]).includes(s));
const scope = getScope();
if (!scope) return <>{children}</>; // No filter set — show everything.
if (allowed.includes(scope)) return <>{children}</>;
return null;
}
export default function DeploymentOnly(props: {
modes: string;
children: React.ReactNode;
}) {
return (
<BrowserOnly fallback={<>{props.children}</>}>
{() => <Inner {...props} />}
</BrowserOnly>
);
}

View File

@@ -0,0 +1,71 @@
import React from 'react';
import Link from '@docusaurus/Link';
import styles from './styles.module.css';
// Per-page edition badge. Authors mark which Mattermost editions a feature
// or configuration applies to. Renders a single-row inline badge at the
// top of the page (paired with <DeploymentAvailability/>).
//
// Canonical tier slugs:
// free — Mattermost Team Edition (free, OSS)
// professional — Mattermost Professional
// enterprise — Mattermost Enterprise
// enterprise-advanced — Mattermost Enterprise Advanced
//
// Usage:
// <EditionAvailability tiers="enterprise,enterprise-advanced" />
// <EditionAvailability tiers="free,professional,enterprise,enterprise-advanced" />
type Tier = 'free' | 'professional' | 'enterprise' | 'enterprise-advanced';
const TIER_LABEL: Record<Tier, string> = {
'free': 'Team Edition',
'professional': 'Professional',
'enterprise': 'Enterprise',
'enterprise-advanced': 'Enterprise Advanced',
};
const KNOWN_TIERS = Object.keys(TIER_LABEL) as Tier[];
function parseTiers(value: string): {valid: Tier[]; invalid: string[]} {
const valid: Tier[] = [];
const invalid: string[] = [];
for (const raw of value.split(',').map((s) => s.trim()).filter(Boolean)) {
if ((KNOWN_TIERS as string[]).includes(raw)) {
valid.push(raw as Tier);
} else {
invalid.push(raw);
}
}
return {valid, invalid};
}
export default function EditionAvailability({tiers}: {tiers: string}) {
const {valid, invalid} = parseTiers(tiers || '');
if (invalid.length > 0) {
return (
<aside className={`${styles.badge} ${styles.unknown}`} role="note">
Unknown edition tier(s): <code>{invalid.join(', ')}</code>. Valid:{' '}
<code>{KNOWN_TIERS.join(', ')}</code>.
</aside>
);
}
if (valid.length === 0) {
return (
<aside className={`${styles.badge} ${styles.unknown}`} role="note">
<code>&lt;EditionAvailability/&gt;</code> requires a non-empty <code>tiers</code> prop.
</aside>
);
}
const labels = valid.map((t) => TIER_LABEL[t]).join(', ');
return (
<aside className={`${styles.badge} ${styles.edition}`} role="note" aria-label="Edition availability">
<span className={styles.key}>Edition</span>
<span className={styles.value}>
<Link href="/product-overview/editions-and-offerings">{labels}</Link>
</span>
</aside>
);
}

View File

@@ -0,0 +1,60 @@
/* Per-page Edition / Deployment / Attestation badges.
* Shared visual contract; the three badge components compose into a
* single inline row at the top of the page. */
.badge {
display: inline-flex;
align-items: center;
gap: 0.45rem;
margin: 0.35rem 0.6rem 0.35rem 0;
padding: 0.35rem 0.7rem;
border-radius: 4px;
font-size: 0.85rem;
line-height: 1.2;
background: var(--mm-denim-50, rgba(30, 50, 92, 0.05));
border-left: 3px solid var(--mm-color-denim, #1E325C);
color: var(--mm-text-primary);
}
[data-theme='dark'] .badge {
background: rgba(255, 255, 255, 0.04);
}
/* Edition and Deployment badges share the same denim left-edge — no color
* differentiation between them; the label text already distinguishes them. */
.edition {
border-left-color: var(--mm-color-denim, #1E325C);
}
[data-theme='dark'] .edition {
border-left-color: var(--mm-denim-300, #8499C5);
}
.key {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 0.72rem;
opacity: 0.75;
}
.value {
font-weight: 600;
}
.value a {
color: inherit;
text-decoration: none;
border-bottom: 1px dotted currentColor;
}
.value a:hover {
text-decoration: none;
border-bottom-style: solid;
}
.unknown {
background: #fff3cd;
color: #664d03;
border-left-color: #ffc107;
}

View File

@@ -0,0 +1,17 @@
import React from 'react';
import styles from './styles.module.css';
/**
* Small section-name marker that sits above the H1 on inner-page
* landings. Replaces the Hero block on /api, /api/examples, and
* /developers — content-first, no marketing flair, brand presence
* via the marigold uppercase eyebrow only.
*
* Usage:
* <Eyebrow>API Reference</Eyebrow>
* # REST API v4
* Subtitle paragraph...
*/
export default function Eyebrow({children}: {children: React.ReactNode}) {
return <div className={styles.eyebrow}>{children}</div>;
}

View File

@@ -0,0 +1,16 @@
.eyebrow {
display: inline-block;
font-family: var(--mm-font-heading);
font-size: 0.74rem;
font-weight: 900;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--mm-marigold-700);
border-bottom: 2px solid var(--mm-color-marigold);
padding-bottom: 0.25rem;
margin-bottom: 1rem;
}
[data-theme='dark'] .eyebrow {
color: var(--mm-color-marigold);
}

View File

@@ -0,0 +1,69 @@
import React from 'react';
import Link from '@docusaurus/Link';
import useBaseUrl from '@docusaurus/useBaseUrl';
import styles from './styles.module.css';
type Cta = {
label: string;
to: string;
variant?: 'primary' | 'ghost';
};
type Props = {
eyebrow?: string;
title?: string;
subtitle?: string;
ctas?: Cta[];
/** Pillars under the hero — defaults to brand pillars on the home hero. */
pillars?: {label: string; description?: string}[];
/** Restraint level: 'full' for /, 'compact' for /developers /api. */
intensity?: 'full' | 'compact';
};
export default function Hero({
eyebrow,
title,
subtitle,
ctas = [],
pillars,
intensity = 'compact',
}: Props): React.ReactElement {
const camoUrl = useBaseUrl('/img/patterns/hex-camo-denim.svg');
return (
<section
className={`mm-hero ${styles.hero} ${intensity === 'full' ? styles.full : styles.compact}`}
style={{['--mm-hero-camo' as string]: `url(${camoUrl})`}}
>
<div className={styles.inner}>
{eyebrow && <div className={styles.eyebrow}>{eyebrow}</div>}
{title && <h1 className={styles.title}>{title}</h1>}
{subtitle && <p className={styles.subtitle}>{subtitle}</p>}
{ctas.length > 0 && (
<div className={styles.ctas}>
{ctas.map((cta, i) => (
<Link
key={`${cta.label}-${i}`}
to={cta.to}
className={cta.variant === 'ghost' ? styles.ctaGhost : styles.ctaPrimary}
>
{cta.label}
<span aria-hidden>→</span>
</Link>
))}
</div>
)}
{pillars && (
<ul className={styles.pillars}>
{pillars.map((p) => (
<li key={p.label}>
<strong>{p.label}</strong>
{p.description && <span>{p.description}</span>}
</li>
))}
</ul>
)}
</div>
<div className={styles.curve} aria-hidden />
</section>
);
}

View File

@@ -0,0 +1,175 @@
/* Branded landing-page hero. Mid-intensity per design call:
* full-bleed denim with hex camo, but capped height so it doesn't
* dominate every visit. The curved bottom edge (`.curve`) matches
* the brand book's photo/color transition treatment.
*/
/* The hero fills the article column it lives in (no full-viewport escape).
* Why: 100vw includes scrollbar width on Windows/Chrome and produces a
* horizontal-scroll bleed. Also when a sidebar is present, escaping to
* 100vw misaligns the hero's centered inner content vs. the body content
* below (which sits in the article column). Containing the hero keeps
* its left/right edges consistent with the rest of the page. */
/* The hero is a flat banded section, no rounded corners (the card-like
* frame felt clunky and disconnected from the rest of the page). The
* pattern sits under a heavier dimming overlay so it reads as
* atmospheric texture, not foreground motif. */
.hero {
position: relative;
width: 100%;
margin-top: -1.25rem;
margin-bottom: 2.5rem;
background-color: var(--mm-color-denim);
background-image:
linear-gradient(180deg, rgba(12, 20, 36, 0.55) 0%, rgba(12, 20, 36, 0.25) 100%),
radial-gradient(ellipse 50% 100% at 100% 50%, rgba(91, 119, 177, 0.15) 0%, rgba(30, 50, 92, 0) 60%),
var(--mm-hero-camo);
background-size: cover, cover, 108px 125px;
background-repeat: no-repeat, no-repeat, repeat;
color: var(--mm-color-white);
overflow: hidden;
isolation: isolate;
border-bottom: 3px solid var(--mm-color-marigold);
}
.full {
padding: clamp(2rem, 3.5vw, 3rem) 0 clamp(2rem, 3.5vw, 3rem);
}
.compact {
padding: clamp(1.5rem, 2.5vw, 2.25rem) 0 clamp(1.75rem, 3vw, 2.5rem);
}
.inner {
/* Fill the hero band; the band itself is sized by its parent
* (the article column). Padding keeps content off the band edges. */
margin: 0 auto;
padding: 0 clamp(1.25rem, 3vw, 2rem);
position: relative;
z-index: 2;
}
.eyebrow {
display: inline-block;
font-family: var(--mm-font-heading);
font-size: 0.72rem;
font-weight: 900;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--mm-color-marigold);
border: 1px solid rgba(255, 188, 31, 0.55);
padding: 0.32rem 0.75rem;
border-radius: 2px;
margin-bottom: 1rem;
}
.title {
font-family: var(--mm-font-heading);
font-weight: 900;
font-size: clamp(1.6rem, 3.2vw, 2.4rem);
line-height: 1.1;
letter-spacing: -0.01em;
color: var(--mm-color-white);
margin: 0 0 0.75rem;
max-width: 28ch;
}
.full .title {
font-size: clamp(1.85rem, 3.8vw, 3rem);
max-width: 24ch;
}
.subtitle {
font-size: clamp(0.95rem, 1.2vw, 1.1rem);
line-height: 1.55;
color: rgba(255, 255, 255, 0.82);
max-width: 64ch;
margin: 0 0 1.5rem;
}
.ctas {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1rem;
margin-top: 0.5rem;
}
.ctaPrimary,
.ctaGhost {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.65rem 1.1rem;
font-family: var(--mm-font-heading);
font-weight: 900;
font-size: 0.78rem;
letter-spacing: 0.05em;
text-transform: uppercase;
text-decoration: none;
border-radius: 3px;
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease;
}
.ctaPrimary {
background: var(--mm-color-marigold);
color: var(--mm-color-black);
border: 2px solid var(--mm-color-marigold);
}
.ctaPrimary:hover {
transform: translateY(-1px);
box-shadow: 0 10px 18px -10px rgba(0, 0, 0, 0.5);
color: var(--mm-color-black);
text-decoration: none;
}
.ctaGhost {
background: transparent;
color: var(--mm-color-white);
border: 2px solid rgba(255, 255, 255, 0.5);
}
.ctaGhost:hover {
background: rgba(255, 255, 255, 0.08);
border-color: var(--mm-color-white);
color: var(--mm-color-white);
text-decoration: none;
}
.pillars {
list-style: none;
padding: 1.75rem 0 0;
margin: 1.75rem 0 0;
border-top: 1px solid rgba(255, 255, 255, 0.15);
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem 1.5rem;
}
@media (max-width: 720px) {
.pillars { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.pillars li {
position: relative;
padding-top: 0.65rem;
border-top: 2px solid var(--mm-color-marigold);
}
.pillars strong {
display: block;
font-family: var(--mm-font-heading);
font-size: 0.9rem;
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
margin-bottom: 0.25rem;
color: var(--mm-color-white);
}
.pillars span {
display: block;
font-size: 0.82rem;
line-height: 1.45;
color: rgba(255, 255, 255, 0.75);
}
.curve { display: none; } /* dropping the curve — too theatrical at this scale */

View File

@@ -0,0 +1,254 @@
import React from 'react';
import Link from '@docusaurus/Link';
import styles from './styles.module.css';
/**
* Interactive Intelligent Mission Environment (IME) diagram.
*
* Recreates the four-layer IME infographic (Use Cases → Application →
* Integration → Deployment) with every region linked to its canonical
* documentation page. Replaces the static infographic image on the
* landing page so visitors can drill into the docs from the diagram
* itself.
*/
type CellProps = {
to: string;
title: string;
children: React.ReactNode;
icon?: React.ReactNode;
};
function Cell({to, title, children, icon}: CellProps) {
return (
<Link to={to} className={styles.cell}>
{icon && <span className={styles.icon} aria-hidden>{icon}</span>}
<div className={styles.cellBody}>
<strong className={styles.cellTitle}>{title}</strong>
<div className={styles.cellText}>{children}</div>
</div>
</Link>
);
}
/* === Inline brand-aligned SVG icons (marigold tone via currentColor) === */
const IconChat = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
</svg>
);
const IconChecklist = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 11l3 3L22 4M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
</svg>
);
const IconCall = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.37 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.33 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
);
const IconTarget = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="6"/>
<circle cx="12" cy="12" r="2"/>
</svg>
);
const IconSparkles = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1"/>
</svg>
);
const IconShield = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
);
const IconCode = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>
</svg>
);
const IconServer = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="3" width="20" height="6" rx="1"/>
<rect x="2" y="15" width="20" height="6" rx="1"/>
<line x1="6" y1="6" x2="6.01" y2="6"/>
<line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
);
const IconCompass = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/>
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/>
</svg>
);
const IconLayers = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
<polyline points="2 17 12 22 22 17"/>
<polyline points="2 12 12 17 22 12"/>
</svg>
);
const IconPlug = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
);
const IconGlobe = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
);
const IconDevices = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="3" width="14" height="11" rx="1"/>
<rect x="14" y="9" width="8" height="11" rx="1"/>
<line x1="2" y1="17" x2="11" y2="17"/>
</svg>
);
const IconVideo = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="23 7 16 12 23 17 23 7"/>
<rect x="1" y="5" width="15" height="14" rx="2"/>
</svg>
);
export default function IMEDiagram() {
return (
<section className={styles.diagram} aria-label="Intelligent Mission Environment overview">
{/* USE CASES */}
<div className={`${styles.layer} ${styles.layerUseCases}`}>
<div className={styles.layerLabel}>Use Cases</div>
<div className={styles.layerBody}>
<div className={styles.useCaseRow}>
<Cell to="/use-case-guide/integrated-security-operations" title="Cyber Defense" icon={<IconShield />}>
SOC/CERT ops, out-of-band incident response, Red Team
</Cell>
<Cell to="/use-case-guide/devops-collaboration" title="DevSecOps" icon={<IconCode />}>
Dev productivity, CI/CD, platform engineering, emergency comms
</Cell>
<Cell to="/use-case-guide/secure-command-and-control" title="Mission Operations" icon={<IconCompass />}>
Critical workflow, Zero Trust, C2 to tactical edge, joint ops
</Cell>
</div>
</div>
</div>
{/* APPLICATION */}
<div className={`${styles.layer} ${styles.layerApplication}`}>
<div className={styles.layerLabel}>Application</div>
<div className={styles.layerBody}>
<div className={styles.appGrid}>
<div className={styles.appIntro}>
<h3><span className={styles.introIcon}><IconLayers /></span>Secure Collaborative Workflow</h3>
<p>
Messaging collaboration across web, desktop, and mobile with file sharing,
audio/screenshare, workflow automation, issue tracking, bots, agents, and open
API access — integrating into modern toolchains and legacy systems with
advanced customization and security controls.
</p>
</div>
<div className={styles.productGrid}>
<Cell to="/end-user-guide/collaborate/collaborate-index" title="Messaging Collaboration" icon={<IconChat />}>
Mattermost Channels for ChatOps and automation
</Cell>
<Cell to="/end-user-guide/workflow-automation/workflow-automation-index" title="Workflow Automation" icon={<IconChecklist />}>
Mattermost Playbooks for automating SOPs
</Cell>
<Cell to="/end-user-guide/collaborate/audio-and-screensharing" title="Audio & Screenshare" icon={<IconCall />}>
Mattermost Calls for real‑time calling and screenshare
</Cell>
<Cell to="/end-user-guide/project-management/project-management-index" title="Project Tracking" icon={<IconTarget />}>
Mattermost Boards for Kanban and work management
</Cell>
<Cell to="/end-user-guide/agents" title="AI Agents & Open APIs" icon={<IconSparkles />}>
Mattermost Agents — AI assistance and integration
</Cell>
</div>
</div>
<div className={styles.appFooter}>
<Link to="/end-user-guide/access/access-your-workspace" className={styles.footerCell}>
<span className={styles.footerIcon}><IconDevices /></span>
Desktop, web, mobile &amp; Microsoft&nbsp;Teams clients
</Link>
<Link to="/developers" className={styles.footerCell}>
<span className={styles.footerIcon}><IconCode /></span>
Extensible open-source architecture
</Link>
</div>
</div>
</div>
{/* INTEGRATION */}
<div className={`${styles.layer} ${styles.layerIntegration}`}>
<div className={styles.layerLabel}>Integration</div>
<div className={styles.layerBody}>
<div className={styles.intGrid}>
<div className={styles.intIntro}>
<h3><span className={styles.introIcon}><IconPlug /></span>Integration & AI Platform</h3>
<p>
Operational extensibility with pre-packaged, source-available connectors,
automations, and templates for rapid and effective systems integration.
</p>
</div>
<Cell to="/integrations-guide/integrations-guide-index" title="Layered Extensibility" icon={<IconCode />}>
<ul>
<li>Pre-packaged and custom integrations</li>
<li>Webhooks and slash commands</li>
<li>Plugin architecture</li>
</ul>
</Cell>
<Cell to="/end-user-guide/agents" title="Multi-Agent / Multi-LLM Integration" icon={<IconSparkles />}>
<ul>
<li>Sovereign AI model support via OpenAI APIs</li>
<li>Custom instructions, RAG, semantic search</li>
<li>Responsible AI control plane</li>
<li>MCP and agent-to-agent architecture</li>
</ul>
</Cell>
</div>
<div className={styles.appFooter}>
<Link to="/integrations-guide/integrations-guide-index" className={styles.footerCell}>
<span className={styles.footerIcon}><IconVideo /></span>
Video meetings: Pexip · Webex · Cisco
</Link>
<Link to="/integrations-guide/integrations-guide-index" className={styles.footerCell}>
<span className={styles.footerIcon}><IconPlug /></span>
Pre-built: GitHub · GitLab · Jira · ServiceNow · M365
</Link>
</div>
</div>
</div>
{/* DEPLOYMENT */}
<div className={`${styles.layer} ${styles.layerDeployment}`}>
<div className={styles.layerLabel}>Deployment</div>
<div className={styles.layerBody}>
<div className={styles.depGrid}>
<div className={styles.depIntro}>
<h3><span className={styles.introIcon}><IconGlobe /></span>Sovereign, Cyber‑Resilient Deployment</h3>
<p>
Kubernetes-based orchestration on private, government, and air-gapped clouds.
Scales from tactical edge to strategic core with geo-distributed ultra-high
availability.
</p>
</div>
<Cell to="/deployment-guide/deployment-scenarios/deployment-scenarios-index" title="Tactical Edge to Strategic Core" icon={<IconServer />}>
Runs at the edge, in your data center, in sovereign clouds, and on global hyperscalers: Azure, AWS, Google Cloud, Oracle Cloud.
</Cell>
<Cell to="/for/air-gapped-operator" title="Mission-Ready Security & Resilience" icon={<IconShield />}>
<ul>
<li>Classified, air-gapped, and DDIL operations</li>
<li>Defense-grade controls, monitoring, and mobile security</li>
<li>Scales to 200K+ users</li>
</ul>
</Cell>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,354 @@
/* Intelligent Mission Environment diagram — v2.
*
* Tighter visual match to the brand infographic:
* - Application zone visibly contained in a denim wash + border
* - Intro panels are NOT clickable cards; they're plain text columns
* (no left-edge accent bar — the surrounding denim wash/borders already
* signal the section boundary)
* - Use Case cells use a soft denim-tinted background, not white-on-white
* - All clickable cells share the same white-with-marigold-on-hover treatment
* - Section titles get a thin marigold underline for visual rhythm
*/
.diagram {
margin: 2rem 0 2.5rem;
border: 1px solid var(--mm-border-subtle, rgba(30, 50, 92, 0.18));
border-radius: 8px;
overflow: hidden;
background: var(--mm-color-white);
box-shadow: 0 4px 18px -14px rgba(30, 50, 92, 0.4);
}
[data-theme='dark'] .diagram {
background: var(--mm-denim-700, #121E37);
border-color: rgba(255, 255, 255, 0.1);
}
/* === Layer scaffold (vertical denim label on left + content area on right) === */
.layer {
display: grid;
grid-template-columns: 2rem 1fr;
border-top: 1px solid var(--mm-border-subtle, rgba(30, 50, 92, 0.15));
}
.layer:first-child {
border-top: none;
}
.layerLabel {
display: flex;
align-items: center;
justify-content: center;
background: var(--mm-color-denim, #1E325C);
color: var(--mm-color-white, #fff);
font-family: var(--mm-font-sans);
font-weight: 800;
font-size: 0.62rem;
letter-spacing: 0.22em;
text-transform: uppercase;
writing-mode: vertical-rl;
transform: rotate(180deg);
padding: 0.85rem 0;
}
.layerBody {
padding: 0.9rem 1rem;
background: var(--mm-bg-page, #fff);
}
[data-theme='dark'] .layerBody {
background: var(--mm-denim-800, #0C1424);
}
/* Application zone gets a stronger denim wash to visibly contain it,
matching the original infographic where the whole Application block
reads as one tinted panel. */
.layerApplication .layerBody {
background: var(--mm-denim-50, rgba(30, 50, 92, 0.05));
}
[data-theme='dark'] .layerApplication .layerBody {
background: rgba(255, 255, 255, 0.03);
}
/* === Use Cases row — 3 horizontal cells, soft grey-tinted === */
.useCaseRow {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.6rem;
}
.useCaseRow .cell {
background: var(--mm-denim-50, rgba(30, 50, 92, 0.045));
border-color: transparent;
}
.useCaseRow .cell:hover {
background: var(--mm-color-white, #fff);
border-color: var(--mm-color-marigold, #FFBC1F);
}
[data-theme='dark'] .useCaseRow .cell {
background: rgba(255, 255, 255, 0.04);
border-color: transparent;
}
/* === Intro panels (Application / Integration / Deployment left column) ===
Not clickable — plain text columns, no left-edge accent bar. */
.appIntro,
.intIntro,
.depIntro {
padding: 0.25rem 0.6rem 0.25rem 0;
}
.appIntro h3,
.intIntro h3,
.depIntro h3 {
display: flex;
align-items: center;
gap: 0.45rem;
font-family: var(--mm-font-sans);
font-size: 0.95rem;
font-weight: 700;
color: var(--mm-color-denim, #1E325C);
margin: 0 0 0.4rem;
letter-spacing: -0.005em;
line-height: 1.25;
}
.introIcon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 1.2rem;
height: 1.2rem;
color: var(--mm-color-denim, #1E325C);
}
.introIcon svg {
width: 100%;
height: 100%;
}
[data-theme='dark'] .appIntro .introIcon,
[data-theme='dark'] .intIntro .introIcon,
[data-theme='dark'] .depIntro .introIcon {
color: var(--mm-denim-300, #8499C5);
}
[data-theme='dark'] .appIntro h3,
[data-theme='dark'] .intIntro h3,
[data-theme='dark'] .depIntro h3 {
color: var(--mm-color-white);
}
.appIntro p,
.intIntro p,
.depIntro p {
font-size: 0.78rem;
line-height: 1.5;
color: var(--mm-text-secondary, var(--mm-text-primary));
margin: 0;
}
/* === Application grid: intro + 5 product cards === */
.appGrid {
display: grid;
grid-template-columns: minmax(190px, 1.1fr) 3.4fr;
gap: 1rem;
align-items: stretch;
}
.productGrid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.5rem;
}
.appFooter {
display: grid;
/* Footer strips read as standalone bands — balanced columns so neither
* label wraps mid-phrase. (The grid above intentionally mirrors the
* intro/products asymmetry; the footer doesn't need to.) */
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-top: 0.65rem;
}
.footerCell {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.55rem 0.85rem;
font-size: 0.78rem;
font-weight: 600;
text-align: center;
border: 1px solid var(--mm-border-subtle, rgba(30, 50, 92, 0.15));
border-radius: 4px;
background: var(--mm-bg-page, #fff);
color: var(--mm-text-primary);
text-decoration: none;
transition: border-color 120ms ease, background 120ms ease;
}
.footerIcon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 1.1rem;
height: 1.1rem;
color: var(--mm-color-marigold, #FFBC1F);
}
.footerIcon svg {
width: 100%;
height: 100%;
}
.footerCell:hover {
border-color: var(--mm-color-marigold, #FFBC1F);
background: var(--mm-marigold-100, rgba(255, 188, 31, 0.08));
color: var(--mm-text-primary);
text-decoration: none;
}
[data-theme='dark'] .footerCell {
background: var(--mm-denim-700, #121E37);
color: var(--mm-color-white);
}
/* === Integration & Deployment grids === */
.intGrid,
.depGrid {
display: grid;
grid-template-columns: minmax(190px, 1.1fr) 1.7fr 1.7fr;
gap: 1rem;
align-items: stretch;
}
/* === Cell — the reusable clickable unit === */
.cell {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.6rem 0.7rem;
border: 1px solid var(--mm-border-subtle, rgba(30, 50, 92, 0.18));
border-radius: 4px;
background: var(--mm-bg-page, #fff);
color: var(--mm-text-primary);
text-decoration: none;
transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
}
.cell:hover {
border-color: var(--mm-color-marigold, #FFBC1F);
box-shadow: 0 4px 14px -10px rgba(30, 50, 92, 0.35);
text-decoration: none;
color: var(--mm-text-primary);
transform: translateY(-1px);
}
[data-theme='dark'] .cell {
background: var(--mm-denim-700, #121E37);
color: var(--mm-color-white);
border-color: rgba(255, 255, 255, 0.1);
}
[data-theme='dark'] .cell:hover {
color: var(--mm-color-white);
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.45rem;
height: 1.45rem;
color: var(--mm-color-marigold, #FFBC1F);
margin-bottom: 0.15rem;
}
.icon svg {
width: 100%;
height: 100%;
}
.cellBody {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.cellTitle {
font-family: var(--mm-font-sans);
font-weight: 700;
font-size: 0.82rem;
line-height: 1.25;
color: var(--mm-color-denim, #1E325C);
}
[data-theme='dark'] .cellTitle {
color: var(--mm-color-white);
}
.cellText {
font-size: 0.74rem;
line-height: 1.4;
color: var(--mm-text-secondary, var(--mm-text-primary));
}
.cellText ul {
margin: 0.2rem 0 0;
padding-left: 0.95rem;
list-style: disc;
}
.cellText li {
margin: 0.08rem 0;
}
/* === Use Case cells get larger title + tighter sub-text — readable at row scale === */
.useCaseRow .cellTitle {
font-size: 0.95rem;
}
.useCaseRow .cellText {
font-size: 0.78rem;
}
/* === Responsive collapse === */
@media (max-width: 980px) {
.appGrid,
.intGrid,
.depGrid {
grid-template-columns: 1fr;
}
.productGrid {
grid-template-columns: repeat(2, 1fr);
}
.appFooter {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {
.useCaseRow {
grid-template-columns: 1fr;
}
.productGrid {
grid-template-columns: 1fr;
}
.layer {
grid-template-columns: 1fr;
}
.layerLabel {
writing-mode: horizontal-tb;
transform: none;
padding: 0.4rem 0.85rem;
justify-content: flex-start;
font-size: 0.65rem;
}
}

View File

@@ -0,0 +1,29 @@
import React from 'react';
import styles from './styles.module.css';
const METHODS: Array<{verb: string; tone: string; meaning: string}> = [
{verb: 'GET', tone: 'get', meaning: 'Read'},
{verb: 'POST', tone: 'post', meaning: 'Create / action'},
{verb: 'PUT', tone: 'put', meaning: 'Replace'},
{verb: 'PATCH', tone: 'patch', meaning: 'Update fields'},
{verb: 'DELETE', tone: 'delete', meaning: 'Remove'},
];
/**
* Inline legend that decodes the HTTP method colors used throughout
* the API sidebar and endpoint pages. Most readers internalize these
* fast — but the first 30 seconds on the API landing matter, and the
* legend collapses that ramp.
*/
export default function MethodLegend() {
return (
<ul className={styles.legend}>
{METHODS.map((m) => (
<li key={m.verb} className={styles.item}>
<span className={`${styles.badge} ${styles[m.tone]}`}>{m.verb}</span>
<span className={styles.meaning}>{m.meaning}</span>
</li>
))}
</ul>
);
}

View File

@@ -0,0 +1,41 @@
.legend {
list-style: none;
margin: 1rem 0 2rem;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
font-size: 0.85rem;
}
.item {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0;
margin: 0;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 3.6rem;
padding: 0.2rem 0.55rem;
font-family: var(--mm-font-mono);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.04em;
border-radius: 3px;
color: var(--mm-color-white);
}
.get { background: #2D7BD2; } /* operational blue */
.post { background: #2C7A4F; } /* operational green */
.put { background: #A86A1F; } /* operational amber */
.patch { background: #6B4FA0; } /* operational violet */
.delete { background: #8B1F1F; } /* operational red */
.meaning {
color: var(--mm-text-secondary);
}

View File

@@ -0,0 +1,124 @@
import React from 'react';
import Link from '@docusaurus/Link';
import useBaseUrl from '@docusaurus/useBaseUrl';
import styles from './styles.module.css';
// Plan-availability and Academy badges. Replaces Sphinx's
// .. include:: /_static/badges/<slug>.rst
// pattern. Slug values mirror the original badge filenames so the
// mechanical RST→MDX conversion is a 1:1 rewrite.
type PlanData = {
kind: 'plan';
text: string;
linkText: string;
href: string;
};
type AcademyData = {
kind: 'academy';
title: string;
href: string;
};
const BADGES: Record<string, PlanData | AcademyData> = {
// Plan-availability banners
'all-commercial': {
kind: 'plan',
text: 'Available on',
linkText: 'Entry, Professional, Enterprise, and Enterprise Advanced plans',
href: 'https://mattermost.com/pricing/',
},
'ent-plus': {
kind: 'plan',
text: 'Available on',
linkText: 'Enterprise and Enterprise Advanced plans',
href: 'https://mattermost.com/pricing/',
},
'ent-adv': {
kind: 'plan',
text: 'Available on',
linkText: 'Enterprise Advanced plans',
href: 'https://mattermost.com/pricing/',
},
'ent-cloud-dedicated': {
kind: 'plan',
text: 'Available only on',
linkText: 'Enterprise and Enterprise Advanced plans',
href: 'https://mattermost.com/contact-sales/',
},
'entry-adv': {
kind: 'plan',
text: 'Available on',
linkText: 'Entry and Enterprise Advanced plans',
href: 'https://mattermost.com/pricing/',
},
'entry-ent': {
kind: 'plan',
text: 'Available on',
linkText: 'Entry, Enterprise, and Enterprise Advanced plans (not available on Professional)',
href: 'https://mattermost.com/pricing/',
},
'pro-plus': {
kind: 'plan',
text: 'Available on',
linkText: 'Professional, Enterprise, and Enterprise Advanced plans',
href: 'https://mattermost.com/pricing/',
},
// Academy callout cards
'academy': {kind: 'academy', title: 'Free online training available', href: 'https://academy.mattermost.com'},
'academy-calls': {kind: 'academy', title: 'Learn about Mattermost Calls', href: 'https://academy.mattermost.com/p/new-mattermost-copilot-multi-llm-setup-usage'},
'academy-channels': {kind: 'academy', title: 'Learn about Mattermost channels', href: 'https://mattermost.com/pl/mattermost-academy-channels-training'},
'academy-copilot-calls': {kind: 'academy', title: 'Learn about Mattermost Copilot and Calls', href: 'https://academy.mattermost.com/p/streamline-communication-with-mattermost-copilot-calls'},
'academy-copilot-setup': {kind: 'academy', title: 'Learn about setting up and configuring Mattermost Copilot with multiple LLMs', href: 'https://academy.mattermost.com/p/new-mattermost-copilot-multi-llm-setup-usage'},
'academy-customize-ui': {kind: 'academy', title: 'Learn what you can customize', href: 'https://mattermost.com/pl/mattermost-academy-customization-training'},
'academy-file-storage': {kind: 'academy', title: 'Learn about file storage', href: 'https://mattermost.com/pl/mattermost-academy-configure-file-storage-training'},
'academy-mattermost-database': {kind: 'academy', title: 'Learn about setting up the Mattermost database', href: 'https://mattermost.com/pl/mattermost-academy-database-configuration-training'},
'academy-message-formatting': {kind: 'academy', title: 'Learn about message formatting', href: 'https://mattermost.com/pl/mattermost-academy-format-messages-training'},
'academy-msteams': {kind: 'academy', title: 'Learn about integrating with Microsoft Teams', href: 'https://academy.mattermost.com/p/new-mattermost-for-microsoft-teams-integration'},
'academy-notifications': {kind: 'academy', title: 'Learn about notifications', href: 'https://mattermost.com/pl/mattermost-academy-notifications-training'},
'academy-platform-overview':{kind: 'academy', title: 'Learn about Mattermost', href: 'https://mattermost.com/pl/mattermost-academy-intro-training'},
'academy-playbooks': {kind: 'academy', title: 'Learn about Mattermost Playbooks', href: 'https://academy.mattermost.com/p/mattermost-playbooks-onboarding-training'},
'academy-search': {kind: 'academy', title: 'Learn about search', href: 'https://mattermost.com/pl/mattermost-academy-search-training'},
'academy-tarball-deployment':{kind: 'academy', title: 'Learn about deploying Mattermost using a tarball', href: 'https://mattermost.com/pl/mattermost-academy-deploy-tarball-training'},
'academy-tarball-upgrade': {kind: 'academy', title: 'Learn about upgrading Mattermost using a tarball', href: 'https://mattermost.com/pl/mattermost-academy-upgrade-tarball-training'},
'academy-teams': {kind: 'academy', title: 'Learn about teams', href: 'https://academy.mattermost.com/p/new-mattermost-for-microsoft-teams-integration'},
};
export default function PlanAvailability({slug}: {slug: string}) {
const flagSrc = useBaseUrl('/img/badges/flag-yellow.svg');
const academySrc = useBaseUrl('/img/badges/academy-callout.jpg');
const data = BADGES[slug];
if (!data) {
return (
<div className={`${styles.badge} ${styles.unknown}`} role="note">
Unknown plan-availability slug: <code>{slug}</code>
</div>
);
}
if (data.kind === 'plan') {
return (
<aside className={`${styles.badge} ${styles.plan}`} role="note">
<img src={flagSrc} alt="" className={styles.flag} aria-hidden />
<span>
{data.text}{' '}
<Link href={data.href}>{data.linkText}</Link>
{data.text.endsWith('on') && !data.linkText.includes('plans') ? ' plans' : ''}
</span>
</aside>
);
}
return (
<Link href={data.href} className={`${styles.badge} ${styles.academy}`} target="_blank" rel="noopener">
<img src={academySrc} alt="" className={styles.academyImg} aria-hidden />
<div className={styles.academyCopy}>
<span className={styles.academyAccent}>Mattermost Academy</span>
<span className={styles.academyTitle}>{data.title}</span>
</div>
</Link>
);
}

View File

@@ -0,0 +1,92 @@
/* Plan-availability banner + Academy callout card.
* Ports the visual contract of the Sphinx _static/badges/*.rst includes. */
.badge {
display: flex;
align-items: center;
gap: 0.65rem;
margin: 1rem 0;
border-radius: 6px;
font-size: 0.95rem;
}
/* Plan-availability — flat denim-tinted strip, denim flag on the left
* (matches the Edition/Deployment/Attestation badge family). */
.plan {
padding: 0.55rem 0.85rem;
background: var(--mm-denim-50, rgba(30, 50, 92, 0.05));
border-left: 3px solid var(--mm-color-denim, #1E325C);
color: var(--mm-text-primary);
}
[data-theme='dark'] .plan {
background: rgba(255, 255, 255, 0.04);
border-left-color: var(--mm-denim-300, #8499C5);
color: var(--mm-text-primary);
}
.plan a {
font-weight: 600;
}
.flag {
flex: 0 0 1.05rem;
width: 1.05rem;
height: auto;
}
/* Academy callout — full-bleed card with image on the left, two stacked
* lines of copy on the right. Whole card is the clickable link. */
.academy {
display: flex;
align-items: stretch;
text-decoration: none;
color: inherit;
overflow: hidden;
border: 1px solid var(--mm-border-subtle);
background: var(--mm-bg-surface);
transition: box-shadow 120ms ease, transform 120ms ease;
}
.academy:hover {
text-decoration: none;
box-shadow: 0 4px 12px rgba(30, 50, 92, 0.12);
transform: translateY(-1px);
}
.academyImg {
flex: 0 0 auto;
width: 6rem;
height: 100%;
object-fit: cover;
display: block;
}
.academyCopy {
display: flex;
flex-direction: column;
justify-content: center;
padding: 0.7rem 1rem;
gap: 0.15rem;
}
.academyAccent {
font-family: var(--mm-font-heading);
font-size: 0.7rem;
font-weight: 900;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--mm-color-marigold);
}
.academyTitle {
font-weight: 600;
color: var(--mm-text-primary);
}
.unknown {
padding: 0.55rem 0.85rem;
background: #fff3cd;
color: #664d03;
border-left: 3px solid #ffc107;
}

View File

@@ -0,0 +1,17 @@
import React from 'react';
import styles from './styles.module.css';
type Plan = 'free' | 'professional' | 'enterprise' | 'cloud' | 'self-hosted';
const META: Record<Plan, {label: string; tone: 'neutral' | 'accent' | 'inverse'}> = {
'free': {label: 'Free', tone: 'neutral'},
'professional': {label: 'Professional', tone: 'accent'},
'enterprise': {label: 'Enterprise', tone: 'inverse'},
'cloud': {label: 'Cloud', tone: 'neutral'},
'self-hosted': {label: 'Self-hosted', tone: 'neutral'},
};
export default function PlanBadge({plan}: {plan: Plan}) {
const m = META[plan];
return <span className={`${styles.badge} ${styles[m.tone]}`}>{m.label}</span>;
}

View File

@@ -0,0 +1,33 @@
.badge {
display: inline-block;
padding: 0.18rem 0.55rem;
font-family: var(--mm-font-heading);
font-size: 0.68rem;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
border-radius: 2px;
vertical-align: middle;
margin: 0 0.25rem;
}
.neutral {
background: var(--mm-denim-50);
color: var(--mm-denim-700);
border: 1px solid var(--mm-denim-100);
}
[data-theme='dark'] .neutral {
background: var(--mm-denim-800);
color: var(--mm-denim-100);
border-color: var(--mm-denim-700);
}
.accent {
background: var(--mm-color-marigold);
color: var(--mm-color-black);
}
.inverse {
background: var(--mm-color-denim);
color: var(--mm-color-white);
}

View File

@@ -0,0 +1,160 @@
import React from 'react';
import CodeBlock from '@theme/CodeBlock';
// @ts-ignore — generated by `npm run build:plugin-godocs` (from docs/site) from
// server/public/plugin; gitignored build output, see docs/site/.gitignore.
import rawDocs from '@site/data/plugin-godocs.json';
import type {GoDocs, GoInterfaceDocs} from './types';
import {signatureText} from './types';
import styles from './styles.module.css';
const docs: GoDocs | undefined = rawDocs && Object.keys(rawDocs).length > 0 ? (rawDocs as GoDocs) : undefined;
function MissingDataNotice() {
return (
<p>
Run <code>npm run build:plugin-godocs</code> (from <code>docs/site</code>) to generate this
documentation.
</p>
);
}
function InterfaceTOC({name, iface}: {name: string; iface: GoInterfaceDocs}) {
const methods = iface.Methods ?? [];
const untagged = methods.filter((m) => !m.Tags || m.Tags.length === 0);
const tags = iface.Tags ?? [];
return (
<ul className={styles.toc}>
{untagged.map((m) => (
<li key={m.Name}>
<a href={`#${name}.${m.Name}`}>{signatureText(m)}</a>
</li>
))}
{tags.map((tag) => (
<li key={tag}>
{tag}
<ul>
{methods
.filter((m) => m.Tags?.includes(tag))
.map((m) => (
<li key={m.Name}>
<a href={`#${name}.${m.Name}`}>{signatureText(m)}</a>
</li>
))}
</ul>
</li>
))}
</ul>
);
}
function InterfaceOverview({name, iface}: {name: string; iface: GoInterfaceDocs}) {
if (!iface.HTML && (!iface.Methods || iface.Methods.length === 0)) return null;
return (
<div>
<h2 id={name}>{name}</h2>
{iface.HTML && <div dangerouslySetInnerHTML={{__html: iface.HTML}} />}
<InterfaceTOC name={name} iface={iface} />
</div>
);
}
function InterfaceMethodDocs({name, iface}: {name: string; iface: GoInterfaceDocs}) {
const methods = iface.Methods ?? [];
if (methods.length === 0) return null;
return (
<div>
<h2>{name}</h2>
{methods.map((m) => (
<div key={m.Name} className={styles.method}>
<h3 id={`${name}.${m.Name}`}>
({name}) {m.Name}
</h3>
<CodeBlock language="go">{signatureText(m)}</CodeBlock>
{m.HTML && <div dangerouslySetInnerHTML={{__html: m.HTML}} />}
</div>
))}
</div>
);
}
function exampleEntries(examples: Record<string, {HTML: string; Code: string}>) {
return Object.entries(examples ?? {}).filter(([name]) => name.startsWith('_'));
}
function ExamplesTOC({examples}: {examples: Record<string, {HTML: string; Code: string}>}) {
const entries = exampleEntries(examples);
if (entries.length === 0) return null;
return (
<div>
<h2 id="Examples">Examples</h2>
<ul className={styles.toc}>
{entries.map(([name]) => (
<li key={name}>
<a href={`#Examples.${name}`}>{displayName(name)}</a>
</li>
))}
</ul>
</div>
);
}
function ExamplesDocs({examples}: {examples: Record<string, {HTML: string; Code: string}>}) {
const entries = exampleEntries(examples);
if (entries.length === 0) return null;
return (
<div>
<h2>Examples</h2>
{entries.map(([name, example]) => (
<div key={name} className={styles.method}>
<h3 id={`Examples.${name}`}>(Example) {displayName(name)}</h3>
{example.HTML && <div dangerouslySetInnerHTML={{__html: example.HTML}} />}
<CodeBlock language="go">{example.Code}</CodeBlock>
</div>
))}
</div>
);
}
function displayName(name: string): string {
const stripped = name.replace(/^_/, '');
return stripped.charAt(0).toUpperCase() + stripped.slice(1);
}
/**
* Renders the full server plugin SDK reference (API / Hooks interfaces, plus runnable examples)
* generated from `server/public/plugin`'s Go doc comments by `gen-plugin-godocs`.
*
* Method signatures are rendered as plain, syntax-highlighted Go code (via the site's standard
* `CodeBlock`) rather than HTML with inline pkg.go.dev links baked into the signature, for
* consistent theming (including dark mode) and real Prism highlighting.
*/
export default function PluginGoDocs() {
if (!docs) {
return <MissingDataNotice />;
}
const hasHelpers = (docs.Helpers?.Methods?.length ?? 0) > 0;
return (
<div className={styles.pluginGoDocs}>
{docs.HTML && <div dangerouslySetInnerHTML={{__html: docs.HTML}} />}
<InterfaceOverview name="API" iface={docs.API} />
<InterfaceOverview name="Hooks" iface={docs.Hooks} />
{hasHelpers && <InterfaceOverview name="Helpers" iface={docs.Helpers} />}
<ExamplesTOC examples={docs.Examples} />
<hr />
<InterfaceMethodDocs name="API" iface={docs.API} />
<hr />
<InterfaceMethodDocs name="Hooks" iface={docs.Hooks} />
{hasHelpers && (
<>
<hr />
<InterfaceMethodDocs name="Helpers" iface={docs.Helpers} />
</>
)}
<hr />
<ExamplesDocs examples={docs.Examples} />
</div>
);
}

View File

@@ -0,0 +1,29 @@
.pluginGoDocs h2 {
margin-top: 2.5rem;
padding-top: 1rem;
border-top: 1px solid var(--mm-border-subtle);
}
.toc {
columns: 2;
column-gap: 2rem;
}
.toc li {
break-inside: avoid;
}
.method {
margin: 1.75rem 0;
}
.method h3 {
font-family: var(--mm-font-mono);
font-size: 1rem;
}
@media (max-width: 768px) {
.toc {
columns: 1;
}
}

View File

@@ -0,0 +1,82 @@
// Shape produced by docs/site/scripts/gen-plugin-godocs — kept in sync manually since the
// generator is Go and can't share TS types directly.
export type GoField = {
Names?: string[];
Type: string;
};
export type GoMethodDocs = {
Name: string;
Tags?: string[];
HTML: string;
Parameters?: GoField[];
Results?: GoField[];
};
export type GoInterfaceDocs = {
HTML: string;
Tags?: string[] | null;
Methods?: GoMethodDocs[] | null;
};
export type GoExampleDocs = {
HTML: string;
Code: string;
};
export type GoDocs = {
HTML: string;
API: GoInterfaceDocs;
Hooks: GoInterfaceDocs;
Helpers: GoInterfaceDocs;
Examples: Record<string, GoExampleDocs>;
};
// "[]" / "*" / "..." prefixes, and package-qualified names (e.g.
// "github.com/mattermost/mattermost/server/public/model.Manifest"), mirroring the old Hugo
// `TypeString` shortcode define — take the last "/"-delimited segment, keeping the "pkg.Type"
// suffix intact.
export function typeText(type: string): string {
if (type.startsWith('[]')) return '[]' + typeText(type.slice(2));
if (type.startsWith('*')) return '*' + typeText(type.slice(1));
if (type.startsWith('...')) return '...' + typeText(type.slice(3));
if (type.startsWith('map[')) {
let depth = 0;
let i = 4;
for (; i < type.length; i++) {
if (type[i] === '[') depth++;
else if (type[i] === ']') {
if (depth === 0) break;
depth--;
}
}
return `map[${typeText(type.slice(4, i))}]${typeText(type.slice(i + 1))}`;
}
const parts = type.split('/');
return parts[parts.length - 1];
}
export function fieldsText(fields?: GoField[]): string {
if (!fields || fields.length === 0) return '';
return fields
.map((f) => (f.Names?.length ? `${f.Names.join(', ')} ` : '') + typeText(f.Type))
.join(', ');
}
export function resultsText(results?: GoField[]): string {
if (!results || results.length === 0) return '';
const needsParens = results.length > 1 || Boolean(results[0].Names?.length);
const inner = fieldsText(results);
return needsParens ? `(${inner})` : inner;
}
export function signatureText(method: GoMethodDocs): string {
const params = fieldsText(method.Parameters);
const results = resultsText(method.Results);
return `${method.Name}(${params})${results ? ' ' + results : ''}`;
}
export function displayExampleName(name: string): string {
const stripped = name.replace(/^_/, '');
return stripped.charAt(0).toUpperCase() + stripped.slice(1);
}

View File

@@ -0,0 +1,26 @@
import React from 'react';
import CodeBlock from '@theme/CodeBlock';
// @ts-ignore — generated by `npm run build:plugin-godocs` (from docs/site); gitignored build
// output, see docs/site/.gitignore.
import rawDocs from '@site/data/plugin-godocs.json';
import type {GoDocs} from '../PluginGoDocs/types';
const docs: GoDocs | undefined = rawDocs && Object.keys(rawDocs).length > 0 ? (rawDocs as GoDocs) : undefined;
/**
* Renders a single Go example's source code, sourced from the same generated data as
* `<PluginGoDocs />`, e.g. `<PluginGoExample name="_helloWorld" />` renders the body of
* `Example_helloWorld`.
*/
export default function PluginGoExample({name}: {name: string}) {
const example = docs?.Examples?.[name];
if (!example) {
return (
<p>
Run <code>npm run build:plugin-godocs</code> (from <code>docs/site</code>) to generate this
example code.
</p>
);
}
return <CodeBlock language="go">{example.Code}</CodeBlock>;
}

View File

@@ -0,0 +1,61 @@
import React from 'react';
import CodeBlock from '@theme/CodeBlock';
// @ts-ignore — generated by `npm run build:plugin-jsdocs` (from docs/site) from
// webapp/channels/src/plugins/registry.ts; gitignored build output, see docs/site/.gitignore.
import rawDocs from '@site/data/plugin-jsdocs.json';
import styles from './styles.module.css';
type JsMethodDocs = {
Name: string;
Parameters: string[];
Comments: string[];
};
type JsDocs = {
Interface: {
Methods: JsMethodDocs[];
};
};
const docs: JsDocs | undefined = rawDocs?.Interface?.Methods?.length > 0 ? (rawDocs as JsDocs) : undefined;
function signature(method: JsMethodDocs): string {
return `${method.Name}(${method.Parameters.join(', ')})`;
}
/**
* Renders the web app plugin registry method reference, generated from the JSDoc comments above
* each `PluginRegistry` method/property in `webapp/channels/src/plugins/registry.ts` by
* `gen-plugin-jsdocs`.
*/
export default function PluginJsDocs() {
if (!docs) {
return (
<p>
Run <code>npm run build:plugin-jsdocs</code> (from <code>docs/site</code>) to generate this
documentation.
</p>
);
}
const methods = docs.Interface.Methods;
return (
<div className={styles.pluginJsDocs}>
<ul className={styles.toc}>
{methods.map((m) => (
<li key={m.Name}>
<a href={`#${m.Name}`}>{signature(m)}</a>
</li>
))}
</ul>
<hr />
{methods.map((m) => (
<div key={m.Name} className={styles.method}>
<h3 id={m.Name}>{m.Name}</h3>
<CodeBlock language="js">{`/**\n${m.Comments.map((c) => ` * ${c}`).join('\n')}\n */\n${signature(m)}`}</CodeBlock>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,23 @@
.toc {
columns: 2;
column-gap: 2rem;
}
.toc li {
break-inside: avoid;
}
.method {
margin: 1.75rem 0;
}
.method h3 {
font-family: var(--mm-font-mono);
font-size: 1rem;
}
@media (max-width: 768px) {
.toc {
columns: 1;
}
}

View File

@@ -0,0 +1,110 @@
import React from 'react';
// @ts-ignore — generated by `npm run build:plugin-manifest-docs` (from docs/site) from
// server/public/model's Manifest struct; gitignored build output, see docs/site/.gitignore.
import rawDocs from '@site/data/plugin-manifest-docs.json';
import styles from './styles.module.css';
type SchemaType = 'array' | 'bool' | 'dict' | 'number' | 'object' | 'string' | 'interface';
type ObjectProperty = {
Name: string;
DocHTML?: string;
Schema?: TypeDocs;
};
type TypeDocs = {
Type: SchemaType;
DocHTML?: string;
ObjectProperties?: ObjectProperty[];
ValueSchema?: TypeDocs;
};
type ManifestDocs = {
Schema: TypeDocs;
};
const docs: ManifestDocs | undefined = rawDocs?.Schema ? (rawDocs as ManifestDocs) : undefined;
function titleCase(type: SchemaType): string {
return type.charAt(0).toUpperCase() + type.slice(1);
}
// Arrays/dicts have no properties of their own — the ToC/Docs walk passes through to their
// element schema, same as the old Hugo `pluginmanifestdocs` shortcode.
function TOC({schema, prefix}: {schema?: TypeDocs; prefix: string}) {
if (!schema) return null;
if (schema.Type === 'object') {
return (
<ul>
{(schema.ObjectProperties ?? []).map((prop) => (
<li key={prop.Name}>
<a href={`#${prefix}${prop.Name}`}>
<code>{prop.Name}</code>
</a>
{prop.Schema && ` - ${titleCase(prop.Schema.Type)}`}
{prop.Schema && <TOC schema={prop.Schema} prefix={`${prefix}${prop.Name}.`} />}
</li>
))}
</ul>
);
}
if (schema.Type === 'array' || schema.Type === 'dict') {
return <TOC schema={schema.ValueSchema} prefix={prefix} />;
}
return null;
}
function Docs({schema, prefix}: {schema?: TypeDocs; prefix: string}) {
if (!schema) return null;
if (schema.Type === 'object') {
return (
<ul className={styles.docsList}>
{(schema.ObjectProperties ?? []).map((prop) => (
<li key={prop.Name} id={`${prefix}${prop.Name}`}>
<p>
<code>{prop.Name}</code>
{prop.Schema && ` - ${titleCase(prop.Schema.Type)}`}
</p>
{prop.DocHTML && <div dangerouslySetInnerHTML={{__html: prop.DocHTML}} />}
{prop.Schema?.DocHTML && <div dangerouslySetInnerHTML={{__html: prop.Schema.DocHTML}} />}
{prop.Schema && <Docs schema={prop.Schema} prefix={`${prefix}${prop.Name}.`} />}
</li>
))}
</ul>
);
}
if (schema.Type === 'array' || schema.Type === 'dict') {
return (
<>
{schema.ValueSchema?.DocHTML && <div dangerouslySetInnerHTML={{__html: schema.ValueSchema.DocHTML}} />}
<Docs schema={schema.ValueSchema} prefix={prefix} />
</>
);
}
return null;
}
/**
* Renders the plugin.json/plugin.yaml manifest field reference, generated from
* `model.Manifest`'s Go doc comments by `gen-plugin-manifest-docs`.
*/
export default function PluginManifestDocs() {
if (!docs) {
return (
<p>
Run <code>npm run build:plugin-manifest-docs</code> (from <code>docs/site</code>) to
generate this documentation.
</p>
);
}
return (
<div className={styles.pluginManifestDocs}>
{docs.Schema.DocHTML && <div dangerouslySetInnerHTML={{__html: docs.Schema.DocHTML}} />}
<h2>Table of contents</h2>
<TOC schema={docs.Schema} prefix="" />
<h2>Documentation</h2>
<Docs schema={docs.Schema} prefix="" />
</div>
);
}

View File

@@ -0,0 +1,14 @@
.docsList {
list-style: none;
padding-left: 0;
}
.docsList li {
margin: 1.25rem 0;
padding-left: 1rem;
border-left: 2px solid var(--mm-border-subtle);
}
.docsList code {
font-size: 0.95em;
}

View File

@@ -0,0 +1,158 @@
import {parseAffectedLines, type VersionRange} from './versions';
export const FEED_URL = 'https://securityupdates.mattermost.com/security_updates.json';
export type TabId = 'server' | 'desktop' | 'mobile' | 'plugins';
export type SecurityUpdateRow = {
issueId: string;
cveId?: string;
severity: string;
affectedVersions: string[];
affectedRanges: VersionRange[];
releaseDate: string;
fixVersions: string[];
details: string;
};
export type SecurityUpdateTab = {
id: TabId;
label: string;
rows: SecurityUpdateRow[];
};
export const TAB_ORDER: {id: TabId; label: string}[] = [
{id: 'server', label: 'Server'},
{id: 'desktop', label: 'Desktop'},
{id: 'mobile', label: 'Mobile'},
{id: 'plugins', label: 'Plugins'},
];
const PLATFORM_MAP: Record<string, TabId> = {
'Mattermost Server': 'server',
'Mattermost Desktop App': 'desktop',
'Mattermost Mobile Apps': 'mobile',
'Mattermost Plugins': 'plugins',
};
type FeedItem = {
issue_id?: string;
cve_id?: string;
severity?: string;
affected_versions?: unknown;
fix_release_date?: string;
fix_versions?: unknown;
details?: string;
platform?: string;
};
function normalizeVersions(value: unknown): string[] {
if (value == null || value === '') {
return [];
}
if (Array.isArray(value)) {
return value.flatMap((entry) => normalizeVersions(entry)).map((entry) => entry.trim()).filter(Boolean);
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) {
return [];
}
if (trimmed.startsWith('[')) {
try {
return normalizeVersions(JSON.parse(trimmed));
} catch {
/* keep as plain string */
}
}
return [trimmed];
}
return [String(value)];
}
function normalizeSeverity(value: string): string {
const trimmed = value.trim();
if (!trimmed || /^n\/?a$/i.test(trimmed)) {
return 'N/A';
}
return trimmed.replace(/^\w/, (c) => c.toUpperCase());
}
function parseIssueParts(issueId: string) {
const parts = issueId.split('-');
return {
year: Number.parseInt(parts[1] || '', 10) || 0,
num: Number.parseInt(parts[2] || '', 10) || 0,
extra: Number.parseInt(parts[3] || '', 10) || 0,
};
}
function toRow(item: FeedItem): SecurityUpdateRow {
const cveId = String(item.cve_id || '').trim();
const affectedVersions = normalizeVersions(item.affected_versions);
const fixVersions = normalizeVersions(item.fix_versions);
return {
issueId: String(item.issue_id || '').trim(),
...(cveId ? {cveId} : {}),
severity: normalizeSeverity(String(item.severity || '')),
affectedVersions,
affectedRanges: parseAffectedLines(affectedVersions, fixVersions),
releaseDate: String(item.fix_release_date || '').slice(0, 10),
fixVersions,
details: String(item.details || '').replace(/\s+/g, ' ').trim(),
};
}
function compareFeedItems(a: FeedItem, b: FeedItem): number {
const dateA = Date.parse(String(a.fix_release_date || '').slice(0, 10)) || 0;
const dateB = Date.parse(String(b.fix_release_date || '').slice(0, 10)) || 0;
if (dateA !== dateB) {
return dateB - dateA;
}
const pa = parseIssueParts(String(a.issue_id || ''));
const pb = parseIssueParts(String(b.issue_id || ''));
if (pa.year !== pb.year) {
return pb.year - pa.year;
}
if (pa.num !== pb.num) {
return pb.num - pa.num;
}
return pb.extra - pa.extra;
}
export function normalizeFeed(feed: unknown): SecurityUpdateTab[] {
if (!Array.isArray(feed)) {
throw new Error('Unexpected feed shape: expected an array of advisories.');
}
const tabs = Object.fromEntries(
TAB_ORDER.map((tab) => [tab.id, {...tab, rows: [] as SecurityUpdateRow[]}]),
) as Record<TabId, SecurityUpdateTab>;
[...(feed as FeedItem[])]
.sort(compareFeedItems)
.forEach((item) => {
const tabId = PLATFORM_MAP[String(item.platform || '').trim()];
if (!tabId) {
return;
}
tabs[tabId].rows.push(toRow(item));
});
return TAB_ORDER.map((tab) => tabs[tab.id]);
}
export async function fetchSecurityUpdates(): Promise<{
tabs: SecurityUpdateTab[];
lastModified: string | null;
}> {
const response = await fetch(FEED_URL, {cache: 'no-store'});
if (!response.ok) {
throw new Error(`Security updates feed returned ${response.status}.`);
}
const payload: unknown = await response.json();
return {
tabs: normalizeFeed(payload),
lastModified: response.headers.get('Last-Modified'),
};
}

View File

@@ -0,0 +1,608 @@
import React, {useCallback, useEffect, useId, useMemo, useRef, useState} from 'react';
import {
fetchSecurityUpdates,
TAB_ORDER,
type SecurityUpdateRow,
type SecurityUpdateTab,
type TabId,
} from './feed';
import styles from './styles.module.css';
import {labelToVersion, rowMatchesVersionRange, type Version} from './versions';
type SortKey = 'issueId' | 'severity' | 'affectedVersions' | 'releaseDate' | 'fixVersions' | 'details';
type SortDir = 'asc' | 'desc';
const PAGE_SIZES = [10, 25, 50, 100];
const SEVERITY_OPTIONS = ['Critical', 'High', 'Medium', 'Low', 'N/A'] as const;
const SEVERITY_RANK: Record<string, number> = {
Critical: 4,
High: 3,
Medium: 2,
Low: 1,
'N/A': 0,
};
const VERSION_FILTER_DEBOUNCE_MS = 250;
const EMPTY_FILTERS = {
issueId: '',
severity: '',
affectedVersion: '',
dateFrom: '',
dateTo: '',
fixVersions: '',
search: '',
};
function parseIssueSort(issueId: string): number[] {
const parts = issueId.split('-');
return [
Number.parseInt(parts[1] || '', 10) || 0,
Number.parseInt(parts[2] || '', 10) || 0,
Number.parseInt(parts[3] || '', 10) || 0,
];
}
function firstVersionLabel(lines: string[]): string {
return lines[0] || '';
}
function sortRows(rows: SecurityUpdateRow[], key: SortKey, dir: SortDir): SecurityUpdateRow[] {
const copy = [...rows];
copy.sort((a, b) => {
let result = 0;
if (key === 'issueId') {
const pa = parseIssueSort(a.issueId);
const pb = parseIssueSort(b.issueId);
result = pa[0] - pb[0] || pa[1] - pb[1] || pa[2] - pb[2] || a.issueId.localeCompare(b.issueId);
} else if (key === 'severity') {
result = (SEVERITY_RANK[a.severity] ?? -1) - (SEVERITY_RANK[b.severity] ?? -1);
} else if (key === 'releaseDate') {
result = (a.releaseDate || '').localeCompare(b.releaseDate || '');
} else if (key === 'affectedVersions') {
result = firstVersionLabel(a.affectedVersions).localeCompare(firstVersionLabel(b.affectedVersions));
} else if (key === 'fixVersions') {
result = firstVersionLabel(a.fixVersions).localeCompare(firstVersionLabel(b.fixVersions));
} else {
result = a.details.localeCompare(b.details);
}
return dir === 'asc' ? result : -result;
});
return copy;
}
function rowMatchesFilters(
row: SecurityUpdateRow,
filters: typeof EMPTY_FILTERS,
minV: Version | null,
maxV: Version | null,
): boolean {
const search = filters.search.trim().toLowerCase();
if (search) {
const haystack = [
row.issueId,
row.cveId,
row.severity,
row.releaseDate,
row.details,
...row.affectedVersions,
...row.fixVersions,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
if (!haystack.includes(search)) {
return false;
}
}
if (filters.issueId.trim()) {
const q = filters.issueId.trim().toLowerCase();
const issueHay = `${row.issueId} ${row.cveId || ''}`.toLowerCase();
if (!issueHay.includes(q)) {
return false;
}
}
if (filters.severity && row.severity !== filters.severity) {
return false;
}
if (filters.fixVersions.trim()) {
const q = filters.fixVersions.trim().toLowerCase();
if (!row.fixVersions.some((line) => line.toLowerCase().includes(q))) {
return false;
}
}
if (filters.dateFrom && row.releaseDate && row.releaseDate < filters.dateFrom) {
return false;
}
if (filters.dateTo && row.releaseDate && row.releaseDate > filters.dateTo) {
return false;
}
if (!rowMatchesVersionRange(row.affectedRanges, minV, maxV)) {
return false;
}
return true;
}
function buildPagination(current: number, total: number): (number | '…')[] {
if (total <= 7) {
return Array.from({length: total}, (_, i) => i + 1);
}
const pages: (number | '…')[] = [1];
const start = Math.max(2, current - 2);
const end = Math.min(total - 1, current + 2);
if (start > 2) {
pages.push('…');
}
for (let page = start; page <= end; page += 1) {
pages.push(page);
}
if (end < total - 1) {
pages.push('…');
}
pages.push(total);
return pages;
}
function VersionList({lines}: {lines: string[]}) {
if (lines.length === 0) {
return <span className={styles.muted}>—</span>;
}
return (
<ul className={styles.versionList}>
{lines.map((line, index) => (
<li key={`${line}-${index}`}>{line}</li>
))}
</ul>
);
}
function rowKey(row: SecurityUpdateRow): string {
return `${row.issueId}|${row.releaseDate}|${row.affectedVersions.join(';')}|${row.fixVersions.join(';')}`;
}
export default function SecurityUpdates() {
const formId = useId();
const [status, setStatus] = useState<'loading' | 'error' | 'ready'>('loading');
const [error, setError] = useState<string | null>(null);
const [tabs, setTabs] = useState<SecurityUpdateTab[]>([]);
const [activeTab, setActiveTab] = useState<TabId>('server');
const [filters, setFilters] = useState(EMPTY_FILTERS);
const [versionDraft, setVersionDraft] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('releaseDate');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const loadGeneration = useRef(0);
const load = useCallback(async () => {
const generation = (loadGeneration.current += 1);
setStatus('loading');
setError(null);
try {
const result = await fetchSecurityUpdates();
if (generation !== loadGeneration.current) {
return;
}
setTabs(result.tabs);
setStatus('ready');
} catch (err) {
if (generation !== loadGeneration.current) {
return;
}
setError(err instanceof Error ? err.message : 'Unable to load security updates.');
setStatus('error');
}
}, []);
useEffect(() => {
void load();
}, [load]);
const activeRows = useMemo(
() => tabs.find((tab) => tab.id === activeTab)?.rows ?? [],
[tabs, activeTab],
);
useEffect(() => {
const timer = window.setTimeout(() => {
setFilters((prev) => {
if (prev.affectedVersion === versionDraft) {
return prev;
}
return {...prev, affectedVersion: versionDraft};
});
}, VERSION_FILTER_DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [versionDraft]);
const dateOrderError = useMemo(() => {
if (filters.dateFrom && filters.dateTo && filters.dateFrom > filters.dateTo) {
return 'Fix released to must be on or after fix released from.';
}
return null;
}, [filters.dateFrom, filters.dateTo]);
const affectedVersionError = useMemo(() => {
const trimmed = filters.affectedVersion.trim();
if (!trimmed) {
return null;
}
if (!labelToVersion(trimmed, 'min')) {
return 'Enter a version such as 10.11 or 10.11.23.';
}
return null;
}, [filters.affectedVersion]);
const filterError = affectedVersionError || dateOrderError;
const filterErrorId = `${formId}-filter-error`;
const filteredRows = useMemo(() => {
if (filterError) {
return [];
}
const minV = labelToVersion(filters.affectedVersion, 'min');
const maxV = labelToVersion(filters.affectedVersion, 'max');
return sortRows(
activeRows.filter((row) => rowMatchesFilters(row, filters, minV, maxV)),
sortKey,
sortDir,
);
}, [activeRows, filters, sortKey, sortDir, filterError]);
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
const currentPage = Math.min(page, totalPages);
const pageRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [activeTab, filters, pageSize, sortKey, sortDir]);
const hasActiveFilters = useMemo(
() =>
Object.values(filters).some((value) => value.trim() !== '') || versionDraft.trim() !== '',
[filters, versionDraft],
);
const setFilter = (key: keyof typeof EMPTY_FILTERS, value: string) => {
setFilters((prev) => ({...prev, [key]: value}));
};
const clearFilters = () => {
setFilters(EMPTY_FILTERS);
setVersionDraft('');
};
const toggleSort = (key: SortKey) => {
if (sortKey === key) {
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'));
return;
}
setSortKey(key);
setSortDir(key === 'releaseDate' || key === 'severity' || key === 'issueId' ? 'desc' : 'asc');
};
const sortProps = (key: SortKey) => ({
'aria-sort': sortKey === key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none',
}) as const;
const renderSortButton = (key: SortKey, label: string) => {
const direction =
sortKey === key ? (sortDir === 'asc' ? 'sorted ascending' : 'sorted descending') : 'not sorted';
return (
<button
type="button"
className={styles.sortButton}
onClick={() => toggleSort(key)}
aria-label={`${label}, ${direction}`}
>
{label}
<span className={styles.sortIcons} aria-hidden>
<span className={sortKey === key && sortDir === 'asc' ? styles.sortActive : undefined}>▲</span>
<span className={sortKey === key && sortDir === 'desc' ? styles.sortActive : undefined}>▼</span>
</span>
</button>
);
};
return (
<div className={styles.root}>
<div className={styles.tabs} role="tablist" aria-label="Product">
{TAB_ORDER.map((tab) => {
const selected = tab.id === activeTab;
return (
<button
key={tab.id}
type="button"
role="tab"
id={`${formId}-tab-${tab.id}`}
aria-selected={selected}
aria-controls={`${formId}-panel`}
tabIndex={selected ? 0 : -1}
className={selected ? `${styles.tab} ${styles.tabActive}` : styles.tab}
onClick={() => setActiveTab(tab.id)}
onKeyDown={(event) => {
const order = TAB_ORDER.map((item) => item.id);
const index = order.indexOf(tab.id);
let next = -1;
if (event.key === 'ArrowRight') {
next = (index + 1) % order.length;
} else if (event.key === 'ArrowLeft') {
next = (index - 1 + order.length) % order.length;
} else if (event.key === 'Home') {
next = 0;
} else if (event.key === 'End') {
next = order.length - 1;
}
if (next < 0) {
return;
}
event.preventDefault();
setActiveTab(order[next]);
document.getElementById(`${formId}-tab-${order[next]}`)?.focus();
}}
>
{tab.label}
</button>
);
})}
</div>
<div
role="tabpanel"
id={`${formId}-panel`}
aria-labelledby={`${formId}-tab-${activeTab}`}
>
{status === 'loading' && (
<div className={styles.status} role="status">
Loading security updates…
</div>
)}
{status === 'error' && (
<div className={styles.error} role="alert">
<p>{error}</p>
<button type="button" className={styles.primaryButton} onClick={() => void load()}>
Retry
</button>
</div>
)}
{status === 'ready' && (
<>
<form
className={styles.filters}
onSubmit={(event) => event.preventDefault()}
aria-label="Filter security updates"
>
<label className={styles.searchField}>
Search all columns
<input
type="search"
value={filters.search}
onChange={(event) => setFilter('search', event.target.value)}
placeholder="Issue ID, CVE, version, or details"
/>
</label>
<label>
Issue ID / CVE
<input
type="search"
value={filters.issueId}
onChange={(event) => setFilter('issueId', event.target.value)}
placeholder="MMSA or CVE"
/>
</label>
<label>
Severity
<select
value={filters.severity}
onChange={(event) => setFilter('severity', event.target.value)}
>
<option value="">All severities</option>
{SEVERITY_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label>
Affected version
<input
value={versionDraft}
onChange={(event) => setVersionDraft(event.target.value)}
placeholder="e.g. 10.11.23"
autoComplete="off"
spellCheck={false}
aria-invalid={Boolean(affectedVersionError)}
aria-describedby={
affectedVersionError
? `${filterErrorId} ${formId}-affected-hint`
: `${formId}-affected-hint`
}
/>
<small id={`${formId}-affected-hint`} className={styles.fieldHint}>
Enter the version you&rsquo;re running to see affected advisories.
</small>
</label>
<label>
Fix released from
<input
type="date"
value={filters.dateFrom}
onChange={(event) => setFilter('dateFrom', event.target.value)}
aria-invalid={Boolean(dateOrderError)}
aria-describedby={dateOrderError ? filterErrorId : undefined}
/>
</label>
<label>
Fix released to
<input
type="date"
value={filters.dateTo}
onChange={(event) => setFilter('dateTo', event.target.value)}
aria-invalid={Boolean(dateOrderError)}
aria-describedby={dateOrderError ? filterErrorId : undefined}
/>
</label>
<label>
Fix versions
<input
type="search"
value={filters.fixVersions}
onChange={(event) => setFilter('fixVersions', event.target.value)}
placeholder="e.g. 10.11.23"
/>
</label>
<div className={styles.filterActions}>
<button
type="button"
className={styles.resetButton}
onClick={clearFilters}
disabled={!hasActiveFilters}
>
Clear filters
</button>
</div>
{filterError ? (
<p className={styles.filterError} id={filterErrorId} role="alert">
{filterError}
</p>
) : null}
</form>
<div className={styles.toolbar}>
<label className={styles.pageSize}>
Rows
<select
value={pageSize}
onChange={(event) => setPageSize(Number.parseInt(event.target.value, 10) || 25)}
>
{PAGE_SIZES.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</label>
<p className={styles.summary} role="status">
{filteredRows.length === 0
? 'No matching advisories'
: `Showing ${(currentPage - 1) * pageSize + 1}–${(currentPage - 1) * pageSize + pageRows.length} of ${filteredRows.length}`}
</p>
</div>
<div className={styles.tableWrap}>
<table className={styles.table} aria-label={`${TAB_ORDER.find((tab) => tab.id === activeTab)?.label} security updates`}>
<thead>
<tr>
<th scope="col" {...sortProps('issueId')}>
{renderSortButton('issueId', 'Issue ID')}
</th>
<th scope="col" {...sortProps('severity')}>
{renderSortButton('severity', 'Severity')}
</th>
<th scope="col" {...sortProps('affectedVersions')}>
{renderSortButton('affectedVersions', 'Affected versions')}
</th>
<th scope="col" {...sortProps('releaseDate')}>
{renderSortButton('releaseDate', 'Release date')}
</th>
<th scope="col" {...sortProps('fixVersions')}>
{renderSortButton('fixVersions', 'Fix versions')}
</th>
<th scope="col" {...sortProps('details')}>
{renderSortButton('details', 'Details')}
</th>
</tr>
</thead>
<tbody>
{pageRows.length === 0 ? (
<tr>
<td colSpan={6} className={styles.empty}>
No security updates match these filters.
</td>
</tr>
) : (
pageRows.map((row) => (
<tr key={rowKey(row)}>
<td data-label="Issue ID">
<span className={styles.issueId}>{row.issueId}</span>
{row.cveId ? (
<a
className={styles.cve}
href={`https://nvd.nist.gov/vuln/detail/${row.cveId}`}
rel="noopener noreferrer"
>
{row.cveId}
</a>
) : null}
</td>
<td data-label="Severity">
<span
className={`${styles.badge} ${
styles[`badge${row.severity.replace(/[^A-Za-z]/g, '')}` as keyof typeof styles] ||
styles.badgeNA
}`}
>
{row.severity}
</span>
</td>
<td data-label="Affected versions">
<VersionList lines={row.affectedVersions} />
</td>
<td data-label="Release date">{row.releaseDate || '—'}</td>
<td data-label="Fix versions">
<VersionList lines={row.fixVersions} />
</td>
<td data-label="Details">{row.details || '—'}</td>
</tr>
))
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<nav className={styles.pagination} aria-label="Security updates pages">
<ol>
{buildPagination(currentPage, totalPages).map((item, index) => (
<li key={`${item}-${index}`}>
{item === '…' ? (
<span className={styles.ellipsis}>…</span>
) : (
<button
type="button"
className={item === currentPage ? styles.pageActive : undefined}
aria-label={`Page ${item}`}
aria-current={item === currentPage ? 'page' : undefined}
onClick={() => setPage(item)}
>
{item}
</button>
)}
</li>
))}
</ol>
</nav>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,458 @@
.root {
margin: 1.5rem 0 2.5rem;
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: 0;
margin-bottom: -1px;
}
.tab {
display: inline-flex;
align-items: center;
padding: 0.55rem 0.95rem;
border: 1px solid var(--mm-border-subtle);
background: var(--mm-bg-surface);
color: var(--mm-text-primary);
font: inherit;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
font-size: 0.78rem;
cursor: pointer;
}
.tab:focus-visible {
outline: 2px solid var(--mm-focus-ring);
outline-offset: 2px;
z-index: 1;
}
.tabActive {
background: var(--mm-color-denim);
border-color: var(--mm-color-denim);
color: var(--mm-color-white);
}
[data-theme='dark'] .tabActive {
background: var(--mm-denim-500);
border-color: var(--mm-denim-500);
color: var(--mm-color-white);
}
.filters {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: start;
gap: 0.75rem 1rem;
padding: 1rem 1.15rem;
margin-bottom: 1rem;
border: 1px solid var(--mm-border-subtle);
border-radius: 4px;
background: var(--mm-bg-subtle);
}
.filters label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.82rem;
font-weight: 600;
color: var(--mm-text-secondary);
}
.filters input,
.filters select,
.pageSize select {
width: 100%;
min-height: 2.4rem;
padding: 0.4rem 0.6rem;
border: 1px solid var(--mm-border-subtle);
border-radius: 4px;
background: var(--mm-bg-surface);
color: var(--mm-text-primary);
font: inherit;
font-weight: 400;
color-scheme: light;
}
[data-theme='dark'] .filters input,
[data-theme='dark'] .filters select,
[data-theme='dark'] .pageSize select {
color-scheme: dark;
}
.filters input:focus-visible,
.filters select:focus-visible,
.pageSize select:focus-visible,
.sortButton:focus-visible,
.primaryButton:focus-visible,
.resetButton:focus-visible,
.pagination button:focus-visible {
outline: 2px solid var(--mm-focus-ring);
outline-offset: 2px;
}
.searchField {
grid-column: 1 / -1;
}
.filterActions {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
}
.hint {
margin: 0;
flex: 1 1 16rem;
font-size: 0.82rem;
font-weight: 400;
color: var(--mm-text-secondary);
}
.fieldHint {
margin: 0.15rem 0 0;
font-size: 0.75rem;
font-weight: 400;
line-height: 1.4;
color: var(--mm-text-secondary);
}
.fieldHint code {
padding: 0.05rem 0.25rem;
border-radius: 3px;
background: var(--mm-bg-surface);
font-size: 0.72rem;
}
.filterError {
grid-column: 1 / -1;
margin: 0;
color: #8b1f1f;
font-size: 0.9rem;
}
[data-theme='dark'] .filterError {
color: #f4a0ab;
}
.primaryButton,
.resetButton {
padding: 0.5rem 0.95rem;
border-radius: 4px;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.primaryButton {
border: none;
background: var(--mm-color-denim);
color: var(--mm-color-white);
}
.primaryButton:hover {
background: var(--mm-denim-600);
}
.resetButton {
border: 1px solid var(--mm-border-subtle);
background: transparent;
color: var(--mm-text-primary);
}
.resetButton:hover:not(:disabled) {
background: var(--mm-bg-surface);
}
.resetButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.pageSize {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.pageSize select {
width: auto;
min-width: 4.5rem;
}
.summary {
margin: 0;
color: var(--mm-text-secondary);
}
.tableWrap {
overflow-x: auto;
border: 1px solid var(--mm-border-subtle);
background: var(--mm-bg-surface);
}
.tableWrap .table {
width: 100%;
min-width: 0;
display: table;
border-collapse: collapse;
table-layout: auto;
}
.table th,
.table td {
padding: 0.7rem 0.75rem;
text-align: left;
vertical-align: top;
font-size: 0.86rem;
line-height: 1.45;
border-bottom: 1px solid var(--mm-border-subtle);
overflow-wrap: anywhere;
word-break: break-word;
}
.table th {
background: var(--mm-denim-50);
font-size: 0.78rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--mm-text-secondary);
}
[data-theme='dark'] .table th {
background: var(--mm-denim-800);
}
.table tbody tr:nth-child(even) {
background: var(--mm-bg-subtle);
}
.table th:nth-child(1),
.table td:nth-child(1) {
width: 14%;
}
.table th:nth-child(2),
.table td:nth-child(2) {
width: 9%;
}
.table th:nth-child(3),
.table td:nth-child(3),
.table th:nth-child(5),
.table td:nth-child(5) {
width: 15%;
}
.table th:nth-child(4),
.table td:nth-child(4) {
width: 11%;
}
.sortButton {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 2rem;
padding: 0.35rem 0;
border: none;
background: transparent;
color: inherit;
font: inherit;
letter-spacing: inherit;
text-transform: inherit;
cursor: pointer;
}
.sortIcons {
display: inline-flex;
flex-direction: column;
font-size: 0.55rem;
line-height: 0.7;
}
.sortIcons span {
opacity: 0.4;
}
.sortIcons .sortActive {
opacity: 1;
color: var(--mm-color-denim);
}
[data-theme='dark'] .sortIcons .sortActive {
color: var(--mm-denim-200);
}
.issueId {
display: block;
font-family: var(--mm-font-mono);
font-size: 0.8rem;
font-weight: 600;
}
.cve {
display: inline-block;
margin-top: 0.2rem;
font-size: 0.78rem;
}
.versionList {
margin: 0;
padding: 0;
list-style: none;
}
.versionList li + li {
margin-top: 0.2rem;
}
.muted,
.empty {
color: var(--mm-text-secondary);
text-align: center;
}
.empty {
padding: 1.5rem 0.75rem;
}
.badge {
display: inline-flex;
padding: 0.15rem 0.5rem;
border: 1px solid transparent;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
white-space: nowrap;
}
.badgeCritical {
background: #fbeded;
color: #8b1f1f;
}
.badgeHigh {
background: #fdecef;
color: #c8102e;
}
.badgeMedium {
background: var(--mm-marigold-300);
color: var(--mm-color-black);
border-color: var(--mm-marigold-500);
}
.badgeLow {
background: #eaf5ee;
color: #1f5a39;
}
.badgeNA {
background: var(--mm-denim-100);
color: var(--mm-denim-700);
}
[data-theme='dark'] .badgeCritical {
background: #4a1f1f;
color: #e9a0a0;
}
[data-theme='dark'] .badgeHigh {
background: #4e0f1b;
color: #f4a0ab;
}
[data-theme='dark'] .badgeMedium {
background: #5c4a14;
color: var(--mm-marigold-100);
border-color: var(--mm-marigold-700);
}
[data-theme='dark'] .badgeLow {
background: #14442c;
color: #7fcc9e;
}
[data-theme='dark'] .badgeNA {
background: var(--mm-denim-700);
color: var(--mm-denim-200);
}
.pagination {
margin-top: 1rem;
}
.pagination ol {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0;
padding: 0;
list-style: none;
}
.pagination button,
.ellipsis {
min-width: 2rem;
height: 2rem;
padding: 0 0.4rem;
border: 1px solid transparent;
background: transparent;
color: var(--mm-text-primary);
font: inherit;
cursor: pointer;
}
.pageActive {
border-color: var(--mm-border-strong) !important;
color: var(--mm-color-denim);
font-weight: 700;
}
[data-theme='dark'] .pageActive {
color: var(--mm-marigold-300);
border-color: var(--mm-denim-300) !important;
}
.status,
.error {
padding: 1.25rem;
border: 1px solid var(--mm-border-subtle);
background: var(--mm-bg-subtle);
}
.error p {
margin: 0 0 0.75rem;
}
@media (max-width: 996px) {
.filters {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.filters {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,231 @@
export type Version = [number, number, number];
export type VersionRange = {min: Version; max: Version};
const INF = 999_999;
const ZERO: Version = [0, 0, 0];
const MAX: Version = [INF, INF, INF];
export function parseVersionToken(raw: string): Version | null {
const cleaned = raw.trim().replace(/^v/i, '');
const match = cleaned.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+|x))?/i);
if (!match) {
return null;
}
const major = Number(match[1]);
const minor = match[2] === undefined ? 0 : Number(match[2]);
const patchToken = match[3];
const patch =
patchToken === undefined ? 0 : patchToken.toLowerCase() === 'x' ? 0 : Number(patchToken);
return [major, minor, patch];
}
export function compareVersions(a: Version, b: Version): number {
for (let i = 0; i < 3; i += 1) {
if (a[i] !== b[i]) {
return a[i] - b[i];
}
}
return 0;
}
function exclusiveUpperBound(v: Version): Version {
if (v[2] > 0) {
return [v[0], v[1], v[2] - 1];
}
if (v[1] > 0) {
return [v[0], v[1] - 1, INF];
}
if (v[0] > 0) {
return [v[0] - 1, INF, INF];
}
return ZERO;
}
function versionFromMatch(token: string, asUpperBound: boolean): Version | null {
const parsed = parseVersionToken(token);
if (!parsed) {
return null;
}
const cleaned = token.trim().replace(/^v/i, '');
if (asUpperBound && /\.x$/i.test(cleaned)) {
return [parsed[0], parsed[1], INF];
}
// "11" as a max means the whole 11 major line.
if (asUpperBound && /^\d+$/.test(cleaned)) {
return [parsed[0], INF, INF];
}
// "11.9" as a max means the whole 11.9 line.
if (asUpperBound && /^\d+\.\d+$/.test(cleaned)) {
return [parsed[0], parsed[1], INF];
}
return parsed;
}
function parseOneLine(line: string): VersionRange[] {
const text = line.trim();
if (!text || /^(n\/a|na)$/i.test(text) || /^exclud/i.test(text)) {
return [];
}
if (/^all$/i.test(text)) {
return [{min: ZERO, max: MAX}];
}
const toMatch = text.match(
/v?(\d+(?:\.\d+){0,2}(?:\.x)?)\s+to\s+v?(\d+(?:\.\d+){0,2}(?:\.x)?)/i,
);
if (toMatch) {
const min = versionFromMatch(toMatch[1], false);
const max = versionFromMatch(toMatch[2], true);
if (min && max) {
return [{min, max}];
}
}
const earlier = text.match(/v?(\d+(?:\.\d+){0,2}(?:\.x)?)\s+and earlier/i);
if (earlier) {
const max = versionFromMatch(earlier[1], true);
if (max) {
return [{min: ZERO, max}];
}
}
if (text.includes('<=')) {
const segments = text.split('<=');
const left = (segments[0] || '').trim();
const rightSegments = segments.slice(1).map((part) => part.trim()).filter(Boolean);
if (rightSegments.length === 0) {
return [];
}
// Multiple "<=" operators: union of upper bounds (e.g. "<=7.1.9 <=7.8.4").
if (rightSegments.length > 1) {
return rightSegments
.map((part) => versionFromMatch(part.split(/\s+/)[0] || '', true))
.filter((max): max is Version => max !== null)
.map((max) => ({min: ZERO, max}));
}
const tokens = rightSegments[0].split(/\s+/).filter(Boolean);
// Pre-2024 mashed line: "<=11.9 11.0.9 11.4.8 …" is a version list, not one open range.
if (tokens.length > 1 && !left) {
return tokens.flatMap((token) => {
const min = versionFromMatch(token, false);
const max = versionFromMatch(token, true);
return min && max ? [{min, max}] : [];
});
}
const max = versionFromMatch(tokens[0] || '', true);
if (!max) {
return [];
}
const branch = left.match(/(\d+)\.(\d+)\.x/i);
if (branch) {
return [{min: [Number(branch[1]), Number(branch[2]), 0], max}];
}
const leftVer = parseVersionToken(left.replace(/^.*\s/, ''));
if (leftVer) {
return [{min: leftVer, max}];
}
return [{min: ZERO, max}];
}
if (text.includes('>=')) {
const min = versionFromMatch(text.split('>=')[1] || '', false);
if (min) {
return [{min, max: MAX}];
}
}
if (text.includes('<')) {
const ranges: VersionRange[] = [];
for (const match of text.matchAll(/<\s*v?(\d+(?:\.\d+){0,2}(?:\.x)?)/gi)) {
// Exclusive bound: do not expand "7.9" to the whole 7.9 line first.
const bound = versionFromMatch(match[1], false);
if (bound) {
ranges.push({min: ZERO, max: exclusiveUpperBound(bound)});
}
}
if (ranges.length > 0) {
return ranges;
}
}
const ranges: VersionRange[] = [];
for (const match of text.matchAll(/v?(\d+\.\d+(?:\.\d+|\.x)?)/gi)) {
const token = match[1];
const version = parseVersionToken(token);
if (version) {
ranges.push({min: version, max: version});
}
}
return ranges;
}
function highestFixVersion(fixVersions: string[]): Version | null {
let highest: Version | null = null;
for (const line of fixVersions) {
for (const match of line.matchAll(/v?(\d+\.\d+(?:\.\d+)?)/gi)) {
const parsed = parseVersionToken(match[1]);
if (!parsed) {
continue;
}
if (!highest || compareVersions(parsed, highest) > 0) {
highest = parsed;
}
}
}
return highest;
}
function isUnbounded(range: VersionRange): boolean {
return compareVersions(range.max, MAX) === 0;
}
export function parseAffectedLines(lines: string[], fixVersions: string[] = []): VersionRange[] {
const ranges = lines.flatMap(parseOneLine);
const cap = highestFixVersion(fixVersions);
if (!cap) {
return ranges;
}
return ranges.map((range) => {
if (!isUnbounded(range)) {
return range;
}
return {
min: range.min,
max: compareVersions(cap, range.min) < 0 ? range.min : cap,
};
});
}
export function rangesOverlap(a: VersionRange, b: VersionRange): boolean {
return compareVersions(a.min, b.max) <= 0 && compareVersions(b.min, a.max) <= 0;
}
export function rowMatchesVersionRange(
ranges: VersionRange[],
minFilter: Version | null,
maxFilter: Version | null,
): boolean {
if (!minFilter && !maxFilter) {
return true;
}
if (ranges.length === 0) {
return false;
}
const filter: VersionRange = {
min: minFilter ?? ZERO,
max: maxFilter ?? MAX,
};
return ranges.some((range) => rangesOverlap(range, filter));
}
export function labelToVersion(label: string, bound: 'min' | 'max'): Version | null {
const trimmed = label.trim();
if (!trimmed || !/^v?\d+(?:\.\d+){0,2}(?:\.x)?$/i.test(trimmed)) {
return null;
}
return versionFromMatch(trimmed, bound === 'max');
}

View File

@@ -0,0 +1,33 @@
import React from 'react';
import styles from './styles.module.css';
type Stat = {
value: string | number;
label: string;
hint?: string;
};
/**
* Horizontal strip of headline numbers — used at the top of landing
* pages to surface the size/scope of the section in a single glance.
*
* Usage:
* <StatStrip stats={[
* {value: '549', label: 'Endpoints'},
* {value: '38', label: 'Resource groups'},
* {value: '5', label: 'Languages', hint: 'curl, PowerShell, Python, Node, Go'},
* ]} />
*/
export default function StatStrip({stats}: {stats: Stat[]}) {
return (
<ul className={styles.strip}>
{stats.map((s) => (
<li key={s.label} className={styles.stat}>
<span className={styles.value}>{s.value}</span>
<span className={styles.label}>{s.label}</span>
{s.hint && <span className={styles.hint}>{s.hint}</span>}
</li>
))}
</ul>
);
}

View File

@@ -0,0 +1,48 @@
.strip {
list-style: none;
margin: 1.5rem 0 2.5rem;
padding: 1.25rem 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem 2rem;
border-top: 2px solid var(--mm-color-marigold);
border-bottom: 1px solid var(--mm-border-subtle);
}
.stat {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0;
margin: 0;
}
.value {
font-family: var(--mm-font-heading);
font-weight: 900;
font-size: clamp(1.75rem, 2.5vw, 2.4rem);
line-height: 1;
letter-spacing: -0.02em;
color: var(--mm-color-denim);
}
[data-theme='dark'] .value {
color: var(--mm-color-white);
}
.label {
font-family: var(--mm-font-heading);
font-weight: 900;
font-size: 0.7rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--mm-text-secondary);
margin-top: 0.4rem;
}
.hint {
font-size: 0.78rem;
line-height: 1.4;
color: var(--mm-text-secondary);
margin-top: 0.25rem;
}

View File

@@ -0,0 +1,224 @@
import React, {useCallback, useEffect, useRef, useState} from 'react';
import styles from './styles.module.css';
type VersionParts = {major: number; minor: number};
type RowRef = {row: HTMLTableRowElement; version: string};
function sortVersions(versions: string[]): string[] {
return [...versions].sort((a, b) => {
const [majorA, minorA] = a.split('.').map(Number);
const [majorB, minorB] = b.split('.').map(Number);
if (majorA !== majorB) {
return majorB - majorA;
}
return minorB - minorA;
});
}
function parseVersion(version: string): VersionParts {
if (version === 'all') {
return {major: 0, minor: 0};
}
const [major, minor] = version.split('.').map(Number);
return {major, minor};
}
function isVersionRelevant(
version: string,
sourceVersion: string,
targetVersion: string,
): boolean {
const rowV = parseVersion(version);
const sourceV = parseVersion(sourceVersion);
const targetV = parseVersion(targetVersion);
return (
(sourceVersion === 'all' ||
rowV.major > sourceV.major ||
(rowV.major === sourceV.major && rowV.minor >= sourceV.minor)) &&
(targetVersion === 'all' ||
rowV.major < targetV.major ||
(rowV.major === targetV.major && rowV.minor <= targetV.minor))
);
}
function collectRows(table: HTMLTableElement): {rows: RowRef[]; versions: string[]} {
const rows: RowRef[] = [];
const versions: string[] = [];
let lastVersion: string | null = null;
table.querySelectorAll<HTMLTableRowElement>('tbody tr').forEach((tr) => {
const cells = tr.querySelectorAll('td');
if (cells.length > 1) {
const versionText = cells[0].textContent?.trim() ?? '';
const versionMatch = versionText.match(/^v(\d+\.\d+)$/);
if (versionMatch) {
const version = versionMatch[1];
if (!versions.includes(version)) {
versions.push(version);
}
lastVersion = version;
rows.push({row: tr, version});
return;
}
}
if (lastVersion && cells.length >= 1) {
rows.push({row: tr, version: lastVersion});
}
});
rows.forEach(({row, version}) => {
row.dataset.version = version;
});
return {rows, versions: sortVersions(versions)};
}
function findUpgradeNotesTable(): HTMLTableElement | null {
return document.querySelector<HTMLTableElement>('.upgrade-notes-table');
}
export default function UpgradeNotesFilter() {
const rowsRef = useRef<RowRef[]>([]);
const [versions, setVersions] = useState<string[]>([]);
const [sourceVersion, setSourceVersion] = useState('all');
const [targetVersion, setTargetVersion] = useState('all');
const [error, setError] = useState<string | null>(null);
const [ready, setReady] = useState(false);
const initializeRows = useCallback(() => {
const table = findUpgradeNotesTable();
if (!table || table.querySelectorAll('tbody tr').length === 0) {
return false;
}
const {rows, versions: foundVersions} = collectRows(table);
if (rows.length === 0 || foundVersions.length === 0) {
return false;
}
rowsRef.current = rows;
setVersions(foundVersions);
setReady(true);
return true;
}, []);
useEffect(() => {
if (initializeRows()) {
return undefined;
}
const timer = window.setInterval(() => {
if (initializeRows()) {
window.clearInterval(timer);
}
}, 100);
return () => window.clearInterval(timer);
}, [initializeRows]);
const showAllRows = () => {
rowsRef.current.forEach(({row}) => row.classList.remove('filtered'));
};
const applyFilter = () => {
setError(null);
if (!ready || rowsRef.current.length === 0) {
if (!initializeRows()) {
setError('Error: Upgrade notes table is still loading. Try again in a moment.');
return;
}
}
if (sourceVersion === 'all' && targetVersion === 'all') {
showAllRows();
return;
}
const sourceV = parseVersion(sourceVersion);
const targetV = parseVersion(targetVersion);
if (
targetV.major < sourceV.major ||
(targetV.major === sourceV.major && targetV.minor < sourceV.minor)
) {
setError('Error: Target version must be greater than or equal to source version.');
return;
}
const versionVisibility = new Map<string, boolean>();
versions.forEach((version) => {
versionVisibility.set(
version,
isVersionRelevant(version, sourceVersion, targetVersion),
);
});
rowsRef.current.forEach(({row, version}) => {
row.classList.toggle('filtered', !versionVisibility.get(version));
});
};
const resetFilter = () => {
setSourceVersion('all');
setTargetVersion('all');
setError(null);
showAllRows();
};
return (
<div className={styles.filters}>
<h3 className={styles.title}>Filter Upgrade Notes</h3>
<p className={styles.description}>
Select source and target versions to see only relevant upgrade notes
</p>
<div className={styles.controls}>
<label className={styles.field} htmlFor="source-version">
From version:
<select
id="source-version"
value={sourceVersion}
onChange={(event) => setSourceVersion(event.target.value)}
disabled={!ready}>
<option value="all">All versions</option>
{versions.map((version) => (
<option key={`source-${version}`} value={version}>
v{version}
</option>
))}
</select>
</label>
<label className={styles.field} htmlFor="target-version">
To version:
<select
id="target-version"
value={targetVersion}
onChange={(event) => setTargetVersion(event.target.value)}
disabled={!ready}>
<option value="all">All versions</option>
{versions.map((version) => (
<option key={`target-${version}`} value={version}>
v{version}
</option>
))}
</select>
</label>
<button
type="button"
className={styles.applyButton}
onClick={applyFilter}
disabled={!ready}>
Apply Filter
</button>
<button type="button" className={styles.resetButton} onClick={resetFilter}>
Reset
</button>
</div>
{error ? <div className={styles.error}>{error}</div> : null}
</div>
);
}

View File

@@ -0,0 +1,82 @@
.filters {
margin: 1.5rem 0;
padding: 1rem 1.25rem;
border: 1px solid var(--mm-border-subtle);
border-radius: 4px;
background: var(--mm-bg-subtle);
}
.title {
margin: 0 0 0.35rem;
font-family: var(--mm-font-heading);
font-size: 1.1rem;
}
.description {
margin: 0 0 1rem;
color: var(--mm-text-secondary);
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1rem;
align-items: end;
}
.field {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.9rem;
}
.field select {
min-width: 10rem;
padding: 0.5rem 0.65rem;
border: 1px solid var(--mm-border-subtle);
border-radius: 4px;
background: var(--mm-bg-surface);
color: var(--mm-text-primary);
}
.applyButton,
.resetButton {
padding: 0.55rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
}
.applyButton {
background: var(--mm-denim-400);
color: var(--mm-color-white);
}
.applyButton:hover {
background: var(--mm-color-denim);
}
.resetButton {
background: transparent;
color: var(--mm-text-primary);
border: 1px solid var(--mm-border-subtle);
}
.resetButton:hover {
background: var(--mm-bg-surface);
}
.error {
margin-top: 0.75rem;
padding: 0.65rem 0.75rem;
border-left: 4px solid #e74c3c;
border-radius: 4px;
background: #fadbd8;
color: #922b21;
}
:global(.upgrade-notes-table tr.filtered) {
display: none;
}

View File

@@ -0,0 +1,989 @@
/*
* Mattermost docs theme overrides on top of Infima.
* Tokens live in tokens.css. This file maps tokens to Infima vars and
* adds the brand surface treatments (navbar density, code blocks,
* sidebar, footer) so the site reads as part of the Mattermost product
* family, not a default Docusaurus install.
*/
/* === Map brand tokens onto Infima === */
:root {
--ifm-color-primary: var(--mm-denim-500);
--ifm-color-primary-dark: var(--mm-denim-600);
--ifm-color-primary-darker: var(--mm-denim-700);
--ifm-color-primary-darkest: var(--mm-denim-800);
--ifm-color-primary-light: var(--mm-denim-400);
--ifm-color-primary-lighter: var(--mm-denim-300);
--ifm-color-primary-lightest: var(--mm-denim-200);
--ifm-font-family-base: var(--mm-font-sans);
--ifm-font-family-monospace: var(--mm-font-mono);
--ifm-heading-font-family: var(--mm-font-sans);
--ifm-heading-font-weight: 700;
--ifm-h1-font-size: 2.6rem;
--ifm-h2-font-size: 1.85rem;
--ifm-h3-font-size: 1.35rem;
--ifm-line-height-base: 1.65;
--ifm-spacing-vertical: 1.2rem;
--ifm-code-font-size: 90%;
--docusaurus-highlighted-code-line-bg: rgba(30, 50, 92, 0.1);
--ifm-background-color: var(--mm-bg-page);
--ifm-background-surface-color: var(--mm-bg-surface);
--ifm-navbar-background-color: var(--mm-color-denim);
--ifm-navbar-link-color: rgba(255, 255, 255, 0.85);
--ifm-navbar-link-hover-color: var(--mm-color-white);
--ifm-navbar-height: 4rem;
--ifm-footer-background-color: var(--mm-color-denim);
--ifm-footer-color: var(--mm-color-white);
--ifm-footer-link-color: rgba(255,255,255,0.78);
--ifm-footer-link-hover-color: var(--mm-color-marigold);
--ifm-footer-title-color: var(--mm-color-white);
--ifm-link-color: var(--mm-link);
--ifm-link-hover-color: var(--mm-link-hover);
--ifm-link-hover-decoration: underline;
--ifm-toc-border-color: var(--mm-border-subtle);
--ifm-hr-border-color: var(--mm-border-subtle);
/* Infima's own default body color isn't derived from our brand palette
* at all — pin it to --mm-text-primary for a guaranteed, auditable
* near-black. */
--ifm-font-color-base: var(--mm-text-primary);
--ifm-color-content: var(--mm-text-primary);
--ifm-font-color-secondary: var(--mm-text-secondary);
--ifm-color-content-secondary: var(--mm-text-secondary);
/* Sidebar */
--ifm-menu-color: var(--mm-denim-700);
--ifm-menu-color-active: var(--mm-color-denim);
/* denim-100, not -50: the sidebar container itself now sits on
* --mm-bg-subtle (= denim-50, see .theme-doc-sidebar-container), so the
* active/hover highlight needs a visibly darker step or it'd disappear
* into its own background. */
--ifm-menu-color-background-active: var(--mm-denim-100);
--ifm-menu-color-background-hover: var(--mm-denim-100);
--ifm-menu-link-padding-vertical: 0.45rem;
--ifm-menu-link-padding-horizontal: 0.85rem;
}
[data-theme='dark'] {
--ifm-color-primary: var(--mm-color-sky);
--ifm-color-primary-dark: var(--mm-denim-300);
--ifm-color-primary-darker: var(--mm-denim-400);
--ifm-color-primary-darkest: var(--mm-denim-500);
--ifm-color-primary-light: var(--mm-denim-100);
--ifm-color-primary-lighter: var(--mm-denim-50);
--ifm-color-primary-lightest: var(--mm-color-white);
--ifm-background-color: var(--mm-bg-page);
--ifm-background-surface-color: var(--mm-bg-surface);
--docusaurus-highlighted-code-line-bg: rgba(255, 188, 31, 0.18);
--ifm-navbar-background-color: var(--mm-denim-800);
--ifm-navbar-link-color: rgba(255,255,255,0.85);
--ifm-navbar-link-hover-color: var(--mm-color-white);
--ifm-menu-color: rgba(255,255,255,0.78);
--ifm-menu-color-active: var(--mm-color-white);
--ifm-menu-color-background-active: var(--mm-denim-700);
--ifm-menu-color-background-hover: var(--mm-denim-700);
/* See light-mode block above — same rationale, dark-mode near-white. */
--ifm-font-color-base: var(--mm-text-primary);
--ifm-color-content: var(--mm-text-primary);
--ifm-font-color-secondary: var(--mm-text-secondary);
--ifm-color-content-secondary: var(--mm-text-secondary);
}
/* The mobile nav slide-out (.navbar-sidebar) always has a dark denim
* background, but its menu inherits the light-mode --ifm-menu-color*
* vars tuned for the light desktop sidebar — force the dark-mode values
* unconditionally so mobile text stays readable in light mode too. */
.navbar-sidebar {
--ifm-menu-color: rgba(255,255,255,0.78);
--ifm-menu-color-active: var(--mm-color-white);
--ifm-menu-color-background-active: var(--mm-denim-700);
--ifm-menu-color-background-hover: var(--mm-denim-700);
}
/* "Back to main menu" text color isn't covered by the vars above. */
.navbar-sidebar__back {
color: var(--ifm-menu-color-active);
}
/* === Type rhythm === */
html {
font-feature-settings: "ss01", "cv11"; /* Inter alt forms */
}
body {
font-family: var(--mm-font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Headlines target Trade Gothic Next Heavy proportions: tighter set
* than Archivo Black's default. We tune letter-spacing per size and
* apply a mild horizontal squeeze via font-stretch where the platform
* supports it (no-op on Archivo Black, which is fixed-width).
*
* h1 used to be 900-weight Archivo Black, but that read as too
* heavy/shouty for page titles at body-copy reading distance — same
* complaint that already moved h2-h4 to this sans-at-700 treatment, so
* h1 now shares it too (just at its own larger --ifm-h1-font-size). */
h1,
.markdown h2, .markdown h3, .markdown h4,
h2, h3, h4 {
font-family: var(--mm-font-sans);
font-weight: 700;
letter-spacing: -0.01em;
line-height: 1.25;
}
/* A touch of extra tracking on bold prose keeps adjacent heavy glyphs
* from visually merging — subtle enough not to loosen word shapes. */
.markdown strong,
.markdown b {
letter-spacing: 0.01em;
}
/* Color alone isn't a reliable "this is a link" signal (WCAG 1.4.1), but a
* default underline on every inline link reads as visual noise in body
* copy. Medium weight is the non-color cue instead — heavier than body
* text, without competing with `.markdown strong`'s bold weight or adding
* an underline (reserved for hover/focus). Scoped to prose so nav/footer/
* sidebar links keep their own treatments. */
.markdown a {
font-weight: 600;
}
/* Search results page ("See all results") — each hit's title is a plain h2
* outside `.markdown`, so it inherits the full --ifm-h2-font-size (1.85rem),
* as large as an in-article section header. Scoped to the wrapper class
* Docusaurus's SearchPage puts on <html> rather than the plugin's own
* (hashed) CSS-module class, so this survives dependency upgrades. */
.search-page-wrapper h2 {
font-size: 1.15rem;
font-weight: 600;
letter-spacing: -0.005em;
}
/* Section heading rhythm — a small marigold rule sits above each h2.
* Scoped via the `mm-section` class (auto-applied below) so it doesn't
* collide with OpenAPI plugin internal h2s (endpoint URL header,
* Request/Response panels, tab headings, etc.) which use their own
* layout and get squashed by our padding-top.
*
* The auto-apply trick: any h2 inside a doc page that ISN'T a
* descendant of an `.openapi-*` container gets the rule. */
.markdown h2:not([class*="openapi"]):not([class*="openapi"] *) {
position: relative;
margin-top: 3rem;
padding-top: 1.4rem;
}
.markdown h2:not([class*="openapi"]):not([class*="openapi"] *)::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 3rem;
height: 2px;
background: var(--mm-color-marigold);
}
/* Belt-and-braces: explicitly null the rule on every OpenAPI plugin
* heading we know the plugin emits. */
.markdown .openapi__method-endpoint-path,
.markdown .openapi-tabs__heading,
.markdown .openapi-tabs__response-header,
.markdown [class*="openapi"] h2 {
position: static !important;
margin-top: 0 !important;
padding-top: 0 !important;
}
.markdown .openapi__method-endpoint-path::before,
.markdown .openapi-tabs__heading::before,
.markdown .openapi-tabs__response-header::before,
.markdown [class*="openapi"] h2::before {
display: none !important;
}
/* === Navbar — solid denim with white text === */
.navbar {
background-color: var(--mm-color-denim);
border-bottom: 2px solid var(--mm-color-marigold);
box-shadow: 0 2px 12px -8px rgba(0, 0, 0, 0.35);
}
.navbar__inner {
max-width: 1280px;
margin: 0 auto;
padding: 0 clamp(1rem, 2.5vw, 1.75rem);
}
.navbar__brand {
margin-right: 1.5rem;
}
.navbar__logo {
height: 1.85rem;
margin-right: 0;
}
.navbar__item {
font-size: 0.9rem;
}
.navbar__link {
/* Body font (Inter) for legibility at small sizes — Archivo Black 900 is
* designed for display headlines, not 12-14px nav. Brand identity is
* preserved via uppercase + letter-spacing. */
font-family: var(--mm-font-sans);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
font-size: 0.875rem;
padding: 0 0.85rem;
position: relative;
}
.navbar__link {
color: rgba(255, 255, 255, 0.92);
}
.navbar__link:hover {
color: var(--mm-color-white);
text-decoration: none;
}
.navbar__link--active,
.navbar__link[aria-current="page"] {
color: var(--mm-color-white);
}
.navbar__link--active::after,
.navbar__link[aria-current="page"]::after {
content: '';
position: absolute;
left: 0.85rem;
right: 0.85rem;
bottom: -1.2rem;
height: 3px;
background: var(--mm-color-marigold);
}
/* GitHub link → render as outlined ghost on a denim navbar. */
.navbar__items--right .navbar__link[href*="github.com"] {
border: 1px solid rgba(255, 255, 255, 0.3);
color: var(--mm-color-white);
border-radius: 3px;
padding: 0.4rem 0.9rem;
letter-spacing: 0.06em;
}
.navbar__items--right .navbar__link[href*="github.com"]:hover {
background: rgba(255, 255, 255, 0.1);
border-color: var(--mm-color-white);
}
/* Color-mode toggle on a denim navbar — white icon, subtle hover. */
.navbar .clean-btn {
color: rgba(255, 255, 255, 0.78);
}
.navbar .clean-btn:hover {
color: var(--mm-color-white);
background: rgba(255, 255, 255, 0.08);
}
/* === Sidebar === */
/* Left nav, right TOC, and the main reading column previously all shared
* the same page background, so nothing set the article apart as "the
* content". Tinting just the side chrome with --mm-bg-subtle — leaving
* the main column on --mm-bg-page/-surface — makes the reading pane read
* as the brighter, foregrounded surface. */
.theme-doc-sidebar-container {
background-color: var(--mm-bg-subtle);
border-right: 1px solid var(--mm-border-subtle) !important;
}
[data-theme='dark'] .theme-doc-sidebar-container {
border-right-color: var(--mm-denim-700) !important;
}
.menu {
padding: 1.25rem 0.75rem !important;
font-size: 0.9rem;
}
.menu__list-item-collapsible,
.menu__link {
border-radius: 3px;
}
.menu__link--sublist-caret::after {
opacity: 0.6;
}
.menu__caret {
background: transparent !important;
}
.menu__list-item--collapsed .menu__list-item-collapsible {
border-radius: 3px;
}
/* Categories at any depth — including OpenAPI tag groups in the API
* sidebar (users → Users, audit logs → Audit Logs) — render in title
* case. (`::first-letter` doesn't apply to the menu link's flex layout,
* so we use text-transform: capitalize which capitalizes the first
* letter of every word.)
*
* Font: Archivo Black's 900-weight display face reads fine for a short,
* single word/acronym at large size (e.g. an "IME" page title), but at
* this small size (0.82rem) its heavy strokes and tight kerning crowd
* multi-word labels together — use the body sans face at bold instead. */
.menu .menu__list-item-collapsible > .menu__link,
.menu > .menu__list > .menu__list-item > .menu__link {
font-family: var(--mm-font-sans);
font-weight: 700;
letter-spacing: 0.02em;
text-transform: capitalize;
font-size: 0.82rem;
color: var(--mm-text-secondary);
padding-top: 0.55rem;
padding-bottom: 0.4rem;
}
/* Category headers hardcode --mm-text-secondary above, so they don't
* pick up the .navbar-sidebar menu-color override — fix separately. */
.navbar-sidebar .menu .menu__list-item-collapsible > .menu__link,
.navbar-sidebar .menu > .menu__list > .menu__list-item > .menu__link {
color: var(--ifm-menu-color);
}
/* Endpoint leaves keep their natural case (e.g. "Login to Mattermost server"). */
.menu .menu__link.api-method {
font-family: var(--mm-font-sans);
font-weight: 500;
letter-spacing: 0;
text-transform: none;
font-size: 0.85rem;
color: var(--mm-text-primary);
}
/* === Code === */
/* `:not(pre) > code`, not bare `code`: a code block's own `<code>` (inside
* `<pre>`) is also just a `code` element, and this rule's specificity
* ([data-theme='dark'] + tag) beats Infima's `pre code { background:
* transparent }` (two tags only). Left unscoped, this inline-code
* background painted over almost the entire visible area of every
* dark-mode code block. */
:not(pre) > code {
background-color: var(--mm-denim-50);
color: var(--mm-text-primary);
border: 1px solid var(--mm-border-subtle);
border-radius: 3px;
padding: 0.1em 0.35em;
font-size: 0.88em;
/* Infima's `code` selector has no line-height of its own, so an inline
* code span inherits the paragraph's 1.65 --ifm-line-height-base,
* inflating the whole line's height wherever one shows up mid-sentence.
* Multi-line fences are unaffected — `pre code` has higher specificity
* and `.theme-code-block .prism-code` sets its own line-height. */
line-height: 1.2;
}
/* Same dark-on-dark collision as blockquote/callout/code-block: denim-800
* (#0C1424) and denim-700 (#121E37) are both within ~1:1 contrast of the
* page background (#0E1525), so inline code like `ClusterEncryptionKey`
* had no visible chip around it in dark mode. --mm-bg-surface + denim-400
* match the fill/border already used for code blocks elsewhere in this
* file, for a consistent "raised surface" look across all code UI. */
[data-theme='dark'] :not(pre) > code {
background-color: var(--mm-bg-surface);
border-color: var(--mm-denim-400);
}
/* === Admonitions / callouts ===
* Dark-mode callout text/label/icon were nearly the same color as the
* background across all five admonition types (note/tip/info/warning/
* danger) — Infima's default alert coloring was never wired to our brand
* tokens. Pin explicit, contrast-checked colors instead. Left border/
* accent-bar colors (--ifm-alert-border-color) are intentionally left
* untouched — a parallel "remove yellow left border" workstream reworks
* those on the same selectors.
*
* Heading label and icon are set explicitly (not just via the shared
* --ifm-alert-foreground-color below) since the label was the most
* visibly low-contrast part, in case a theme upgrade changes the
* inheritance. */
[data-theme='dark'] .alert {
--ifm-alert-foreground-color: var(--mm-callout-text);
}
[data-theme='dark'] .alert__heading,
[data-theme='dark'] .alert__icon svg {
color: var(--mm-callout-text);
fill: var(--mm-callout-text);
}
/* denim-700 (the previous note/info background) is ~1.1:1 from the page
* — same navy hue family, no visible surface. success/warning/danger
* don't have this problem since green/amber/red are a different hue
* family from the page. Fix: denim-500 (brand primary, ~4x page
* luminance) as the fill, plus a border (separate from the reserved
* --ifm-alert-border-color/left accent bar) for a crisp edge. */
[data-theme='dark'] .alert--secondary,
[data-theme='dark'] .alert--info {
--ifm-alert-background-color: var(--mm-denim-500);
border-top: 1px solid var(--mm-denim-300);
border-right: 1px solid var(--mm-denim-300);
border-bottom: 1px solid var(--mm-denim-300);
}
[data-theme='dark'] .alert--success {
--ifm-alert-background-color: #14442c;
}
[data-theme='dark'] .alert--warning {
--ifm-alert-background-color: #4a3a10;
}
[data-theme='dark'] .alert--danger {
--ifm-alert-background-color: #4a1f1f;
}
.theme-code-block {
border: 1px solid var(--mm-border-subtle);
border-radius: 4px;
box-shadow: none !important;
}
/* --mm-denim-700 (#121E37) is only ~1.1:1 contrast from the page
* background (#0E1525) — same dark-navy hue family, no visible edge.
* denim-400 is a real jump in lightness, giving the block a border that
* actually reads as a boundary. */
[data-theme='dark'] .theme-code-block {
border-color: var(--mm-denim-400);
}
/* Light mode uses the "github" Prism theme (near-white bg) — --mm-denim-50
* keeps it in the brand palette without breaking github's token contrast.
*
* Dark mode uses "dracula" (docusaurus.config.ts), whose token colors are
* tuned against its own #282a36 background — don't hardcode a different
* background here, or token colors go muddy/low-contrast. But dracula's own
* #282a36 is only ~1.3:1 from our page bg (#0E1525), i.e. visually the same,
* so it still needs lightening. #52607A (~2.9:1) is that lighter fill;
* denim-400 below gives it a real border. Needed to go further than the
* contrast ratio alone suggests — low-luminance colors require a bigger
* jump than the formula implies to look distinct to the eye. */
.theme-code-block .prism-code {
font-size: 0.85rem;
background: var(--mm-denim-50) !important;
}
[data-theme='dark'] .theme-code-block .prism-code {
background: #52607A !important;
}
/* Lightening the dark-mode code block fill above (#52607A) leaves
* dracula's comment token — already its dimmest/de-emphasized color
* (#6272a4) — at only ~1.35:1 contrast against it, i.e. nearly invisible.
* #D3DBF2 restores ~4.6:1 (WCAG AA) while staying a muted blue-lavender,
* distinguishable from the brighter default token colors but still legible.
* !important is required: prism-react-renderer sets each token's color via
* an inline `style` attribute, which otherwise beats this class selector. */
[data-theme='dark'] .theme-code-block .prism-code .token.comment {
color: #D3DBF2 !important;
}
/* Copy button — denim border, marigold on hover */
.clean-btn[class*="copyButton"] {
border: 1px solid var(--mm-border-subtle) !important;
border-radius: 3px !important;
}
.clean-btn[class*="copyButton"]:hover {
background: var(--mm-color-marigold) !important;
color: var(--mm-color-black) !important;
border-color: var(--mm-color-marigold) !important;
}
/* === Tables === */
table {
display: table;
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
}
table thead {
background: var(--mm-color-denim);
}
/* Same problem as the TOC category labels above — multi-word headers
* like "Feature category" at 0.78rem crowd together in Archivo Black.
* Same fix: base sans face at bold weight. */
table thead th {
color: var(--mm-color-white);
font-family: var(--mm-font-sans);
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
font-size: 0.78rem;
padding: 0.85rem 1rem;
text-align: left;
border: none;
}
table tbody td {
border-color: var(--mm-border-subtle);
padding: 0.7rem 1rem;
}
table tbody tr:nth-child(even) {
background: var(--mm-denim-50);
}
/* Same collision as blockquote/callout/code — denim-800 zebra striping
* was indistinguishable from the page background in dark mode. */
[data-theme='dark'] table tbody tr:nth-child(even) {
background: var(--mm-bg-surface);
}
/* === Blockquote ===
* Neutral denim border + subtle tint — marigold is reserved for CTAs and
* interactive accents (see tokens.css), not an unconditional bar on every
* quoted paragraph. */
blockquote {
border-left: 3px solid var(--mm-border-strong);
background: var(--mm-bg-subtle);
padding: 1rem 1.25rem;
margin: 1.5rem 0;
border-radius: 0 4px 4px 0;
color: var(--mm-text-primary);
}
/* --mm-bg-subtle in dark mode is denim-800 (#0C1424), effectively the same
* as the page background — same collision already fixed for callouts and
* code blocks. The marigold left border already reads fine; only the
* fill needed the surface-level bump. */
[data-theme='dark'] blockquote {
background: var(--mm-bg-surface);
}
/* === Content images ===
* Screenshots/diagrams are committed at capture resolution (up to 1024px,
* some portrait) with no cap beyond Infima's 100%-of-column default, so
* they rendered at full source size. Cap both dimensions and let the
* browser scale proportionally against whichever binds first.
*
* `:not([class])` scopes this to authored MDX images, leaving
* component-rendered images (always CSS-module-classed) alone. An author
* can opt out with an explicit `width` or `height` attribute. */
.markdown img:not([class]):not([width]):not([height]) {
display: block;
width: auto;
height: auto;
max-width: min(100%, 720px);
max-height: 640px;
margin-inline: auto;
}
/* === Inline UI icons (product menu, checkmarks, keyboard glyphs, etc.) ===
* `<img className="theme-icon">` is used throughout the docs to inline small
* UI icons (e.g. the product-switcher waffle icon) next to text. Nothing
* previously styled this class, so icons rendered at their raw SVG size
* (baseline-aligned, often 24px) and — since they're flat single-color
* PNGs/SVGs baked black with no `fill` attribute an <img> could recolor —
* were nearly invisible against a dark background in dark mode. */
.theme-icon {
width: 1.1em;
height: 1.1em;
vertical-align: text-bottom;
margin: 0 0.05em;
}
[data-theme='dark'] .theme-icon {
filter: invert(1);
}
/* A few icons carry their own brand color (green checkmark, colored
* presence dots/badges) rather than being flat black glyphs — inverting
* those would shift their hue instead of fixing contrast, so exempt them. */
[data-theme='dark'] .theme-icon[src*="checkmark.svg"],
[data-theme='dark'] .theme-icon[src*="Ack-Button"],
[data-theme='dark'] .theme-icon[src$=".png"] {
filter: none;
}
.footer {
background-color: var(--ifm-footer-background-color);
background-image:
linear-gradient(0deg, rgba(12, 20, 36, 0.55), rgba(12, 20, 36, 0.55)),
url('/img/patterns/hex-camo-denim.svg');
background-size: cover, 108px 125px;
background-repeat: no-repeat, repeat;
color: var(--ifm-footer-color);
padding: 3.5rem 1rem 2.5rem;
}
/* Footer column titles ("Docs", "Community", "More") stay on the heading
* face: short, single words are the case Archivo Black handles fine —
* it's small multi-word labels (TOC categories, table headers above)
* that crowd together at this weight. */
.footer__title {
color: var(--ifm-footer-title-color);
font-family: var(--mm-font-heading);
letter-spacing: 0.08em;
text-transform: uppercase;
font-size: 0.78rem;
margin-bottom: 1rem;
}
.footer__copyright {
margin-top: 2rem;
padding-top: 1.25rem;
border-top: 1px solid rgba(255,255,255,0.18);
font-size: 0.82rem;
color: rgba(255,255,255,0.7);
}
/* === Doc page chrome === */
/* Docusaurus's default `.theme-doc-markdown` is fluid; we don't constrain
* it. The OpenAPI plugin's 2-column layout (request panel on the right)
* needs the full width of the article column. */
.theme-doc-markdown {
font-size: 1rem;
}
.theme-doc-markdown h1:first-child {
margin-top: 0.5rem;
font-size: 2.4rem;
letter-spacing: -0.025em;
}
/* Widen the docs main container so the sidebar isn't pushed to the edge
* with a huge gap before the content. Default Infima container is too
* conservative for API pages with right panels. */
.docMainContainer_TBSr,
[class*='docMainContainer'] {
max-width: none !important;
/* Sits in an outer flex row alongside the left sidebar; without
* min-width: 0 its default `min-width: auto` lets wide content (long
* code block lines, etc.) force it — and the whole row — wider than
* the viewport instead of scrolling within its own content. */
min-width: 0;
}
.docMainContainer_TBSr > .container,
[class*='docMainContainer'] > .container {
max-width: 1600px;
padding-left: clamp(1rem, 2vw, 2rem);
padding-right: clamp(1rem, 2vw, 2rem);
}
/* theme-classic's DocItem/Layout only caps the article column at 75% via
* its own `docItemCol` class when a TOC renders alongside it — pages with
* `hide_table_of_contents: true` (e.g. the homepage) get a lone full-width
* `.col` instead, so prose reads much wider than on every other doc page.
* Recreate the same 75% cap here, keyed off the `<article>` that's always
* a descendant of the doc content column, regardless of TOC presence. */
@media (min-width: 997px) {
.docMainContainer_TBSr .col:has(> div > article),
[class*='docMainContainer'] .col:has(> div > article) {
max-width: 75%;
}
}
/* Without max-width, the doc content flex column no longer has an upper
* bound to shrink against, so a wide code block (long lines, e.g. inline
* YAML comments) inflates its own intrinsic min-content size and drags the
* whole row — including the TOC column — wider than the viewport, instead
* of just scrolling horizontally within the code block itself. `min-width:
* 0` restores the standard flexbox shrink behavior so `overflow-x: auto` on
* code blocks can do its job. */
.docMainContainer_TBSr > .container > .row > [class*='col'],
[class*='docMainContainer'] > .container > .row > [class*='col'] {
min-width: 0;
}
/* TOC right column (per-page) — same side-panel treatment as the left
* sidebar, see comment above `.theme-doc-sidebar-container`. */
.table-of-contents {
background-color: var(--mm-bg-subtle);
border-left: 1px solid var(--mm-border-subtle);
padding: 0.4rem 0.75rem 0.4rem 1rem;
font-size: 0.82rem;
}
/* --mm-color-denim is a fixed brand navy, not a semantic/theme-aware
* token — it never adapts for dark mode. Against the dark page background
* that's only ~1.45:1 contrast, i.e. the active TOC item nearly vanishes.
* --ifm-color-primary is already the theme-aware equivalent (denim in
* light mode, --mm-color-sky in dark, ~12:1 against the dark page). */
.table-of-contents__link--active,
.table-of-contents__link:hover {
color: var(--ifm-color-primary);
font-weight: 600;
}
/* === Marigold CTA pill (used in MDX content) === */
.mm-cta {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: var(--mm-color-marigold);
color: var(--mm-color-black);
font-family: var(--mm-font-heading);
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
border-radius: 3px;
text-decoration: none;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.mm-cta:hover {
transform: translateY(-1px);
box-shadow: 0 8px 16px -8px rgba(0, 0, 0, 0.25);
color: var(--mm-color-black);
text-decoration: none;
}
/* Tables: keep native table layout so header and body columns stay aligned.
* Wide tables (e.g. upgrade notes) use a dedicated wrapper class instead. */
.markdown table {
display: table;
width: 100%;
table-layout: auto;
border-collapse: collapse;
}
.upgrade-notes-table-wrapper {
width: 100%;
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.markdown table.upgrade-notes-table {
display: table;
width: 100%;
min-width: 0;
table-layout: fixed;
}
.markdown table.upgrade-notes-table thead {
display: table-header-group;
}
.markdown table.upgrade-notes-table tbody {
display: table-row-group;
}
.markdown table.upgrade-notes-table thead th {
text-transform: none;
letter-spacing: normal;
font-family: var(--mm-font-sans);
font-weight: 700;
font-size: 0.85rem;
line-height: 1.35;
padding: 0.5rem 0.65rem;
}
.markdown table.upgrade-notes-table th,
.markdown table.upgrade-notes-table td {
vertical-align: top;
padding: 0.5rem 0.65rem;
min-width: 0;
overflow-wrap: break-word;
word-break: break-word;
}
.markdown table.upgrade-notes-table th:first-child,
.markdown table.upgrade-notes-table td:first-child {
width: 25%;
}
.markdown table.upgrade-notes-table th:last-child,
.markdown table.upgrade-notes-table td:last-child {
width: 75%;
max-width: 0;
}
.markdown table.upgrade-notes-table td:first-child:not(:last-child) {
white-space: nowrap;
}
.markdown table.upgrade-notes-table tbody tr:nth-child(even) {
background: transparent;
}
.markdown table.upgrade-notes-table tbody td {
border-bottom: 1px solid var(--mm-border-subtle);
}
.markdown table.upgrade-notes-table pre,
.markdown table.upgrade-notes-table .sourceCode {
max-width: 100%;
margin: 0.5rem 0 0;
overflow-x: auto;
}
.markdown table.upgrade-notes-table pre,
.markdown table.upgrade-notes-table pre code,
.markdown table.upgrade-notes-table code {
white-space: pre-wrap;
word-break: break-word;
}
/* === Mobile / responsive === */
@media (max-width: 996px) {
/* Tighter heading scale on small screens. */
.markdown h1 { font-size: 1.85rem; line-height: 1.15; }
.markdown h2 { font-size: 1.4rem; }
.markdown h3 { font-size: 1.15rem; }
}
@media (max-width: 768px) {
/* Navbar: tighter logo + items. */
.navbar__inner { padding: 0 0.75rem; }
.navbar__logo { height: 1.5rem; }
.navbar__link { padding: 0 0.5rem; font-size: 0.72rem; }
/* OpenAPI right-panel (Authorization, Request, Response) — stacks
* below the content on phones instead of squeezing in a 2nd column. */
.openapi-right-panel__container {
width: 100% !important;
margin-top: 1.5rem;
}
.openapi-left-panel__container {
width: 100% !important;
}
/* Footer: looser stacking, less padding. */
.footer { padding: 2.5rem 1rem 1.75rem; }
/* Code blocks: smaller font for phones so lines don't break too eagerly. */
.theme-code-block .prism-code { font-size: 0.78rem; }
}
@media (max-width: 480px) {
/* Hide the marigold accent on the navbar at very narrow widths
* where the gradient distracts from the compact nav. */
.navbar::after { display: none; }
/* Hero: even tighter on small phones. */
.markdown h1 { font-size: 1.6rem; }
}
/* === Section divider used between Hero and content === */
.mm-section-rule {
height: 0;
border: none;
border-top: 2px solid var(--mm-color-marigold);
width: 3rem;
margin: 2.5rem 0 1.5rem;
}
/* === Release policy Gantt chart (scoped — no global mermaid overrides) === */
.release-policy-gantt {
margin: 1.25rem 0;
/* On narrow viewports, scroll horizontally instead of shrinking the
* chart below a legible size. */
overflow-x: auto;
}
.release-policy-gantt .docusaurus-mermaid-container {
margin: 0;
background: var(--mm-color-white);
border: 2px solid #0745a1;
border-radius: 4px;
padding: 0.35rem 0.5rem 0.25rem;
}
[data-theme='dark'] .release-policy-gantt .docusaurus-mermaid-container {
background: #1e1e1e;
border-color: #555555;
}
/* Hide mermaid's semi-transparent gantt row backgrounds (pink tint in light mode) */
.release-policy-gantt .docusaurus-mermaid-container svg rect.section,
.release-policy-gantt .docusaurus-mermaid-container svg rect.grid-panel {
opacity: 0 !important;
}
.release-policy-gantt .docusaurus-mermaid-container svg {
background: transparent;
}
.release-policy-gantt .docusaurus-mermaid-container svg line {
stroke: #0745a1;
}
.release-policy-gantt .docusaurus-mermaid-container svg rect.task {
fill: #0984e3 !important;
stroke: #2d3436 !important;
stroke-width: 2px !important;
}
.release-policy-gantt .docusaurus-mermaid-container svg rect[class*='crit'] {
fill: #d63031 !important;
stroke: #2d3436 !important;
stroke-width: 2px !important;
}
.release-policy-gantt .docusaurus-mermaid-container .taskText {
fill: #ffffff !important;
font-weight: 700 !important;
font-size: 22px !important;
}
.release-policy-gantt .docusaurus-mermaid-container .taskTextOutsideLeft,
.release-policy-gantt .docusaurus-mermaid-container .taskTextOutsideRight {
fill: #2d3436 !important;
font-weight: 700 !important;
font-size: 22px !important;
}
.release-policy-gantt .docusaurus-mermaid-container .sectionTitle,
.release-policy-gantt .docusaurus-mermaid-container text[class*='tick'],
.release-policy-gantt .docusaurus-mermaid-container text[class*='label'] {
fill: #2d3436 !important;
font-weight: 600 !important;
font-size: 22px !important;
}
[data-theme='dark'] .release-policy-gantt .docusaurus-mermaid-container svg rect.task {
stroke: #ffffff !important;
}
[data-theme='dark'] .release-policy-gantt .docusaurus-mermaid-container svg rect[class*='crit'] {
stroke: #ffffff !important;
}
[data-theme='dark'] .release-policy-gantt .docusaurus-mermaid-container text {
fill: #ffffff !important;
}
.release-policy-gantt .docusaurus-mermaid-container > svg {
display: block;
width: 100%;
height: auto;
}
/* (Marigold strip is now the navbar's own border-bottom.) */
/* === Pages with a Hero hide breadcrumbs + prev/next pagination ===
* Hero is already a strong page-opener; the breadcrumbs ("Home >
* Product Overview > Overview") read as redundant clutter. */
.theme-doc-markdown:has(.mm-hero) ~ nav.pagination-nav,
nav.pagination-nav:has(+ .theme-doc-markdown .mm-hero) {
display: none;
}
article:has(.mm-hero) > .theme-doc-breadcrumbs,
article:has(.mm-hero) > nav[aria-label*="readcrumb"],
.theme-doc-markdown:has(.mm-hero) > div:first-child:has(nav[aria-label*="readcrumb"]),
nav[aria-label*="readcrumb"]:has(~ .theme-doc-markdown .mm-hero) {
display: none;
}
/* Pages with a Hero: drop the article's top-padding so the hero band
* sits flush under the navbar. Hero and body content both fill the
* article column naturally (no width override needed). */
.theme-doc-markdown:has(.mm-hero) {
padding-top: 0;
}

View File

@@ -0,0 +1,99 @@
/*
* Mattermost brand design tokens.
* Source: Mattermost-Brand-Guidelines-2025.
* See PLAN.md §3.2 for the full token spec and rationale.
*
* Accessibility constraint (per brand guidelines): use only black or white
* text on the four primary colors — these combinations are pre-approved
* for contrast.
*/
:root {
/* === Primary palette === */
--mm-color-denim: #1E325C; /* primary brand, dominant */
--mm-color-marigold: #FFBC1F; /* accent — CTAs only, used sparingly */
--mm-color-white: #FFFFFF;
--mm-color-black: #1B1D22;
/* === Secondary palette (sparingly, never overpower) === */
--mm-color-sand: #CCC4AE;
--mm-color-bark: #604F3D;
--mm-color-sky: #C5D2EC;
/* === Denim shade ramp (derived for surfaces, borders, focus rings) === */
--mm-denim-50: #EEF1F8;
--mm-denim-100: #D6DDEC;
--mm-denim-200: #ADBBD8;
--mm-denim-300: #8499C5;
--mm-denim-400: #5B77B1;
--mm-denim-500: #1E325C; /* = denim, brand primary */
--mm-denim-600: #182849;
--mm-denim-700: #121E37;
--mm-denim-800: #0C1424;
--mm-denim-900: #060A12;
/* === Marigold shade ramp === */
--mm-marigold-100: #FFEFC8;
--mm-marigold-300: #FFD672;
--mm-marigold-500: #FFBC1F; /* = marigold, brand accent */
--mm-marigold-700: #B27F00;
/* === Typography ===
* Trade Gothic Next is licensed via Adobe Fonts and not embedded yet
* (open item — see PLAN.md §12). Until licensing is confirmed, body uses
* Inter and headlines use Archivo Black, which is the closest free match
* for Trade Gothic Heavy's geometric, ultra-bold display feel. */
--mm-font-sans: "Inter", "Trade Gothic Next", -apple-system, BlinkMacSystemFont,
"Segoe UI", "Helvetica Neue", system-ui, sans-serif;
--mm-font-heading: "Archivo Black", "Trade Gothic Next Heavy",
"Helvetica Neue", "Impact", system-ui, sans-serif;
--mm-font-mono: "JetBrains Mono", "SF Mono", "Menlo", "Consolas",
"Liberation Mono", monospace;
/* === Semantic surfaces (light) === */
--mm-bg-page: var(--mm-color-white);
--mm-bg-surface: var(--mm-color-white);
--mm-bg-inverse: var(--mm-color-denim);
--mm-bg-subtle: var(--mm-denim-50);
--mm-text-primary: var(--mm-color-black);
--mm-text-secondary: var(--mm-denim-600);
--mm-text-inverse: var(--mm-color-white);
--mm-text-on-accent: var(--mm-color-black); /* black text on marigold */
--mm-border-subtle: var(--mm-denim-100);
--mm-border-strong: var(--mm-denim-300);
/* Not --mm-color-denim (#1E325C): it clears WCAG AA against white
* (12.6:1) but sits only ~1.5:1 from --mm-text-primary, so links and
* body copy read as the same shade of "dark". This blue keeps 6.5:1+
* against the page while staying clearly apart from body text. */
--mm-link: #3D5D96;
--mm-link-hover: var(--mm-denim-700);
--mm-accent: var(--mm-color-marigold);
--mm-focus-ring: var(--mm-marigold-500);
}
/* === Dark mode (data-theme=dark applied by Docusaurus) === */
[data-theme='dark'] {
--mm-bg-page: #0E1525;
--mm-bg-surface: #14213D;
--mm-bg-inverse: var(--mm-color-denim);
--mm-bg-subtle: var(--mm-denim-800);
--mm-text-primary: var(--mm-color-white);
--mm-text-secondary: var(--mm-denim-200);
--mm-text-inverse: var(--mm-color-black);
--mm-text-on-accent: var(--mm-color-black);
--mm-border-subtle: var(--mm-denim-700);
--mm-border-strong: var(--mm-denim-500);
/* Not --mm-color-sky (#C5D2EC): passes AA against the page (~12:1),
* but sits only ~1.5:1 from white body text, so links disappear into
* surrounding copy. This blue keeps ~6.4:1 against the page while
* reading clearly darker than white body text. */
--mm-link: #5B9BF0;
--mm-link-hover: var(--mm-color-white);
--mm-accent: var(--mm-color-marigold);
/* Infima's default secondary-alert text/background pairing isn't wired
* to our palette — pin explicit, contrast-checked brand colors instead.
* Left border/accent-bar colors are deliberately left alone — a
* parallel "remove yellow left border" workstream touches those. */
--mm-callout-text: var(--mm-denim-50);
--mm-callout-note-bg: var(--mm-denim-700);
}

View File

@@ -0,0 +1,61 @@
// Globally-available MDX components. Authors can write <Note>...</Note>
// in any .mdx file without an import.
import MDXComponents from '@theme-original/MDXComponents';
import {Note, Tip, Important, Attention, Warning, Security} from '@site/src/components/Callout';
import PlanBadge from '@site/src/components/PlanBadge';
import PlanAvailability from '@site/src/components/PlanAvailability';
import EditionAvailability from '@site/src/components/EditionAvailability';
import DeploymentAvailability from '@site/src/components/DeploymentAvailability';
import AttestationStatus from '@site/src/components/AttestationStatus';
import DeploymentOnly from '@site/src/components/DeploymentOnly';
import IMEDiagram from '@site/src/components/IMEDiagram';
import DeploymentArchitectureBuilder from '@site/src/components/DeploymentArchitectureBuilder';
import CompassIcon from '@site/src/components/CompassIcon';
import Hero from '@site/src/components/Hero';
import Eyebrow from '@site/src/components/Eyebrow';
import StatStrip from '@site/src/components/StatStrip';
import MethodLegend from '@site/src/components/MethodLegend';
import CardGrid from '@site/src/components/CardGrid';
import UpgradeNotesFilter from '@site/src/components/UpgradeNotesFilter';
import SecurityUpdates from '@site/src/components/SecurityUpdates';
import PluginGoDocs from '@site/src/components/PluginGoDocs';
import PluginGoExample from '@site/src/components/PluginGoExample';
import PluginJsDocs from '@site/src/components/PluginJsDocs';
import PluginManifestDocs from '@site/src/components/PluginManifestDocs';
// Globally available so migrated developer docs (Hugo `tabs` shortcode)
// can use them without imports.
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
export default {
...MDXComponents,
Note,
Tip,
Important,
Attention,
Warning,
Security,
PlanBadge,
PlanAvailability,
EditionAvailability,
DeploymentAvailability,
AttestationStatus,
DeploymentOnly,
IMEDiagram,
DeploymentArchitectureBuilder,
CompassIcon,
Hero,
Eyebrow,
StatStrip,
MethodLegend,
CardGrid,
UpgradeNotesFilter,
SecurityUpdates,
PluginGoDocs,
PluginGoExample,
PluginJsDocs,
PluginManifestDocs,
Tabs,
TabItem,
};

View File

@@ -0,0 +1,623 @@
/* eslint-disable jsx-a11y/no-autofocus */
// Ejected from @docusaurus/theme-search-algolia@3.10.1 (`SearchPage`).
//
// Upstream never forwards `themeConfig.algolia.searchParameters` to this
// component — it destructures only {appId, apiKey, indexName,
// contextualSearch} and builds the query helper from a hardcoded parameter
// set. The DocSearch modal does honour `searchParameters`, so the two search
// surfaces returned differently ordered results for the same query. There is
// no config option to fix this, and Algolia Rules aren't available on our
// plan, so swizzling is the only route.
//
// The ONLY delta from upstream is the three edits marked "Mattermost delta"
// below: `searchParameters` added to the `useAlgoliaThemeConfig()`
// destructure, `...searchParameters` spread last into the
// `algoliaSearchHelper` options, and the `PlainSearchParameters` type import
// the spread's cast needs. Everything else is verbatim upstream, including
// the swizzle-stripped MIT license header. Filter values and scores are
// deliberately not duplicated here — docusaurus.config.ts stays the single
// source of truth so the modal and this page cannot drift apart again.
//
// On a Docusaurus upgrade, re-eject from the new version and re-apply those
// three edits rather than diffing this whole file:
// npm run swizzle @docusaurus/theme-search-algolia SearchPage -- \
// --eject --danger --typescript
// (the theme is only registered when ALGOLIA_APP_ID and
// ALGOLIA_SEARCH_API_KEY are set, so export placeholders first). Drop this
// file entirely once upstream passes `searchParameters` through.
import React, {
type ReactNode,
useEffect,
useReducer,
useRef,
useState,
} from 'react';
import clsx from 'clsx';
import algoliaSearchHelper, {
type PlainSearchParameters,
} from 'algoliasearch-helper';
import {liteClient} from 'algoliasearch/lite';
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
import Head from '@docusaurus/Head';
import Link from '@docusaurus/Link';
import {useAllDocsData} from '@docusaurus/plugin-content-docs/client';
import {
HtmlClassNameProvider,
PageMetadata,
useEvent,
usePluralForm,
useSearchQueryString,
} from '@docusaurus/theme-common';
import Translate, {translate} from '@docusaurus/Translate';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import {
useAlgoliaThemeConfig,
useSearchResultUrlProcessor,
} from '@docusaurus/theme-search-algolia/client';
import Layout from '@theme/Layout';
import Heading from '@theme/Heading';
import styles from './styles.module.css';
// Very simple pluralization: probably good enough for now
function useDocumentsFoundPlural() {
const {selectMessage} = usePluralForm();
return (count: number) =>
selectMessage(
count,
translate(
{
id: 'theme.SearchPage.documentsFound.plurals',
description:
'Pluralized label for "{count} documents found". Use as much plural forms (separated by "|") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)',
message: 'One document found|{count} documents found',
},
{count},
),
);
}
function useDocsSearchVersionsHelpers() {
const allDocsData = useAllDocsData();
// State of the version select menus / algolia facet filters
// docsPluginId -> versionName map
const [searchVersions, setSearchVersions] = useState<{
[pluginId: string]: string;
}>(() =>
Object.entries(allDocsData).reduce(
(acc, [pluginId, pluginData]) => ({
...acc,
[pluginId]: pluginData.versions[0]!.name,
}),
{},
),
);
// Set the value of a single select menu
const setSearchVersion = (pluginId: string, searchVersion: string) =>
setSearchVersions((s) => ({...s, [pluginId]: searchVersion}));
const versioningEnabled = Object.values(allDocsData).some(
(docsData) => docsData.versions.length > 1,
);
return {
allDocsData,
versioningEnabled,
searchVersions,
setSearchVersion,
};
}
// We want to display one select per versioned docs plugin instance
function SearchVersionSelectList({
docsSearchVersionsHelpers,
}: {
docsSearchVersionsHelpers: ReturnType<typeof useDocsSearchVersionsHelpers>;
}) {
const versionedPluginEntries = Object.entries(
docsSearchVersionsHelpers.allDocsData,
)
// Do not show a version select for unversioned docs plugin instances
.filter(([, docsData]) => docsData.versions.length > 1);
return (
<div
className={clsx(
'col',
'col--3',
'padding-left--none',
styles.searchVersionColumn,
)}>
{versionedPluginEntries.map(([pluginId, docsData]) => {
const labelPrefix =
versionedPluginEntries.length > 1 ? `${pluginId}: ` : '';
return (
<select
key={pluginId}
onChange={(e) =>
docsSearchVersionsHelpers.setSearchVersion(
pluginId,
e.target.value,
)
}
defaultValue={docsSearchVersionsHelpers.searchVersions[pluginId]}
className={styles.searchVersionInput}>
{docsData.versions.map((version, i) => (
<option
key={i}
label={`${labelPrefix}${version.label}`}
value={version.name}
/>
))}
</select>
);
})}
</div>
);
}
function AlgoliaLogo(): ReactNode {
return (
<svg
width="80"
height="24"
aria-label="Algolia"
role="img"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 2196.2 500"
style={{maxWidth: '150px'}}>
<defs>
{/* eslint-disable-next-line @docusaurus/no-untranslated-text */}
<style>{`.cls-1,.cls-2{fill:#003dff}.cls-2{fill-rule:evenodd}`}</style>
</defs>
<path
className="cls-2"
d="M1070.38,275.3V5.91c0-3.63-3.24-6.39-6.82-5.83l-50.46,7.94c-2.87,.45-4.99,2.93-4.99,5.84l.17,273.22c0,12.92,0,92.7,95.97,95.49,3.33,.1,6.09-2.58,6.09-5.91v-40.78c0-2.96-2.19-5.51-5.12-5.84-34.85-4.01-34.85-47.57-34.85-54.72Z"
/>
<rect
className="cls-1"
x="1845.88"
y="104.73"
width="62.58"
height="277.9"
rx="5.9"
ry="5.9"
/>
<path
className="cls-2"
d="M1851.78,71.38h50.77c3.26,0,5.9-2.64,5.9-5.9V5.9c0-3.62-3.24-6.39-6.82-5.83l-50.77,7.95c-2.87,.45-4.99,2.92-4.99,5.83v51.62c0,3.26,2.64,5.9,5.9,5.9Z"
/>
<path
className="cls-2"
d="M1764.03,275.3V5.91c0-3.63-3.24-6.39-6.82-5.83l-50.46,7.94c-2.87,.45-4.99,2.93-4.99,5.84l.17,273.22c0,12.92,0,92.7,95.97,95.49,3.33,.1,6.09-2.58,6.09-5.91v-40.78c0-2.96-2.19-5.51-5.12-5.84-34.85-4.01-34.85-47.57-34.85-54.72Z"
/>
<path
className="cls-2"
d="M1631.95,142.72c-11.14-12.25-24.83-21.65-40.78-28.31-15.92-6.53-33.26-9.85-52.07-9.85-18.78,0-36.15,3.17-51.92,9.85-15.59,6.66-29.29,16.05-40.76,28.31-11.47,12.23-20.38,26.87-26.76,44.03-6.38,17.17-9.24,37.37-9.24,58.36,0,20.99,3.19,36.87,9.55,54.21,6.38,17.32,15.14,32.11,26.45,44.36,11.29,12.23,24.83,21.62,40.6,28.46,15.77,6.83,40.12,10.33,52.4,10.48,12.25,0,36.78-3.82,52.7-10.48,15.92-6.68,29.46-16.23,40.78-28.46,11.29-12.25,20.05-27.04,26.25-44.36,6.22-17.34,9.24-33.22,9.24-54.21,0-20.99-3.34-41.19-10.03-58.36-6.38-17.17-15.14-31.8-26.43-44.03Zm-44.43,163.75c-11.47,15.75-27.56,23.7-48.09,23.7-20.55,0-36.63-7.8-48.1-23.7-11.47-15.75-17.21-34.01-17.21-61.2,0-26.89,5.59-49.14,17.06-64.87,11.45-15.75,27.54-23.52,48.07-23.52,20.55,0,36.63,7.78,48.09,23.52,11.47,15.57,17.36,37.98,17.36,64.87,0,27.19-5.72,45.3-17.19,61.2Z"
/>
<path
className="cls-2"
d="M894.42,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-14.52,22.58-22.99,49.63-22.99,78.73,0,44.89,20.13,84.92,51.59,111.1,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47,1.23,0,2.46-.03,3.68-.09,.36-.02,.71-.05,1.07-.07,.87-.05,1.75-.11,2.62-.2,.34-.03,.68-.08,1.02-.12,.91-.1,1.82-.21,2.73-.34,.21-.03,.42-.07,.63-.1,32.89-5.07,61.56-30.82,70.9-62.81v57.83c0,3.26,2.64,5.9,5.9,5.9h50.42c3.26,0,5.9-2.64,5.9-5.9V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,206.92c-12.2,10.16-27.97,13.98-44.84,15.12-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-42.24,0-77.12-35.89-77.12-79.37,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33v142.83Z"
/>
<path
className="cls-2"
d="M2133.97,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-14.52,22.58-22.99,49.63-22.99,78.73,0,44.89,20.13,84.92,51.59,111.1,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47,1.23,0,2.46-.03,3.68-.09,.36-.02,.71-.05,1.07-.07,.87-.05,1.75-.11,2.62-.2,.34-.03,.68-.08,1.02-.12,.91-.1,1.82-.21,2.73-.34,.21-.03,.42-.07,.63-.1,32.89-5.07,61.56-30.82,70.9-62.81v57.83c0,3.26,2.64,5.9,5.9,5.9h50.42c3.26,0,5.9-2.64,5.9-5.9V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,206.92c-12.2,10.16-27.97,13.98-44.84,15.12-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-42.24,0-77.12-35.89-77.12-79.37,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33v142.83Z"
/>
<path
className="cls-2"
d="M1314.05,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-11.79,18.34-19.6,39.64-22.11,62.59-.58,5.3-.88,10.68-.88,16.14s.31,11.15,.93,16.59c4.28,38.09,23.14,71.61,50.66,94.52,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47h0c17.99,0,34.61-5.93,48.16-15.97,16.29-11.58,28.88-28.54,34.48-47.75v50.26h-.11v11.08c0,21.84-5.71,38.27-17.34,49.36-11.61,11.08-31.04,16.63-58.25,16.63-11.12,0-28.79-.59-46.6-2.41-2.83-.29-5.46,1.5-6.27,4.22l-12.78,43.11c-1.02,3.46,1.27,7.02,4.83,7.53,21.52,3.08,42.52,4.68,54.65,4.68,48.91,0,85.16-10.75,108.89-32.21,21.48-19.41,33.15-48.89,35.2-88.52V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,64.1s.65,139.13,0,143.36c-12.08,9.77-27.11,13.59-43.49,14.7-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-1.32,0-2.63-.03-3.94-.1-40.41-2.11-74.52-37.26-74.52-79.38,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33Z"
/>
<path
className="cls-1"
d="M249.83,0C113.3,0,2,110.09,.03,246.16c-2,138.19,110.12,252.7,248.33,253.5,42.68,.25,83.79-10.19,120.3-30.03,3.56-1.93,4.11-6.83,1.08-9.51l-23.38-20.72c-4.75-4.21-11.51-5.4-17.36-2.92-25.48,10.84-53.17,16.38-81.71,16.03-111.68-1.37-201.91-94.29-200.13-205.96,1.76-110.26,92-199.41,202.67-199.41h202.69V407.41l-115-102.18c-3.72-3.31-9.42-2.66-12.42,1.31-18.46,24.44-48.53,39.64-81.93,37.34-46.33-3.2-83.87-40.5-87.34-86.81-4.15-55.24,39.63-101.52,94-101.52,49.18,0,89.68,37.85,93.91,85.95,.38,4.28,2.31,8.27,5.52,11.12l29.95,26.55c3.4,3.01,8.79,1.17,9.63-3.3,2.16-11.55,2.92-23.58,2.07-35.92-4.82-70.34-61.8-126.93-132.17-131.26-80.68-4.97-148.13,58.14-150.27,137.25-2.09,77.1,61.08,143.56,138.19,145.26,32.19,.71,62.03-9.41,86.14-26.95l150.26,133.2c6.44,5.71,16.61,1.14,16.61-7.47V9.48C499.66,4.25,495.42,0,490.18,0H249.83Z"
/>
</svg>
);
}
type ResultDispatcherState = {
items: {
title: string;
url: string;
summary: string;
breadcrumbs: string[];
}[];
query: string | null;
totalResults: number | null;
totalPages: number | null;
lastPage: number | null;
hasMore: boolean | null;
loading: boolean | null;
};
type ResultDispatcher =
| {type: 'reset'; value?: undefined}
| {type: 'loading'; value?: undefined}
| {type: 'update'; value: ResultDispatcherState}
| {type: 'advance'; value?: undefined};
function getSearchPageTitle(searchQuery: string | undefined): string {
return searchQuery
? translate(
{
id: 'theme.SearchPage.existingResultsTitle',
message: 'Search results for "{query}"',
description: 'The search page title for non-empty query',
},
{
query: searchQuery,
},
)
: translate({
id: 'theme.SearchPage.emptyResultsTitle',
message: 'Search the documentation',
description: 'The search page title for empty query',
});
}
function SearchPageContent(): ReactNode {
const {
i18n: {currentLocale},
} = useDocusaurusContext();
const {
// Mattermost delta: `searchParameters` added (upstream omits it).
algolia: {appId, apiKey, indexName, contextualSearch, searchParameters},
} = useAlgoliaThemeConfig();
const processSearchResultUrl = useSearchResultUrlProcessor();
const documentsFoundPlural = useDocumentsFoundPlural();
const docsSearchVersionsHelpers = useDocsSearchVersionsHelpers();
const [searchQuery, setSearchQuery] = useSearchQueryString();
const pageTitle = getSearchPageTitle(searchQuery);
const initialSearchResultState: ResultDispatcherState = {
items: [],
query: null,
totalResults: null,
totalPages: null,
lastPage: null,
hasMore: null,
loading: null,
};
const [searchResultState, searchResultStateDispatcher] = useReducer(
(prevState: ResultDispatcherState, data: ResultDispatcher) => {
switch (data.type) {
case 'reset': {
return initialSearchResultState;
}
case 'loading': {
return {...prevState, loading: true};
}
case 'update': {
if (searchQuery !== data.value.query) {
return prevState;
}
return {
...data.value,
items:
data.value.lastPage === 0
? data.value.items
: prevState.items.concat(data.value.items),
};
}
case 'advance': {
const hasMore = prevState.totalPages! > prevState.lastPage! + 1;
return {
...prevState,
lastPage: hasMore ? prevState.lastPage! + 1 : prevState.lastPage,
hasMore,
};
}
default:
return prevState;
}
},
initialSearchResultState,
);
// respect settings from the theme config for facets
const disjunctiveFacets = contextualSearch
? ['language', 'docusaurus_tag']
: [];
const algoliaClient = liteClient(appId, apiKey);
const algoliaHelper = algoliaSearchHelper(algoliaClient, indexName, {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: why errors happens after upgrading to TS 5.5 ?
hitsPerPage: 15,
advancedSyntax: true,
disjunctiveFacets,
// Mattermost delta: spread last so theme config wins on conflicts. The
// cast bridges DocSearch's `SearchOptions` (where `optionalFilters` also
// accepts a bare string) and the helper's narrower array-only type.
...(searchParameters as PlainSearchParameters),
});
algoliaHelper.on(
'result',
({results: {query, hits, page, nbHits, nbPages}}) => {
if (query === '' || !Array.isArray(hits)) {
searchResultStateDispatcher({type: 'reset'});
return;
}
const sanitizeValue = (value: string) =>
value.replace(
/algolia-docsearch-suggestion--highlight/g,
'search-result-match',
);
const items = hits.map(
({
url,
_highlightResult: {hierarchy},
_snippetResult: snippet = {},
}: {
url: string;
_highlightResult: {hierarchy: {[key: string]: {value: string}}};
_snippetResult: {content?: {value: string}};
}) => {
const titles = Object.keys(hierarchy).map((key) =>
sanitizeValue(hierarchy[key]!.value),
);
return {
title: titles.pop()!,
url: processSearchResultUrl(url),
summary: snippet.content
? `${sanitizeValue(snippet.content.value)}...`
: '',
breadcrumbs: titles,
};
},
);
searchResultStateDispatcher({
type: 'update',
value: {
items,
query,
totalResults: nbHits,
totalPages: nbPages,
lastPage: page,
hasMore: nbPages > page + 1,
loading: false,
},
});
},
);
const [loaderRef, setLoaderRef] = useState<HTMLDivElement | null>(null);
const prevY = useRef(0);
const observer = useRef(
ExecutionEnvironment.canUseIntersectionObserver &&
new IntersectionObserver(
// TODO need to fix this React Compiler lint error
// eslint-disable-next-line react-compiler/react-compiler
(entries) => {
const {
isIntersecting,
boundingClientRect: {y: currentY},
} = entries[0]!;
if (isIntersecting && prevY.current > currentY) {
searchResultStateDispatcher({type: 'advance'});
}
prevY.current = currentY;
},
{threshold: 1},
),
);
const makeSearch = useEvent((page: number = 0) => {
if (contextualSearch) {
algoliaHelper.addDisjunctiveFacetRefinement('docusaurus_tag', 'default');
algoliaHelper.addDisjunctiveFacetRefinement('language', currentLocale);
Object.entries(docsSearchVersionsHelpers.searchVersions).forEach(
([pluginId, searchVersion]) => {
algoliaHelper.addDisjunctiveFacetRefinement(
'docusaurus_tag',
`docs-${pluginId}-${searchVersion}`,
);
},
);
}
algoliaHelper.setQuery(searchQuery).setPage(page).search();
});
useEffect(() => {
if (!loaderRef) {
return undefined;
}
const currentObserver = observer.current;
if (currentObserver) {
currentObserver.observe(loaderRef);
return () => currentObserver.unobserve(loaderRef);
}
return () => true;
}, [loaderRef]);
useEffect(() => {
searchResultStateDispatcher({type: 'reset'});
if (searchQuery) {
searchResultStateDispatcher({type: 'loading'});
setTimeout(() => {
makeSearch();
}, 300);
}
}, [searchQuery, docsSearchVersionsHelpers.searchVersions, makeSearch]);
useEffect(() => {
if (!searchResultState.lastPage || searchResultState.lastPage === 0) {
return;
}
makeSearch(searchResultState.lastPage);
}, [makeSearch, searchResultState.lastPage]);
return (
<Layout>
<PageMetadata title={pageTitle} />
<Head>
{/*
We should not index search pages
See https://github.com/facebook/docusaurus/pull/3233
*/}
<meta property="robots" content="noindex, follow" />
</Head>
<div className="container margin-vert--lg">
<Heading as="h1">{pageTitle}</Heading>
<form className="row" onSubmit={(e) => e.preventDefault()}>
<div
className={clsx('col', styles.searchQueryColumn, {
'col--9': docsSearchVersionsHelpers.versioningEnabled,
'col--12': !docsSearchVersionsHelpers.versioningEnabled,
})}>
<input
type="search"
name="q"
className={styles.searchQueryInput}
placeholder={translate({
id: 'theme.SearchPage.inputPlaceholder',
message: 'Type your search here',
description: 'The placeholder for search page input',
})}
aria-label={translate({
id: 'theme.SearchPage.inputLabel',
message: 'Search',
description: 'The ARIA label for search page input',
})}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
autoComplete="off"
autoFocus
/>
</div>
{contextualSearch && docsSearchVersionsHelpers.versioningEnabled && (
<SearchVersionSelectList
docsSearchVersionsHelpers={docsSearchVersionsHelpers}
/>
)}
</form>
<div className="row">
<div className={clsx('col', 'col--8', styles.searchResultsColumn)}>
{!!searchResultState.totalResults &&
documentsFoundPlural(searchResultState.totalResults)}
</div>
<div className={clsx('col', 'col--4', styles.searchLogoColumn)}>
<span>
{translate({
id: 'theme.SearchPage.algoliaLabel',
message: 'Powered by',
description:
'The text explain that the search powered by Algolia',
})}
</span>
<Link
to="https://www.algolia.com/"
aria-label={translate({
id: 'theme.SearchPage.algoliaLabel',
message: 'Powered by Algolia',
description: 'The description label for Algolia mention',
})}>
<AlgoliaLogo />
</Link>
</div>
</div>
{searchResultState.items.length > 0 ? (
<main>
{searchResultState.items.map(
({title, url, summary, breadcrumbs}, i) => (
<article key={i} className={styles.searchResultItem}>
<Heading as="h2" className={styles.searchResultItemHeading}>
<Link to={url} dangerouslySetInnerHTML={{__html: title}} />
</Heading>
{breadcrumbs.length > 0 && (
<nav aria-label="breadcrumbs">
<ul
className={clsx(
'breadcrumbs',
styles.searchResultItemPath,
)}>
{breadcrumbs.map((html, index) => (
<li
key={index}
className="breadcrumbs__item"
// Developer provided the HTML, so assume it's safe.
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{__html: html}}
/>
))}
</ul>
</nav>
)}
{summary && (
<p
className={styles.searchResultItemSummary}
// Developer provided the HTML, so assume it's safe.
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{__html: summary}}
/>
)}
</article>
),
)}
</main>
) : (
[
searchQuery && !searchResultState.loading && (
<p key="no-results">
<Translate
id="theme.SearchPage.noResultsText"
description="The paragraph for empty search result">
No results were found
</Translate>
</p>
),
!!searchResultState.loading && (
<div key="spinner" className={styles.loadingSpinner} />
),
]
)}
{searchResultState.hasMore && (
<div className={styles.loader} ref={setLoaderRef}>
<Translate
id="theme.SearchPage.fetchingNewResults"
description="The paragraph for fetching new search results">
Fetching new results...
</Translate>
</div>
)}
</div>
</Layout>
);
}
export default function SearchPage(): ReactNode {
return (
<HtmlClassNameProvider className="search-page-wrapper">
<SearchPageContent />
</HtmlClassNameProvider>
);
}

View File

@@ -0,0 +1,120 @@
.searchQueryInput,
.searchVersionInput {
border-radius: var(--ifm-global-radius);
border: 2px solid var(--ifm-toc-border-color);
font: var(--ifm-font-size-base) var(--ifm-font-family-base);
padding: 0.8rem;
width: 100%;
background: var(--docsearch-searchbox-focus-background);
color: var(--docsearch-text-color);
margin-bottom: 0.5rem;
transition: border var(--ifm-transition-fast) ease;
}
.searchQueryInput:focus,
.searchVersionInput:focus {
border-color: var(--docsearch-primary-color);
outline: none;
}
.searchQueryInput::placeholder {
color: var(--docsearch-muted-color);
}
.searchResultsColumn {
font-size: 0.9rem;
font-weight: bold;
}
.searchLogoColumn {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: flex-end;
}
.searchLogoColumn a {
display: flex;
}
.searchLogoColumn span {
color: var(--docsearch-muted-color);
font-weight: normal;
}
.searchResultItem {
padding: 1rem 0;
border-bottom: 1px solid var(--ifm-toc-border-color);
}
.searchResultItemHeading {
font-weight: 400;
margin-bottom: 0;
}
.searchResultItemPath {
font-size: 0.8rem;
color: var(--ifm-color-content-secondary);
--ifm-breadcrumb-separator-size-multiplier: 1;
}
.searchResultItemSummary {
margin: 0.5rem 0 0;
font-style: italic;
}
@media only screen and (max-width: 996px) {
.searchQueryColumn {
max-width: 60% !important;
}
.searchVersionColumn {
max-width: 40% !important;
}
.searchResultsColumn {
max-width: 60% !important;
}
.searchLogoColumn {
max-width: 40% !important;
padding-left: 0 !important;
}
}
@media screen and (max-width: 576px) {
.searchQueryColumn {
max-width: 100% !important;
}
.searchVersionColumn {
max-width: 100% !important;
padding-left: var(--ifm-spacing-horizontal) !important;
}
}
.loadingSpinner {
width: 3rem;
height: 3rem;
border: 0.4em solid #eee;
border-top-color: var(--ifm-color-primary);
border-radius: 50%;
animation: loading-spin 1s linear infinite;
margin: 0 auto;
}
@keyframes loading-spin {
100% {
transform: rotate(360deg);
}
}
.loader {
margin-top: 2rem;
}
:global(.search-result-match) {
color: var(--docsearch-hit-color);
background: rgb(255 215 142 / 25%);
padding: 0.09em 0;
}