diff --git a/apps/docs/astro.config.ts b/apps/docs/astro.config.ts index 3027788..d118ade 100644 --- a/apps/docs/astro.config.ts +++ b/apps/docs/astro.config.ts @@ -29,7 +29,14 @@ export default defineConfig({ description: 'Run, self-host and observe a Lumyx SFU: quickstart, deployment, the six media-path metrics and the REST API.', social: [{ icon: 'github', label: 'GitHub', href: REPO }], - sidebar: sidebarGroups().map((group) => ({ label: group.label, items: group.items })), + // Les squelettes portent le badge « Soon » de la maquette Docs v3. + sidebar: sidebarGroups().map((group) => ({ + label: group.label, + items: group.items.map((slug) => + stubs.includes(slug) ? { slug, badge: { text: 'Soon', class: 'lumyx-soon' } } : slug, + ), + })), + editLink: { baseUrl: 'https://github.com/lumyx-dev/website/edit/main/apps/docs/' }, customCss: ['./src/styles/global.css'], expressiveCode: false, routeMiddleware: './src/route-middleware.ts', @@ -38,6 +45,9 @@ export default defineConfig({ ThemeSelect: './src/components/starlight/ThemeSelect.astro', PageTitle: './src/components/starlight/PageTitle.astro', SiteTitle: './src/components/starlight/SiteTitle.astro', + SocialIcons: './src/components/starlight/SocialIcons.astro', + PageSidebar: './src/components/starlight/PageSidebar.astro', + Footer: './src/components/starlight/Footer.astro', }, }), // Voir apps/landing/astro.config.ts : les enfants passes depuis le MDX deviennent des diff --git a/apps/docs/package.json b/apps/docs/package.json index c317f89..e4b637f 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -14,6 +14,7 @@ "verify:ds": "node scripts/verify-ds.mjs" }, "dependencies": { + "@astrojs/markdown-satteri": "0.4.6", "@astrojs/react": "^7.1.2", "@astrojs/sitemap": "^3.7.4", "@astrojs/starlight": "^0.42.7", @@ -25,6 +26,7 @@ "@tailwindcss/vite": "^4.3.3", "astro": "^7.4.4", "react": "19.2.8", + "satteri": "0.10.5", "react-dom": "19.2.8", "tailwindcss": "^4.3.3" }, diff --git a/apps/docs/src/components/code-panel.astro b/apps/docs/src/components/code-panel.astro new file mode 100644 index 0000000..b786d27 --- /dev/null +++ b/apps/docs/src/components/code-panel.astro @@ -0,0 +1,25 @@ +--- +// Panneau de code de la maquette Docs v3 : toujours sombre, nom de fichier, bouton Copy. +import { CopyButton } from './copy-button'; +import type { Token } from '@/lib/json-tokens'; + +interface Props { + label: string; + lines: Token[][]; + class?: string; +} +const { label, lines, class: className } = Astro.props; +const text = lines.map((l) => l.map((t) => t.v).join('')).join('\n'); +--- + +
+
+ {label} + +
+
{
+    lines.map((line) => (
+      
{line.map((t) => {t.v})}
+ )) + }
+
diff --git a/apps/docs/src/components/copy-button.tsx b/apps/docs/src/components/copy-button.tsx new file mode 100644 index 0000000..9b120ae --- /dev/null +++ b/apps/docs/src/components/copy-button.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; + +/** Bouton « Copy » des panneaux de code. Ilot React : seul morceau interactif de la page. */ +export function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = React.useState(false); + const timer = React.useRef>(undefined); + React.useEffect(() => () => clearTimeout(timer.current), []); + + return ( + + ); +} diff --git a/apps/docs/src/components/metric-detail.astro b/apps/docs/src/components/metric-detail.astro new file mode 100644 index 0000000..3f3b8d2 --- /dev/null +++ b/apps/docs/src/components/metric-detail.astro @@ -0,0 +1,36 @@ +--- +// Le corps d'UNE metrique (maquette Docs v3) : texte et encarts a gauche, exemple JSON a droite. +// Le titre reste un vrai `##` dans le MDX, avec son ancre epinglee (`## Jitter [#jitter_ms]`) : +// c'est lui qui alimente le sommaire. Ce composant ne rend donc ni titre ni `id`. +import { METRICS } from '@/lib/metrics-data'; +import { tokenizeJson } from '@/lib/json-tokens'; +import CodePanel from './code-panel.astro'; + +interface Props { + field: string; +} +const m = METRICS.find((metric) => metric.field === Astro.props.field); +if (!m) throw new Error(`MetricDetail: metrique inconnue "${Astro.props.field}"`); +--- + +
+
+ {m.field} · {m.unit} +

{m.body}

+
+
Default
+
{m.threshold}
+
Scope
+
{m.scope}
+
Breaks
+
{m.breaks}
+
+
+ +

What to do. {m.action}

+
+
+ +
diff --git a/apps/docs/src/components/metrics-reference.tsx b/apps/docs/src/components/metrics-reference.tsx index 84a9351..2952ae0 100644 --- a/apps/docs/src/components/metrics-reference.tsx +++ b/apps/docs/src/components/metrics-reference.tsx @@ -1,104 +1,28 @@ -import { - Card, - CardContent, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@lumyx/ui'; import { METRICS } from '@/lib/metrics-data'; /** - * Tableau recapitulatif des six seuils par defaut, repris de l'ancienne page. Le brief de la - * tache 5 ne le mentionnait pas ; sans lui, la vue d'ensemble (six metriques sur une grille de - * six colonnes) disparaissait de la doc migree. + * « At a glance » (maquette Docs v3) : une carte par metrique, qui renvoie a sa section. + * Seuil par defaut en pastille, ce qu'elle casse, et le nom du champ. */ -export function MetricsThresholds() { +export function MetricsGlance() { return ( - - - - - Metric - Field - Unit - Threshold - Scope - What it breaks - - - - {METRICS.map((m) => ( - - {m.name} - {m.field} - {m.unit} - {/* `sl-num` : c'est la colonne dont le travail est la comparaison verticale de - nombres (> 2% / > 200ms / < 100kbps). Sans chiffres tabulaires, ils ne - s'alignent pas. */} - {m.threshold} - {m.scope} - {m.breaks} - - ))} - -
-
- ); -} - -/** - * Le corps d'UNE metrique : sept champs se tiennent mieux dans un objet que dans des - * paragraphes paralleles. - * - * Ce composant ne rend deliberement ni titre ni `id`. Le titre est un vrai `##` dans le MDX, - * avec son ancre epinglee (`## Packet loss [#packet_loss_ratio]`), pour deux raisons : Fumadocs - * derive la table des matieres des titres du document au moment de la compilation, donc un titre - * rendu a l'interieur d'un composant React n'y figure jamais ; et l'ancre explicite conserve - * `#packet_loss_ratio` la ou rehype-slug aurait produit `#packet-loss`. Poser ici un `id` en plus - * le dupliquerait. - */ -export function MetricDetail({ field }: { field: string }) { - const m = METRICS.find((metric) => metric.field === field); - if (!m) throw new Error(`MetricDetail: metrique inconnue "${field}"`); - - return ( -
- {m.field} - -

{m.body}

- - - -
- {[ - ['Unit', m.unit], - ['Default threshold', m.threshold], - ['Scope', m.scope], - ].map(([label, value]) => ( -
-
{label}
-
{value}
-
- ))} -
-
-
- -
- {m.sample.map((line) => ( - - {line} +
+ {METRICS.map((m) => ( + + + {m.name} + + {m.threshold} + - ))} -
- -

- What breaks: {m.breaks} -

-

{m.action}

+ {m.breaks} + {m.field} +
+ ))}
); } diff --git a/apps/docs/src/components/starlight/Footer.astro b/apps/docs/src/components/starlight/Footer.astro new file mode 100644 index 0000000..129ba1f --- /dev/null +++ b/apps/docs/src/components/starlight/Footer.astro @@ -0,0 +1,9 @@ +--- +// Pied de page (maquette Docs v3) : seulement la page precedente et la suivante. Le lien +// d'edition est monte dans l'en-tete de page (PageTitle.astro). +import Pagination from '@astrojs/starlight/components/Pagination.astro'; +--- + +
+ +
diff --git a/apps/docs/src/components/starlight/PageSidebar.astro b/apps/docs/src/components/starlight/PageSidebar.astro new file mode 100644 index 0000000..7637e91 --- /dev/null +++ b/apps/docs/src/components/starlight/PageSidebar.astro @@ -0,0 +1,22 @@ +--- +// Le sommaire de Starlight, suivi de l'encart « New to Lumyx? » de la maquette Docs v3. +// L'encart vit dans la colonne de droite : il n'apparait que la ou elle existe (page avec +// sommaire, ecran large), et pas sur le quickstart lui-meme. +import Default from '@astrojs/starlight/components/PageSidebar.astro'; + +const { toc, id } = Astro.locals.starlightRoute; +const showNewcomer = Boolean(toc) && id !== 'quickstart'; +--- + + +{ + showNewcomer && ( + + ) +} diff --git a/apps/docs/src/components/starlight/PageTitle.astro b/apps/docs/src/components/starlight/PageTitle.astro index db6eee3..d72ba0f 100644 --- a/apps/docs/src/components/starlight/PageTitle.astro +++ b/apps/docs/src/components/starlight/PageTitle.astro @@ -1,15 +1,41 @@ --- -// Remplace le PageTitle de Starlight : fil d'Ariane (nom de la section), titre au palier 44 et -// chapo au palier 16, comme sous Fumadocs. L'id `_top` est celui que Starlight attend. -const { entry, sidebar } = Astro.locals.starlightRoute; +// Remplace le PageTitle de Starlight (maquette Docs v3) : fil d'Ariane Docs › section › page, +// titre en degrade, chapo, puis la version et le lien d'edition. L'id `_top` est celui que +// Starlight attend. +import { Icon } from '@astrojs/starlight/components'; +import { VERSION } from '@/lib/site-data'; + +const { entry, sidebar, editUrl } = Astro.locals.starlightRoute; type SidebarEntry = (typeof sidebar)[number]; function hasCurrent(e: SidebarEntry): boolean { return e.type === 'link' ? e.isCurrent : e.entries.some(hasCurrent); } const section = sidebar.find((e) => e.type === 'group' && hasCurrent(e))?.label; +const crumbs = ['Docs', section].filter(Boolean) as string[]; --- -{section &&

{section}

} +

{entry.data.title}

{entry.data.description &&

{entry.data.description}

} +

+ Lumyx {VERSION} + { + editUrl && ( + <> + + Edit on GitHub + + ) + } +

diff --git a/apps/docs/src/components/starlight/SiteTitle.astro b/apps/docs/src/components/starlight/SiteTitle.astro index 372100e..3783d44 100644 --- a/apps/docs/src/components/starlight/SiteTitle.astro +++ b/apps/docs/src/components/starlight/SiteTitle.astro @@ -1,5 +1,5 @@ --- -// Le wordmark Lumyx, qui renvoie vers lumyx.dev comme sous Fumadocs. +// Le wordmark Lumyx, qui renvoie vers lumyx.dev, suivi de « Docs » en retrait (maquette Docs v3). import { Wordmark } from '@lumyx/ui'; import { SITE_URL } from '@/lib/env'; --- @@ -7,6 +7,7 @@ import { SITE_URL } from '@/lib/env'; + diff --git a/apps/docs/src/components/starlight/SocialIcons.astro b/apps/docs/src/components/starlight/SocialIcons.astro new file mode 100644 index 0000000..aeb9c33 --- /dev/null +++ b/apps/docs/src/components/starlight/SocialIcons.astro @@ -0,0 +1,38 @@ +--- +// Remplace les icones sociales de Starlight par la barre de liens de la maquette Docs v3 : +// retour au site, GitHub, version en pastille. Le bouton de theme suit, rendu par Starlight. +import { SITE_URL } from '@/lib/env'; +import { REPO, VERSION } from '@/lib/site-data'; +--- + + + + diff --git a/apps/docs/src/content/docs/metrics-reference.mdx b/apps/docs/src/content/docs/metrics-reference.mdx index 5a6ebf9..155ffbd 100644 --- a/apps/docs/src/content/docs/metrics-reference.mdx +++ b/apps/docs/src/content/docs/metrics-reference.mdx @@ -1,16 +1,26 @@ --- title: Metrics reference -description: Six metrics, read from RTCP as the media passes through the SFU. Each one is armed by default with the threshold below and a 30s debounce, per peer and — where the scope says so — per room. +description: Six numbers, measured in the media path for every peer and sampled every 500 ms. Each has a default threshold you can change per environment. --- -import { MetricDetail, MetricsThresholds } from '@/components/metrics-reference'; +import { Aside } from '@astrojs/starlight/components'; +import { MetricsGlance } from '@/components/metrics-reference'; +import MetricDetail from '@/components/metric-detail.astro'; +import CodePanel from '@/components/code-panel.astro'; +import { tokenizeToml } from '@/lib/json-tokens'; {/* Les ancres sont epinglees en `[#champ]` plutot que laissees a rehype-slug, qui aurait renomme `#packet_loss_ratio` en `#packet-loss` et casse les liens profonds existants. */} -## Default thresholds + - +## At a glance [#overview] + + ## Packet loss [#packet_loss_ratio] @@ -36,14 +46,29 @@ import { MetricDetail, MetricsThresholds } from '@/components/metrics-reference' -## Overriding a threshold - -Thresholds live per project and per environment since v0.4.0. Set one and it applies to every room in that -environment; alerts still debounce for 30s before firing. - -```http -PATCH /v1/projects/live-classroom/thresholds - -{ "packet_loss_ratio": 0.03, - "rtt_ms": 250 } -``` +## Change a threshold [#overrides] + +
+
+

+ Thresholds will live in lumyx.toml and be + read at startup. Keep a looser file for staging: a room on a bad office link should not look like an outage. +

+

A metric counts as crossed only after it stays past the threshold for the full debounce window.

+

Draft format. Today the SFU reads environment variables.

+
+ +
diff --git a/apps/docs/src/lib/heading-ids.test.ts b/apps/docs/src/lib/heading-ids.test.ts new file mode 100644 index 0000000..e40fcf8 --- /dev/null +++ b/apps/docs/src/lib/heading-ids.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test"; +import { splitPinnedId } from "./heading-ids"; + +describe("splitPinnedId", () => { + test("separe le titre et l'ancre", () => { + expect(splitPinnedId("Jitter [#jitter_ms]")).toEqual({ text: "Jitter", id: "jitter_ms" }); + }); + + test("ignore un titre ordinaire", () => { + expect(splitPinnedId("At a glance")).toBeNull(); + expect(splitPinnedId("Arrays [like this] in a title")).toBeNull(); + }); +}); diff --git a/apps/docs/src/lib/heading-ids.ts b/apps/docs/src/lib/heading-ids.ts new file mode 100644 index 0000000..a7427f1 --- /dev/null +++ b/apps/docs/src/lib/heading-ids.ts @@ -0,0 +1,32 @@ +import type { MdastPluginDefinition } from 'satteri'; + +/** + * Ancres epinglees : `## Jitter [#jitter_ms]` rend `

Jitter

`. + * La syntaxe vient de Fumadocs ; Starlight ne la connait pas et laissait `[#jitter_ms]` dans le + * titre et le sommaire, avec une ancre `#jitter-jitter_ms`. Le plugin retire le suffixe et pose + * l'`id` ; le plugin d'ancres d'Astro garde un `id` existant, donc le sommaire suit. + * + * Plugin Satteri (le moteur Markdown par defaut d'Astro 7), pas remark : `markdown.remarkPlugins` + * n'est lu que par l'ancien moteur unified. + */ +const PINNED = /\s*\[#([\w-]+)\]\s*$/; + +/** `"Jitter [#jitter_ms]"` → `{ text: "Jitter", id: "jitter_ms" }` ; `null` sans ancre epinglee. */ +export function splitPinnedId(value: string): { text: string; id: string } | null { + const m = PINNED.exec(value); + return m ? { text: value.slice(0, m.index), id: m[1]! } : null; +} + +export const pinnedHeadingIds: MdastPluginDefinition = { + name: 'lumyx-pinned-heading-ids', + heading(node, ctx) { + const last = node.children.at(-1); + if (last?.type !== 'text') return; + const pinned = splitPinnedId(last.value); + if (!pinned) return; + ctx.setProperty(last, 'value', pinned.text); + // `hProperties` est lu au passage en hast mais absent du type `HeadingData`. + const data = node.data as { hProperties?: Record } | undefined; + ctx.setProperty(node, 'data', { ...data, hProperties: { ...data?.hProperties, id: pinned.id } }); + }, +}; diff --git a/apps/docs/src/lib/json-tokens.ts b/apps/docs/src/lib/json-tokens.ts new file mode 100644 index 0000000..3a9bb88 --- /dev/null +++ b/apps/docs/src/lib/json-tokens.ts @@ -0,0 +1,33 @@ +/** + * Coloration minimale des exemples JSON de la page Metrics reference (maquette Docs v3). + * Rend des classes de tokens, pas des couleurs : les panneaux de code sont toujours sombres + * (classe `.dark`), et les tokens y prennent leur valeur sombre. + */ +export type Token = { v: string; cls: string }; + +const CLS = { key: "text-accent-text", str: "text-accent2", num: "text-ok", dim: "text-faint", txt: "text-body" }; +const RE = /("(?:[^"\\]|\\.)*")(\s*:)?|\b(\d+(?:\.\d+)?)\b|([{}[\],:])/g; + +export function tokenizeJson(line: string): Token[] { + const out: Token[] = []; + let i = 0; + let m: RegExpExecArray | null; + RE.lastIndex = 0; + while ((m = RE.exec(line))) { + if (m.index > i) out.push({ v: line.slice(i, m.index), cls: CLS.txt }); + if (m[1]) { + out.push({ v: m[1], cls: m[2] ? CLS.key : CLS.str }); + if (m[2]) out.push({ v: m[2], cls: CLS.dim }); + } else if (m[3]) out.push({ v: m[3], cls: CLS.num }); + else out.push({ v: m[4]!, cls: CLS.dim }); + i = RE.lastIndex; + } + if (i < line.length) out.push({ v: line.slice(i), cls: CLS.txt }); + return out.length ? out : [{ v: " ", cls: CLS.txt }]; +} + +/** TOML : commentaires estompes, sections en accent, le reste en texte. */ +export function tokenizeToml(line: string): Token[] { + const cls = line.startsWith("#") ? CLS.dim : line.startsWith("[") ? CLS.key : CLS.txt; + return [{ v: line || " ", cls }]; +} diff --git a/apps/docs/src/lib/markdown.ts b/apps/docs/src/lib/markdown.ts index 17b241c..7d56515 100644 --- a/apps/docs/src/lib/markdown.ts +++ b/apps/docs/src/lib/markdown.ts @@ -1,4 +1,6 @@ import { lumyxCodeTheme } from '@lumyx/ui/code-theme'; +import { satteri } from '@astrojs/markdown-satteri'; +import { pinnedHeadingIds } from './heading-ids'; /** * Coloration du code. Expressive Code (le defaut de Starlight) est coupe : il calcule ses propres @@ -8,4 +10,5 @@ import { lumyxCodeTheme } from '@lumyx/ui/code-theme'; */ export const markdown = { shikiConfig: { theme: lumyxCodeTheme }, + processor: satteri({ mdastPlugins: [pinnedHeadingIds] }), }; diff --git a/apps/docs/src/lib/metrics-data.test.ts b/apps/docs/src/lib/metrics-data.test.ts index 5d94bca..a3a8c73 100644 --- a/apps/docs/src/lib/metrics-data.test.ts +++ b/apps/docs/src/lib/metrics-data.test.ts @@ -2,7 +2,8 @@ import { describe, expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; import { createElement } from "react"; import { METRICS } from "./metrics-data"; -import { MetricsThresholds } from "../components/metrics-reference"; +import { MetricsGlance } from "../components/metrics-reference"; +import { tokenizeJson } from "./json-tokens"; describe("donnees des metriques", () => { test("porte les six metriques du collecteur", () => { @@ -33,32 +34,39 @@ describe("donnees des metriques", () => { }); }); -describe("MetricsThresholds", () => { - // Le tableau recapitulatif est la seule vue d'ensemble des six metriques : une ligne perdue - // ne casse rien et ne se voit pas. On compte donc les cellules et on verifie que chaque valeur - // des donnees arrive bien dans le rendu. - const html = renderToStaticMarkup(createElement(MetricsThresholds)); +describe("MetricsGlance", () => { + // La grille « At a glance » est la seule vue d'ensemble des six metriques : une carte perdue + // ne casse rien et ne se voit pas. On compte les cartes et on verifie leurs valeurs. + const html = renderToStaticMarkup(createElement(MetricsGlance)); - test("rend les six lignes sur les six colonnes", () => { - expect(html.match(/ { + expect(html.match(/ { + test("chaque carte porte le nom, le seuil, ce qu'elle casse et le champ", () => { for (const m of METRICS) { - for (const value of [m.name, m.field, m.unit, m.threshold, m.scope, m.breaks]) { + for (const value of [m.name, m.threshold, m.breaks, m.field]) { expect(html).toContain(escapeHtml(value)); } } }); - test("la colonne des seuils garde les chiffres tabulaires", () => { - // `sl-num` est ce qui aligne verticalement `> 2%`, `> 200ms` et `< 100kbps`. + test("les seuils gardent les chiffres tabulaires", () => { expect(html.match(/class="[^"]*\bsl-num\b[^"]*"/g) ?? []).toHaveLength(METRICS.length); }); }); +describe("tokenizeJson", () => { + test("restitue chaque ligne d'exemple a l'identique", () => { + for (const m of METRICS) { + for (const line of m.sample) { + expect(tokenizeJson(line).map((t) => t.v).join("")).toBe(line); + } + } + }); +}); + function escapeHtml(value: string) { return value .replaceAll("&", "&") diff --git a/apps/docs/src/lib/metrics-data.ts b/apps/docs/src/lib/metrics-data.ts index 7fa5872..89457d6 100644 --- a/apps/docs/src/lib/metrics-data.ts +++ b/apps/docs/src/lib/metrics-data.ts @@ -1,3 +1,7 @@ +/** + * Les six metriques du collecteur RTCP, tel que la maquette Docs v3 les presente. Le collecteur + * est planifie (README de sfu-core) : la page le dit en tete. + */ export type Metric = { name: string; field: string; @@ -6,6 +10,7 @@ export type Metric = { scope: string; breaks: string; body: string; + /** Exemple JSON complet, une entree par ligne, rendu dans le panneau `peer.json`. */ sample: string[]; action: string; }; @@ -15,42 +20,42 @@ export const METRICS: Metric[] = [ name: "Packet loss", field: "packet_loss_ratio", unit: "ratio, shown as %", threshold: "> 2%", scope: "peer, room", breaks: "Choppy audio, frozen frames", body: "Fraction of RTP packets the receiver reported missing over the interval, read from RTCP receiver reports. Above 2% sustained, audio artefacts become audible before video visibly breaks.", - sample: ['"packet_loss_ratio": 0.079,', '"packets_lost": 412,', '"packets_expected": 5215'], - action: "Response: force audio-only for that peer, or drop it to a lower simulcast layer to reduce what has to survive the link.", + sample: ['{', ' "peer": "ff104b2c",', ' "packet_loss_ratio": 0.079,', ' "packets_lost": 412,', ' "threshold": 0.02', '}'], + action: "Force audio-only for that peer, or drop it to a lower simulcast layer to reduce what has to survive the link.", }, { name: "Round-trip time", field: "rtt_ms", unit: "milliseconds", threshold: "> 200ms", scope: "peer", breaks: "Participants talking over each other", body: "Round trip between the SFU and the peer, from RTCP sender/receiver report timestamps. Conversation stops feeling natural somewhere past 200ms one-way perceived latency.", - sample: ['"rtt_ms": 284,', '"rtt_p95_ms": 331,', '"ice_candidate_type": "relay"'], - action: "Response: check whether the peer fell back to a TURN relay, and whether a closer region exists for that user population.", + sample: ['{', ' "peer": "ff104b2c",', ' "rtt_ms": 284,', ' "rtt_p95_ms": 331,', ' "ice_candidate_type": "relay"', '}'], + action: "Check whether the peer fell back to a TURN relay, and whether a closer region exists for that user population.", }, { name: "Jitter", field: "jitter_ms", unit: "milliseconds", threshold: "> 30ms", scope: "peer", breaks: "Growing jitter buffer, drifting latency", body: "Variance in packet arrival timing. The receiver absorbs it by growing its jitter buffer, which trades latency for smoothness — so high jitter shows up as delay creeping upward rather than as loss.", - sample: ['"jitter_ms": 42,', '"jitter_buffer_delay_ms": 180'], - action: "Response: usually a network path problem rather than a bandwidth problem. Compare with loss before touching bitrate.", + sample: ['{', ' "peer": "ff104b2c",', ' "jitter_ms": 42,', ' "jitter_buffer_delay_ms": 180', '}'], + action: "Usually a network path problem rather than a bandwidth problem. Compare with loss before touching bitrate.", }, { name: "NACK ratio", field: "nack_ratio", unit: "ratio, shown as %", threshold: "> 5%", scope: "peer", breaks: "Retransmission storms, saturated uplink", body: "Share of packets that had to be requested again. A rising NACK ratio is the earliest signal that a peer uplink is saturated — it moves before loss and before freeze ratio.", - sample: ['"nack_ratio": 0.114,', '"nack_count": 1284,', '"retransmit_bytes": 482113'], - action: "Response: reduce the target bitrate for that publisher, or turn off the highest simulcast layer for the room.", + sample: ['{', ' "peer": "ff104b2c",', ' "nack_ratio": 0.114,', ' "nack_count": 1284', '}'], + action: "Reduce the target bitrate for that publisher, or turn off the highest simulcast layer for the room.", }, { name: "Freeze ratio", field: "freeze_ratio", unit: "ratio, shown as %", threshold: "> 1%", scope: "peer", breaks: "Video the user calls broken", body: "Share of the interval during which the decoder produced no new frame. This is the metric closest to what a participant actually complains about, which is why its threshold is the tightest.", - sample: ['"freeze_ratio": 0.041,', '"freeze_count": 6,', '"total_freeze_duration_ms": 2460'], - action: "Response: if freeze is high while loss is low, look at the sender — encoder starvation and CPU pressure produce exactly this shape.", + sample: ['{', ' "peer": "ff104b2c",', ' "freeze_ratio": 0.18,', ' "freeze_count": 3,', ' "total_freeze_ms": 2400', '}'], + action: "If freeze is high while loss is low, look at the sender — encoder starvation and CPU pressure produce exactly this shape.", }, { name: "Bitrate", field: "bitrate_kbps", unit: "kilobits per second", threshold: "< 100kbps", scope: "peer, track, room", breaks: "Encoder giving up entirely", body: "Forwarded bitrate per track. Reported as a floor rather than a ceiling: a video track that collapses under 100kbps has effectively stopped being video.", - sample: ['"bitrate_kbps": 84,', '"target_bitrate_kbps": 1200,', '"codec": "vp8", "layer": "f"'], - action: "Response: check congestion control decisions and whether the publisher is CPU-bound before assuming the network.", + sample: ['{', ' "peer": "ff104b2c",', ' "track": "video",', ' "bitrate_kbps": 84,', ' "layer": "q"', '}'], + action: "Check congestion control decisions and whether the publisher is CPU-bound before assuming the network.", }, ]; diff --git a/apps/docs/src/styles/global.css b/apps/docs/src/styles/global.css index d5280d5..67fb767 100644 --- a/apps/docs/src/styles/global.css +++ b/apps/docs/src/styles/global.css @@ -53,9 +53,10 @@ --sl-color-text: var(--text-body); --sl-color-text-accent: var(--accent-text); --sl-color-text-invert: var(--text-on-accent); - --sl-color-bg: var(--surface-page); - --sl-color-bg-nav: var(--surface-page); - --sl-color-bg-sidebar: var(--surface-page); + /* Fond carte, comme la maquette Docs v3 et la home : blanc en clair. */ + --sl-color-bg: var(--surface-card); + --sl-color-bg-nav: var(--surface-card); + --sl-color-bg-sidebar: var(--surface-card); --sl-color-bg-inline-code: var(--code-bg); --sl-color-bg-accent: var(--accent); --sl-color-hairline-light: var(--border-hairline); @@ -93,8 +94,9 @@ --sl-text-code: var(--text-code); --sl-text-code-sm: var(--text-code); - /* Grille Lumyx : la prose tient 680px. */ - --sl-content-width: 680px; + /* Maquette Docs v3 : la colonne tient 880px pour les sections a deux colonnes ; la prose, + elle, reste a 680px (regle `.sl-markdown-content > p` plus bas). */ + --sl-content-width: 880px; } /* Titres de section de la sidebar : le seul manierisme typographique du systeme, par son @@ -103,31 +105,206 @@ @apply sl-label; } - /* En-tete de page (src/components/starlight/PageTitle.astro). */ + /* En-tete de page (src/components/starlight/PageTitle.astro), maquette Docs v3. */ .lumyx-crumb { - margin: 0 0 0.5rem; - font-size: var(--text-12); - line-height: var(--text-12--line-height); + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin: 0 0 1rem; + font-size: var(--text-14); + line-height: 20px; color: var(--text-faint); } + .lumyx-crumb-sep { + font-size: 12px; + color: var(--border-strong); + } + .lumyx-crumb-current { + color: var(--text-strong); + } .lumyx-title { margin: 0; - font-size: var(--text-44); - line-height: var(--text-44--line-height); + font-size: 36px; + line-height: 1; font-weight: 600; - letter-spacing: -0.02em; - color: var(--text-strong); - text-wrap: pretty; + letter-spacing: -0.04em; + text-wrap: balance; + /* Le degrade de la maquette, porte par les tokens : il suit le theme. */ + background: linear-gradient(90deg, var(--text-strong), var(--text-muted)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + } + @media (min-width: 40rem) { + .lumyx-title { + font-size: 48px; + } } .lumyx-lead { margin: 1rem 0 0; max-width: 680px; - font-size: var(--text-16); - letter-spacing: var(--text-16--letter-spacing); - line-height: 1.625; + font-size: 18px; + line-height: 28px; color: var(--text-muted); text-wrap: pretty; } + .lumyx-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1rem; + margin: 1rem 0 0; + font-size: var(--text-14); + line-height: 20px; + color: var(--text-faint); + } + .lumyx-meta a { + color: var(--text-muted); + text-decoration: none; + } + .lumyx-meta a:hover { + color: var(--text-strong); + } + + /* La prose garde 680px dans la colonne de 880px. */ + .sl-markdown-content > :is(p, ul, ol) { + max-width: 680px; + } + + /* En-tete du site : translucide et floute, comme la home. */ + .page > .header { + background-color: color-mix(in srgb, var(--surface-card) 80%, transparent); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + } + + /* Bouton de recherche (Pagefind) : le champ gris de la maquette, ⌘K compris. */ + site-search button[data-open-modal] { + border: 1px solid var(--border-hairline); + border-radius: var(--radius-xs); + background-color: var(--surface-sunken); + color: var(--text-faint); + font-size: var(--text-14); + } + site-search button[data-open-modal]:hover { + border-color: var(--border-strong); + color: var(--text-muted); + } + + /* Sidebar : liens en pastilles arrondies, page courante en teinte d'accent. */ + .sidebar-content a { + padding: 4px 12px; + border-radius: var(--radius-xs); + font-size: var(--text-14); + color: var(--text-muted); + transition: background-color 500ms cubic-bezier(0.32, 0.72, 0, 1), color 500ms cubic-bezier(0.32, 0.72, 0, 1); + } + .sidebar-content a:hover { + background-color: var(--surface-hover); + color: var(--text-strong); + } + .sidebar-content .lumyx-soon { + border: 0; + background: transparent; + font-size: var(--text-12); + font-weight: 400; + color: var(--text-faint); + } + + /* Sommaire de droite : un filet a gauche, l'entree active marquee en accent. */ + starlight-toc h2, + mobile-starlight-toc .toggle { + @apply sl-label; + } + starlight-toc nav a { + padding: 4px 0 4px calc(12px + var(--depth, 0) * 12px); + font-size: var(--text-14); + line-height: 20px; + color: var(--text-muted); + box-shadow: inset 1px 0 0 var(--border-hairline); + transition: color 500ms cubic-bezier(0.32, 0.72, 0, 1); + } + starlight-toc nav a:hover { + color: var(--text-strong); + } + starlight-toc nav a[aria-current='true'] { + color: var(--accent-text); + box-shadow: inset 2px 0 0 var(--accent); + } + + /* Encart « New to Lumyx? » sous le sommaire (src/components/starlight/PageSidebar.astro). */ + .lumyx-newcomer { + box-sizing: border-box; + width: calc(var(--sl-sidebar-width) - 2 * var(--sl-sidebar-pad-x)); + max-width: calc(100% - 2 * var(--sl-sidebar-pad-x)); + margin: 1.5rem var(--sl-sidebar-pad-x) 0; + padding: 1rem; + border: 1px solid var(--border-hairline); + border-radius: var(--radius-sm); + background: var(--surface-sunken); + } + .lumyx-newcomer-title { + margin: 0; + font-size: var(--text-14); + font-weight: 600; + color: var(--text-strong); + } + .lumyx-newcomer-body { + margin: 0.5rem 0 0; + font-size: var(--text-12); + line-height: 16px; + color: var(--text-muted); + } + .lumyx-newcomer-cta { + display: inline-flex; + margin-top: 0.75rem; + padding: 4px 12px; + border-radius: var(--radius-xs); + background: var(--text-strong); + color: var(--surface-page); + font-size: var(--text-14); + font-weight: 600; + text-decoration: none; + } + .lumyx-newcomer-cta:hover { + background: var(--text-body); + color: var(--surface-page); + } + + /* Encart de note : la carte grise de la maquette plutot qu'un aplat colore. */ + .sl-markdown-content .starlight-aside--note { + border: 1px solid var(--border-hairline); + border-radius: var(--radius-lg); + background: var(--surface-sunken); + color: var(--text-muted); + } + .sl-markdown-content .starlight-aside--note .starlight-aside__title { + color: var(--text-strong); + } + + /* Page precedente / suivante : des cartes, sans ombre. */ + .lumyx-footer { + margin-top: 3rem; + padding-top: 3rem; + border-top: 1px solid var(--border-hairline); + } + .lumyx-footer .pagination-links a { + border: 1px solid var(--border-strong); + border-radius: var(--radius-lg); + box-shadow: none; + color: var(--text-faint); + font-size: var(--text-12); + transition: border-color 600ms cubic-bezier(0.32, 0.72, 0, 1), transform 600ms cubic-bezier(0.32, 0.72, 0, 1); + } + .lumyx-footer .pagination-links a:hover { + border-color: var(--text-faint); + transform: translateY(-2px); + } + .lumyx-footer .pagination-links .link-title { + font-size: var(--text-16); + font-weight: 600; + color: var(--text-strong); + } /* `##` ouvre une section par une hairline : le rythme visuel de l'ancienne doc. */ .sl-markdown-content :is(h2, h3):not(:where(.not-content *)) { @@ -138,15 +315,16 @@ /* Starlight enveloppe chaque titre dans `.sl-heading-wrapper` avec son lien d'ancre, et le

y est `inline` : la hairline se pose sur l'enveloppe, sinon elle ne couvre que le texte. */ .sl-markdown-content .sl-heading-wrapper.level-h2:not(:first-child) { - margin-top: 2rem; - padding-top: 2rem; + margin-top: 3rem; + padding-top: 3rem; border-top: 1px solid var(--border-hairline); } /* Page courante dans la sidebar : la teinte d'accent, pas un aplat plein. */ .sidebar-content a[aria-current='page'] { background-color: var(--accent-tint); - color: var(--text-strong); + color: var(--accent-text); + font-weight: 600; } /* Bloc de code : la bordure du design system, le palier `text-code` en Geist Mono. Le fond vient diff --git a/bun.lock b/bun.lock index 0bda71e..3fbe59b 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "name": "@lumyx/docs", "version": "0.1.0", "dependencies": { + "@astrojs/markdown-satteri": "0.4.6", "@astrojs/react": "^7.1.2", "@astrojs/sitemap": "^3.7.4", "@astrojs/starlight": "^0.42.7", @@ -25,6 +26,7 @@ "astro": "^7.4.4", "react": "19.2.8", "react-dom": "19.2.8", + "satteri": "0.10.5", "tailwindcss": "^4.3.3", }, "devDependencies": { @@ -202,11 +204,11 @@ "@emmetio/stream-reader-utils": ["@emmetio/stream-reader-utils@0.1.0", "", {}, "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A=="], - "@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], @@ -386,7 +388,7 @@ "@mapbox/node-pre-gyp": ["@mapbox/node-pre-gyp@2.0.3", "", { "dependencies": { "consola": "^3.2.3", "detect-libc": "^2.0.0", "https-proxy-agent": "^7.0.5", "node-fetch": "^2.6.7", "nopt": "^8.0.0", "semver": "^7.5.3", "tar": "^7.4.0" }, "bin": { "node-pre-gyp": "bin/node-pre-gyp" } }, "sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || 2.0.0-beta.2", "@emnapi/runtime": "^1.7.1 || 2.0.0-beta.2" } }, "sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw=="], "@next/env": ["@next/env@16.3.2", "", {}, "sha512-8k4YoG8cM7LWlkfzGNYCRBbFNlernLiMw4s0btVl+CmmWqn3VpYypA72/5Feb1UWdxe6tHqr5KHP4p4Y4m9luA=="], @@ -1776,9 +1778,13 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@astrojs/compiler-binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + "@astrojs/astro2tsx/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], + + "@astrojs/astro2tsx/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g=="], - "@astrojs/compiler-binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || 2.0.0-beta.2", "@emnapi/runtime": "^1.7.1 || 2.0.0-beta.2" } }, "sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw=="], + "@astrojs/compiler-binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], + + "@astrojs/compiler-binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -1786,10 +1792,6 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@bruits/satteri-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], - - "@bruits/satteri-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || 2.0.0-beta.2", "@emnapi/runtime": "^1.7.1 || 2.0.0-beta.2" } }, "sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw=="], - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], @@ -1864,7 +1866,9 @@ "yaml-language-server/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], - "@bruits/satteri-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + "@astrojs/astro2tsx/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], + + "@astrojs/compiler-binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], "@eslint/eslintrc/espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],