@zccz14/ux 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zccz14/ux
2
2
 
3
- `@zccz14/ux` provides the shared `AppLayout` shell for the NTNL app family (Cybion, NormAI, CTX, Linkit, Midas, HIT, 1Exchange, DeepSeek-LB, OpenAI-LB). One implementation of the sidebar, the AppHeader, and the Linkit account control keeps the interaction identical across apps and replaces the per-app copies of that chrome.
3
+ `@zccz14/ux` provides the shared app shell and the shared Markdown renderer for the NTNL app family (Cybion, NormAI, CTX, Linkit, Midas, HIT, 1Exchange, DeepSeek-LB, OpenAI-LB). One implementation of the sidebar, the AppHeader and the Linkit account control keeps the interaction identical across apps; one implementation of `<Markdown>` keeps rendered content — replies, checkpoints, manuals, documents, articles — identical too. Both replace the per-app copies they supersede.
4
4
 
5
5
  ## What the shell owns
6
6
 
@@ -102,9 +102,164 @@ type AppNavGroup = { label: string; items: AppNavItem[] };
102
102
  - **Favicon** — an `<svg>` logo is serialised to a `data:image/svg+xml` icon with its rendered colour pinned; an `<img>` logo reuses the image URL. The icon is rewritten when `.dark` flips, when `prefers-color-scheme` changes, and when the mark itself is swapped.
103
103
  - **Layout contract** — the shell is viewport-locked (`100svh`); the main region is the page's only scroll container and applies the standard `1rem` / `1.5rem` padding. Pages own their inner layout.
104
104
 
105
+ ## Markdown
106
+
107
+ `<Markdown content={...} />` is the single renderer for every piece of Markdown in the family.
108
+
109
+ ```tsx
110
+ import { Markdown } from "@zccz14/ux";
111
+
112
+ <Markdown content={reply} />;
113
+ ```
114
+
115
+ One prop, on purpose. There is no `math`, `mermaid`, `components`, `remarkPlugins` or `urlTransform` switch: a surface either renders Markdown the family way, or it renders through something else. Every capability below is always on, because the point of the component is that a reply in Cybion, a checkpoint, a manual preview and a CTX article look the same.
116
+
117
+ ### What it renders
118
+
119
+ - **GitHub Flavored Markdown** — tables, task lists, strikethrough, autolinks.
120
+ - **Math through KaTeX** — `$E=mc^2# @zccz14/ux
121
+
122
+ `@zccz14/ux` provides the shared app shell and the shared Markdown renderer for the NTNL app family (Cybion, NormAI, CTX, Linkit, Midas, HIT, 1Exchange, DeepSeek-LB, OpenAI-LB). One implementation of the sidebar, the AppHeader and the Linkit account control keeps the interaction identical across apps; one implementation of `<Markdown>` keeps rendered content — replies, checkpoints, manuals, documents, articles — identical too. Both replace the per-app copies they supersede.
123
+
124
+ ## What the shell owns
125
+
126
+ - **Sidebar** — one animated icon-collapse sidebar: 16rem expanded, 3rem collapsed, 200 ms width transition. Midas, 1Exchange and 1earn-style shells without animation are replaced by this behavior.
127
+ - **Navigation groups only** — the sidebar has no separate bottom section. Settings and status destinations live in the navigation groups like every other destination, so the bottom edge of every app's sidebar looks the same.
128
+ - **AppHeader** — sidebar trigger, current page title, your `headerSlot`, and the Linkit account control (`LinkitMyInfo`), in that order. Role badges such as 超级管理员 stay out of the shell: administrator affordances live in the administrator navigation group and pages.
129
+ - **Favicon** — the same mark becomes the page favicon: the shell serialises the rendered logo into `link[rel="icon"]`, pins the colour the mark currently shows (a favicon has no app stylesheet, so `currentColor` would otherwise fall back to black), and re-renders it whenever the theme changes. Pass `favicon={false}` to keep an app-owned icon.
130
+ - **Logo** — you pass one mark; the shell renders it inside a fixed 1.75rem box and controls the display size itself, so the logo size is identical everywhere. Pass a mark that follows the current colour (an icon using `currentColor`, or an SVG with mode-aware fills) so it keeps working in both themes. The title beside the logo disappears while collapsed.
131
+ - **Linkit integration** — the viewer's language selects the shell copy (中文/English), and theme, language and account controls all live in the `LinkitMyInfo` dialog. The shell never reads the theme itself: it renders whatever colour scheme its own CSS resolves (see [Styling](#styling)).
132
+
133
+ ## Installation
134
+
135
+ ```sh
136
+ npm install @zccz14/ux
137
+ ```
138
+
139
+ Peer dependencies: `react` (18 or 19), `react-dom`, `react-router-dom` 7, `@base-ui/react` ≥ 1.6, `linkit-react-components` ≥ 0.6.
140
+
141
+ ## Usage
142
+
143
+ ```tsx
144
+ import { AuthMiniProvider } from "auth-mini-react-components";
145
+ import { LinkitProvider } from "linkit-react-components";
146
+ import { AppLayout } from "@zccz14/ux";
147
+ import { MessagesSquareIcon, ServerIcon } from "lucide-react";
148
+ import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
149
+
150
+ import "@zccz14/ux/styles.css";
151
+ import "linkit-react-components/styles.css";
152
+ import { CybionMark } from "./cybion-mark";
153
+
154
+ export function App() {
155
+ return (
156
+ <AuthMiniProvider
157
+ authMiniBaseUrl="https://auth.ntnl.io"
158
+ audiences={["cybion.ntnl.io", "linkit.ntnl.io"]}
159
+ autoRedirectToLogin
160
+ >
161
+ <LinkitProvider linkitBaseUrl="https://linkit.ntnl.io" lang="zh-CN">
162
+ <HashRouter>
163
+ <AppLayout
164
+ logo={<CybionMark />}
165
+ title="Cybion"
166
+ nav={[
167
+ {
168
+ label: "工作台",
169
+ items: [
170
+ { to: "/threads", label: "对话", icon: <MessagesSquareIcon /> },
171
+ { to: "/system", label: "系统资源", icon: <ServerIcon /> },
172
+ ],
173
+ },
174
+ ]}
175
+ headerSlot={<RefreshButton />}
176
+ >
177
+ <Routes>
178
+ <Route path="/threads" element={<ThreadsPage />} />
179
+ <Route path="*" element={<Navigate replace to="/threads" />} />
180
+ </Routes>
181
+ </AppLayout>
182
+ </HashRouter>
183
+ </LinkitProvider>
184
+ </AuthMiniProvider>
185
+ );
186
+ }
187
+ ```
188
+
189
+ `AppLayout` must render below `AuthMiniProvider → LinkitProvider` (it renders `LinkitMyInfo` and reads `useLinkit()`), and inside a router (navigation items render router links).
190
+
191
+ ## API
192
+
193
+ ### `AppLayout` props
194
+
195
+ | Prop | Type | Required | Description |
196
+ | --- | --- | --- | --- |
197
+ | `logo` | `ReactNode` | yes | The logo mark. The shell renders it at its own fixed 1.75rem size and mirrors it into the page favicon; pass a mark that follows the current colour. |
198
+ | `favicon` | `boolean` | no | Defaults to `true`: the logo mark is written to `link[rel="icon"]` and re-rendered on theme changes. Set `false` to leave the app's own icon alone. |
199
+ | `title` | `string` | yes | App name beside the logo. Hidden while the sidebar is collapsed. |
200
+ | `nav` | `AppNavGroup[]` | yes | Navigation groups rendered in the sidebar. |
201
+ | `headerSlot` | `ReactNode` | no | Rendered immediately left of `LinkitMyInfo` in the AppHeader. Use it for page-level actions such as refresh. |
202
+ | `pageTitle` | `ReactNode` | no | Overrides the page title derived from the active navigation item. Use it for detail routes with their own title. |
203
+ | `children` | `ReactNode` | yes | Page content, rendered in the scrollable main region. |
204
+
205
+ ### Navigation types
206
+
207
+ ```ts
208
+ type AppNavItem = { to: string; label: string; icon: ReactNode };
209
+ type AppNavGroup = { label: string; items: AppNavItem[] };
210
+ ```
211
+
212
+ `to` is a router path. Each item renders a `NavLink`, so active items receive `aria-current="page"` and the standard active styling. Route matching uses longest-prefix wins, and `/` matches only exactly.
213
+
214
+ ## Behavior
215
+
216
+ - **Page title** — the AppHeader shows the active navigation item's `label`, matched from `location.pathname`. `pageTitle` wins when supplied.
217
+ - **Collapse** — the header trigger or `⌘B` / `Ctrl+B` toggles the sidebar; the choice persists in the `sidebar_state` cookie.
218
+ - **Mobile** — below 768px the sidebar becomes a modal navigation drawer: the trigger opens it, tapping a link or the backdrop closes it.
219
+ - **Collapsed tooltips** — icon-only items reveal their label in a tooltip on hover and focus.
220
+ - **Reduced motion** — `prefers-reduced-motion: reduce` disables the animations.
221
+ - **Favicon** — an `<svg>` logo is serialised to a `data:image/svg+xml` icon with its rendered colour pinned; an `<img>` logo reuses the image URL. The icon is rewritten when `.dark` flips, when `prefers-color-scheme` changes, and when the mark itself is swapped.
222
+ - **Layout contract** — the shell is viewport-locked (`100svh`); the main region is the page's only scroll container and applies the standard `1rem` / `1.5rem` padding. Pages own their inner layout.
223
+
224
+ ## Markdown
225
+
226
+ `<Markdown content={...} />` is the single renderer for every piece of Markdown in the family.
227
+
228
+ ```tsx
229
+ import { Markdown } from "@zccz14/ux";
230
+
231
+ <Markdown content={reply} />;
232
+ ```
233
+
234
+ One prop, on purpose. There is no `math`, `mermaid`, `components`, `remarkPlugins` or `urlTransform` switch: a surface either renders Markdown the family way, or it renders through something else. Every capability below is always on, because the point of the component is that a reply in Cybion, a checkpoint, a manual preview and a CTX article look the same.
235
+
236
+ ### What it renders
237
+
238
+ - **GitHub Flavored Markdown** — tables, task lists, strikethrough, autolinks.
239
+ , `$…$`, `\(…\)`, `\[…\]` and `math`-tagged code fences, because models emit all of them. The stylesheet and fonts are imported by the component itself, so a formula is never unstyled; the runtime (~76 KB gzipped) is fetched only by a surface whose content can actually contain a formula, and stays cached for the rest of the session. Until that chunk arrives — and always when rendering on a server — a formula shows as the source it was written as, the same state a still-streaming formula is in. Broken math degrades to one `.katex-error` span carrying the parser message.
240
+ - **Mermaid diagrams** — a `mermaid`-tagged fence becomes an SVG, loaded on demand: a surface without a diagram never downloads mermaid. The chart follows the current theme and the surrounding text size, and re-renders when `.dark` flips. An unparseable diagram falls back to its own source block.
241
+ - **Links** — absolute URLs open in a new tab with `rel="noopener noreferrer"` and an external marker; in-page anchors and relative paths stay in the app. Only `http(s)` and `mailto` survive: `javascript:`, `data:` and friends degrade to plain text.
242
+ - **Tables and code** — a wide table or a long code block scrolls inside its own box, so the transcript or article around it never grows a horizontal scrollbar.
243
+ - **Raw HTML** — never rendered. The pipeline has no `rehype-raw`, so HTML in the source is shown as text.
244
+
245
+ ### Typography is owned here
246
+
247
+ The component wraps its content in `.ux-markdown`, and that block is the family's typographic contract: every size is expressed in `em` off the inherited font size, and no measure is imposed. The same content therefore keeps identical proportions in a 14px chat bubble and a 16px article page. The host decides two things and only two — the font size of the container the content is dropped into, and the width of that container. Do not wrap the component in `prose` classes: the stylesheet is the single source of the look, and `scripts/check-styles.mjs` enforces it.
248
+
249
+ ### Why the pipeline is a runtime dependency
250
+
251
+ `react-markdown`, `remark-gfm`, `remark-math-extended`, `rehype-katex`, `katex` and `mermaid` are `dependencies`, not peers, so an app wires nothing. An app that never renders Markdown tree-shakes the whole chain away, and two pieces are deliberately kept out of the main bundle even for apps that do: Mermaid (loaded by the first diagram, so a surface without one never downloads it) and the math runtime (KaTeX's ~76 KB gzipped JavaScript, loaded by the first surface whose content may contain a formula). What stays static is KaTeX's stylesheet — about 3.6 KB gzipped, with fonts the browser only fetches once a formula is on screen — because a formula must never render unstyled. `scripts/check-package.mjs` fails the build if the manifest ever claims `sideEffects: false`, which would let a bundler drop that stylesheet and render every formula as bare LaTeX with no error to notice.
252
+
253
+ ## Migrating an app from its local Markdown renderer
254
+
255
+ 1. Delete the app's own renderer — the `react-markdown` wrapper, its plugin wiring, its `components` map, its `Mermaid`/`MarkdownPre` pair, and any `mermaid`/`rehype-katex`/`remark-*`/`katex` dependency entries that only served it.
256
+ 2. Replace each call site with `<Markdown content={markdown} />` and drop the surrounding `prose …` classes: the typography lives in `.ux-markdown`, and the container only owes the content a font size and a width.
257
+ 3. Keep app-specific node mapping out of the way — if a surface really needs different output (inline mention tokens, a clamped preview), it belongs in the library, not in a local fork of the plugin chain.
258
+ 4. Keep the KaTeX stylesheet import out of the app: the component owns it.
259
+
105
260
  ## Styling
106
261
 
107
- Import `@zccz14/ux/styles.css` once. It consumes the app's shadcn theme tokens and falls back to its own values, so the shell stays readable even when a token is missing: `--background`, `--foreground`, `--border`, `--accent`, `--muted`, `--ring`, `--sidebar`, `--sidebar-foreground`, `--sidebar-accent`, `--sidebar-accent-foreground`, `--sidebar-border`, `--sidebar-ring`.
262
+ Import `@zccz14/ux/styles.css` once. It consumes the app's shadcn theme tokens and falls back to its own values, so the shell stays readable even when a token is missing: `--background`, `--foreground`, `--border`, `--accent`, `--muted`, `--ring`, `--sidebar`, `--sidebar-foreground`, `--sidebar-accent`, `--sidebar-accent-foreground`, `--sidebar-border`, `--sidebar-ring`. The Markdown layer reads `--muted`, `--muted-foreground`, `--primary` and `--destructive` through the same `--ux-*` layer (`--ux-muted`, `--ux-muted-foreground`, `--ux-primary`, `--ux-destructive`).
108
263
 
109
264
  Those tokens are read through a `--ux-*` layer declared twice — a light set on `:root` and a dark set on `.dark` — so every fallback has a dark counterpart. A missing `--sidebar` therefore renders dark in dark mode instead of a light sidebar. The shell also sets `color-scheme` per mode, so native scrollbars and controls follow the theme without help from a theme provider.
110
265
 
@@ -121,9 +276,9 @@ Theme state itself is not the shell's business: whatever toggles `.dark` on `<ht
121
276
  ## Development
122
277
 
123
278
  ```sh
124
- npm install
125
- npm run typecheck
126
- npm test
127
- npm run build
128
- npm run pack:check
279
+ pnpm install
280
+ pnpm run typecheck
281
+ pnpm test
282
+ pnpm run build
283
+ pnpm run pack:check
129
284
  ```
package/dist/icons.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export declare function CloseIcon(): import("react").JSX.Element;
2
2
  export declare function PanelLeftIcon(): import("react").JSX.Element;
3
+ export declare function ExternalLinkIcon(): import("react").JSX.Element;
package/dist/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export { AppLayout } from "./app-layout.js";
2
- export type { AppLayoutProps, AppNavGroup, AppNavItem } from "./types.js";
2
+ export { Markdown } from "./markdown.js";
3
+ export type { AppLayoutProps, AppNavGroup, AppNavItem, MarkdownProps } from "./types.js";
package/dist/index.js CHANGED
@@ -1,64 +1,67 @@
1
- import { jsxs as d, jsx as n, Fragment as L } from "react/jsx-runtime";
2
- import { useEffect as k, useRef as M, useState as v, useCallback as x, useSyncExternalStore as C } from "react";
3
- import { Dialog as h } from "@base-ui/react/dialog";
4
- import { Tooltip as m } from "@base-ui/react/tooltip";
5
- import { useLinkit as E, LinkitMyInfo as O } from "linkit-react-components";
6
- import { useLocation as y, NavLink as I } from "react-router-dom";
7
- const P = {
1
+ import { jsxs as d, jsx as n, Fragment as T } from "react/jsx-runtime";
2
+ import { useEffect as g, useRef as v, useState as x, useCallback as _, useSyncExternalStore as L } from "react";
3
+ import { Dialog as f } from "@base-ui/react/dialog";
4
+ import { Tooltip as h } from "@base-ui/react/tooltip";
5
+ import { useLinkit as z, LinkitMyInfo as F } from "linkit-react-components";
6
+ import { useLocation as C, NavLink as $ } from "react-router-dom";
7
+ import I from "react-markdown";
8
+ import A from "remark-gfm";
9
+ import "katex/dist/katex.min.css";
10
+ const R = {
8
11
  closeNavigation: "Close navigation",
9
12
  navigation: "Navigation",
10
13
  toggleSidebar: "Toggle sidebar"
11
- }, T = {
14
+ }, W = {
12
15
  closeNavigation: "关闭导航",
13
16
  navigation: "导航",
14
17
  toggleSidebar: "切换侧边栏"
15
18
  };
16
- function A(e) {
17
- return e.toLowerCase().startsWith("zh") ? T : P;
19
+ function j(e) {
20
+ return e.toLowerCase().startsWith("zh") ? W : R;
18
21
  }
19
- const $ = 'link[rel~="icon"]';
20
- function R(e) {
22
+ const B = 'link[rel~="icon"]';
23
+ function q(e) {
21
24
  const t = e.cloneNode(!0);
22
25
  return t.setAttribute("xmlns", "http://www.w3.org/2000/svg"), t.style.color = getComputedStyle(e).color, new XMLSerializer().serializeToString(t);
23
26
  }
24
- function W(e) {
27
+ function D(e) {
25
28
  if (e instanceof SVGSVGElement)
26
29
  return {
27
- href: `data:image/svg+xml,${encodeURIComponent(R(e))}`,
30
+ href: `data:image/svg+xml,${encodeURIComponent(q(e))}`,
28
31
  type: "image/svg+xml"
29
32
  };
30
- const { currentSrc: t, src: o } = e, i = t || o;
31
- return /\.svg([?#]|$)/i.test(i) ? { href: i, type: "image/svg+xml" } : { href: i };
33
+ const { currentSrc: t, src: r } = e, a = t || r;
34
+ return /\.svg([?#]|$)/i.test(a) ? { href: a, type: "image/svg+xml" } : { href: a };
32
35
  }
33
- function j() {
34
- const e = document.querySelector($);
36
+ function V() {
37
+ const e = document.querySelector(B);
35
38
  if (e)
36
39
  return e;
37
40
  const t = document.createElement("link");
38
41
  return t.rel = "icon", document.head.append(t), t;
39
42
  }
40
- function z(e, t) {
41
- k(() => {
43
+ function G(e, t) {
44
+ g(() => {
42
45
  if (!t)
43
46
  return;
44
- const o = () => {
45
- var l;
46
- const p = (l = e.current) == null ? void 0 : l.querySelector("svg, img");
47
- if (!p)
47
+ const r = () => {
48
+ var m;
49
+ const o = (m = e.current) == null ? void 0 : m.querySelector("svg, img");
50
+ if (!o)
48
51
  return;
49
- const { href: u, type: s } = W(p), c = j();
52
+ const { href: u, type: s } = D(o), c = V();
50
53
  s ? c.type = s : c.removeAttribute("type"), c.getAttribute("href") !== u && c.setAttribute("href", u);
51
54
  };
52
- o();
53
- const i = new MutationObserver(o);
54
- i.observe(document.documentElement, { attributes: !0, attributeFilter: ["class", "style"] }), e.current && i.observe(e.current, { childList: !0, subtree: !0 });
55
- const r = window.matchMedia("(prefers-color-scheme: dark)");
56
- return r.addEventListener("change", o), () => {
57
- i.disconnect(), r.removeEventListener("change", o);
55
+ r();
56
+ const a = new MutationObserver(r);
57
+ a.observe(document.documentElement, { attributes: !0, attributeFilter: ["class", "style"] }), e.current && a.observe(e.current, { childList: !0, subtree: !0 });
58
+ const i = window.matchMedia("(prefers-color-scheme: dark)");
59
+ return i.addEventListener("change", r), () => {
60
+ a.disconnect(), i.removeEventListener("change", r);
58
61
  };
59
62
  }, [t, e]);
60
63
  }
61
- function B() {
64
+ function K() {
62
65
  return /* @__PURE__ */ d(
63
66
  "svg",
64
67
  {
@@ -76,7 +79,7 @@ function B() {
76
79
  }
77
80
  );
78
81
  }
79
- function F() {
82
+ function H() {
80
83
  return /* @__PURE__ */ d(
81
84
  "svg",
82
85
  {
@@ -94,174 +97,194 @@ function F() {
94
97
  }
95
98
  );
96
99
  }
97
- const f = "sidebar_state", q = 3600 * 24 * 7, w = "(max-width: 767px)";
98
- function D() {
99
- const e = document.cookie.split("; ").find((t) => t.startsWith(`${f}=`));
100
- return e ? e.slice(f.length + 1) !== "false" : !0;
100
+ function U() {
101
+ return /* @__PURE__ */ d(
102
+ "svg",
103
+ {
104
+ "aria-hidden": "true",
105
+ className: "ux-markdown__external-link",
106
+ fill: "none",
107
+ stroke: "currentColor",
108
+ strokeLinecap: "round",
109
+ strokeLinejoin: "round",
110
+ strokeWidth: "2",
111
+ viewBox: "0 0 24 24",
112
+ children: [
113
+ /* @__PURE__ */ n("path", { d: "M15 3h6v6" }),
114
+ /* @__PURE__ */ n("path", { d: "M10 14 21 3" }),
115
+ /* @__PURE__ */ n("path", { d: "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" })
116
+ ]
117
+ }
118
+ );
101
119
  }
102
- function K(e) {
103
- document.cookie = `${f}=${e}; path=/; max-age=${q}`;
120
+ const k = "sidebar_state", Q = 3600 * 24 * 7, P = "(max-width: 767px)";
121
+ function X() {
122
+ const e = document.cookie.split("; ").find((t) => t.startsWith(`${k}=`));
123
+ return e ? e.slice(k.length + 1) !== "false" : !0;
104
124
  }
105
- function G(e) {
106
- const t = window.matchMedia(w);
125
+ function J(e) {
126
+ document.cookie = `${k}=${e}; path=/; max-age=${Q}`;
127
+ }
128
+ function Y(e) {
129
+ const t = window.matchMedia(P);
107
130
  return t.addEventListener("change", e), () => t.removeEventListener("change", e);
108
131
  }
109
- function V() {
110
- return C(
111
- G,
112
- () => window.matchMedia(w).matches,
132
+ function Z() {
133
+ return L(
134
+ Y,
135
+ () => window.matchMedia(P).matches,
113
136
  () => !1
114
137
  );
115
138
  }
116
- function Q() {
117
- const e = V(), [t, o] = v(D), [i, r] = v(!1), p = x((s) => {
118
- o(s), K(s);
119
- }, []), u = x(() => {
139
+ function ee() {
140
+ const e = Z(), [t, r] = x(X), [a, i] = x(!1), o = _((s) => {
141
+ r(s), J(s);
142
+ }, []), u = _(() => {
120
143
  if (e) {
121
- r((s) => !s);
144
+ i((s) => !s);
122
145
  return;
123
146
  }
124
- p(!t);
125
- }, [e, t, p]);
126
- return k(() => {
147
+ o(!t);
148
+ }, [e, t, o]);
149
+ return g(() => {
127
150
  function s(c) {
128
151
  (c.metaKey || c.ctrlKey) && c.key === "b" && (c.preventDefault(), u());
129
152
  }
130
153
  return window.addEventListener("keydown", s), () => window.removeEventListener("keydown", s);
131
- }, [u]), { isMobile: e, open: t, openMobile: i, setOpenMobile: r, toggleSidebar: u };
154
+ }, [u]), { isMobile: e, open: t, openMobile: a, setOpenMobile: i, toggleSidebar: u };
132
155
  }
133
- function _(e, t) {
134
- let o;
135
- for (const i of e)
136
- for (const r of i.items)
137
- (r.to === "/" ? t === "/" : t === r.to || t.startsWith(`${r.to}/`)) && (!o || r.to.length > o.to.length) && (o = r);
138
- return o;
156
+ function E(e, t) {
157
+ let r;
158
+ for (const a of e)
159
+ for (const i of a.items)
160
+ (i.to === "/" ? t === "/" : t === i.to || t.startsWith(`${i.to}/`)) && (!r || i.to.length > r.to.length) && (r = i);
161
+ return r;
139
162
  }
140
- function U({
163
+ function te({
141
164
  collapsed: e,
142
165
  end: t,
143
- item: o,
144
- onNavigate: i
166
+ item: r,
167
+ onNavigate: a
145
168
  }) {
146
- return /* @__PURE__ */ d(m.Root, { children: [
169
+ return /* @__PURE__ */ d(h.Root, { children: [
147
170
  /* @__PURE__ */ d(
148
- m.Trigger,
171
+ h.Trigger,
149
172
  {
150
173
  render: /* @__PURE__ */ n(
151
- I,
174
+ $,
152
175
  {
153
176
  className: "ux-nav-item",
154
177
  end: t,
155
- onClick: i,
156
- to: o.to
178
+ onClick: a,
179
+ to: r.to
157
180
  }
158
181
  ),
159
182
  children: [
160
- /* @__PURE__ */ n("span", { className: "ux-nav-item__icon", children: o.icon }),
161
- /* @__PURE__ */ n("span", { className: "ux-nav-item__text", children: o.label })
183
+ /* @__PURE__ */ n("span", { className: "ux-nav-item__icon", children: r.icon }),
184
+ /* @__PURE__ */ n("span", { className: "ux-nav-item__text", children: r.label })
162
185
  ]
163
186
  }
164
187
  ),
165
- /* @__PURE__ */ n(m.Portal, { children: /* @__PURE__ */ n(
166
- m.Positioner,
188
+ /* @__PURE__ */ n(h.Portal, { children: /* @__PURE__ */ n(
189
+ h.Positioner,
167
190
  {
168
191
  className: "ux-tooltip-positioner",
169
192
  side: "right",
170
193
  sideOffset: 6,
171
- children: /* @__PURE__ */ n(m.Popup, { className: "ux-tooltip", hidden: !e, children: o.label })
194
+ children: /* @__PURE__ */ n(h.Popup, { className: "ux-tooltip", hidden: !e, children: r.label })
172
195
  }
173
196
  ) })
174
197
  ] });
175
198
  }
176
- function N({
199
+ function M({
177
200
  copy: e,
178
201
  logo: t,
179
- logoRef: o,
180
- nav: i,
181
- onNavigate: r,
182
- state: p,
202
+ logoRef: r,
203
+ nav: a,
204
+ onNavigate: i,
205
+ state: o,
183
206
  title: u
184
207
  }) {
185
- const { pathname: s } = y(), c = _(i, s);
186
- return /* @__PURE__ */ d(L, { children: [
208
+ const { pathname: s } = C(), c = E(a, s);
209
+ return /* @__PURE__ */ d(T, { children: [
187
210
  /* @__PURE__ */ d("div", { className: "ux-sidebar__header", children: [
188
- /* @__PURE__ */ n("span", { className: "ux-sidebar__logo", ref: o, children: t }),
211
+ /* @__PURE__ */ n("span", { className: "ux-sidebar__logo", ref: r, children: t }),
189
212
  /* @__PURE__ */ n("span", { className: "ux-sidebar__title", children: u })
190
213
  ] }),
191
- /* @__PURE__ */ n("nav", { "aria-label": e.navigation, className: "ux-sidebar__nav", children: i.map((l) => /* @__PURE__ */ d("div", { className: "ux-nav-group", children: [
192
- /* @__PURE__ */ n("div", { className: "ux-nav-group__label", children: l.label }),
193
- l.items.map((a) => (
214
+ /* @__PURE__ */ n("nav", { "aria-label": e.navigation, className: "ux-sidebar__nav", children: a.map((m) => /* @__PURE__ */ d("div", { className: "ux-nav-group", children: [
215
+ /* @__PURE__ */ n("div", { className: "ux-nav-group__label", children: m.label }),
216
+ m.items.map((l) => (
194
217
  // NavLink would mark a parent section current on its children's
195
218
  // routes; follow matchActiveItem instead so only the deepest
196
219
  // match (or the exact path) stays marked.
197
220
  /* @__PURE__ */ n(
198
- U,
221
+ te,
199
222
  {
200
- collapsed: p === "collapsed",
201
- end: a !== c || a.to === s,
202
- item: a,
203
- onNavigate: r
223
+ collapsed: o === "collapsed",
224
+ end: l !== c || l.to === s,
225
+ item: l,
226
+ onNavigate: i
204
227
  },
205
- a.to
228
+ l.to
206
229
  )
207
230
  ))
208
- ] }, l.label)) })
231
+ ] }, m.label)) })
209
232
  ] });
210
233
  }
211
- function te({
234
+ function Ce({
212
235
  children: e,
213
236
  favicon: t = !0,
214
- headerSlot: o,
215
- logo: i,
216
- nav: r,
217
- pageTitle: p,
237
+ headerSlot: r,
238
+ logo: a,
239
+ nav: i,
240
+ pageTitle: o,
218
241
  title: u
219
242
  }) {
220
- var g;
221
- const { pathname: s } = y(), { lang: c } = E(), l = A(c), a = Q(), b = M(null), S = p ?? ((g = _(r, s)) == null ? void 0 : g.label);
222
- return z(b, t), /* @__PURE__ */ n(m.Provider, { delay: 0, children: /* @__PURE__ */ d(
243
+ var w;
244
+ const { pathname: s } = C(), { lang: c } = z(), m = j(c), l = ee(), N = v(null), O = o ?? ((w = E(i, s)) == null ? void 0 : w.label);
245
+ return G(N, t), /* @__PURE__ */ n(h.Provider, { delay: 0, children: /* @__PURE__ */ d(
223
246
  "div",
224
247
  {
225
248
  className: "ux-app-layout",
226
- "data-state": a.open ? "expanded" : "collapsed",
249
+ "data-state": l.open ? "expanded" : "collapsed",
227
250
  children: [
228
251
  /* @__PURE__ */ n("aside", { className: "ux-sidebar", children: /* @__PURE__ */ n(
229
- N,
252
+ M,
230
253
  {
231
- copy: l,
232
- logo: i,
233
- logoRef: b,
234
- nav: r,
235
- state: a.open ? "expanded" : "collapsed",
254
+ copy: m,
255
+ logo: a,
256
+ logoRef: N,
257
+ nav: i,
258
+ state: l.open ? "expanded" : "collapsed",
236
259
  title: u
237
260
  }
238
261
  ) }),
239
- a.isMobile ? /* @__PURE__ */ n(
240
- h.Root,
262
+ l.isMobile ? /* @__PURE__ */ n(
263
+ f.Root,
241
264
  {
242
- open: a.openMobile,
243
- onOpenChange: a.setOpenMobile,
244
- children: /* @__PURE__ */ d(h.Portal, { children: [
245
- /* @__PURE__ */ n(h.Backdrop, { className: "ux-drawer-backdrop" }),
246
- /* @__PURE__ */ d(h.Popup, { className: "ux-drawer", children: [
247
- /* @__PURE__ */ n(h.Title, { className: "ux-visually-hidden", children: l.navigation }),
265
+ open: l.openMobile,
266
+ onOpenChange: l.setOpenMobile,
267
+ children: /* @__PURE__ */ d(f.Portal, { children: [
268
+ /* @__PURE__ */ n(f.Backdrop, { className: "ux-drawer-backdrop" }),
269
+ /* @__PURE__ */ d(f.Popup, { className: "ux-drawer", children: [
270
+ /* @__PURE__ */ n(f.Title, { className: "ux-visually-hidden", children: m.navigation }),
248
271
  /* @__PURE__ */ n(
249
272
  "button",
250
273
  {
251
- "aria-label": l.closeNavigation,
274
+ "aria-label": m.closeNavigation,
252
275
  className: "ux-drawer__close",
253
276
  type: "button",
254
- onClick: () => a.setOpenMobile(!1),
255
- children: /* @__PURE__ */ n(B, {})
277
+ onClick: () => l.setOpenMobile(!1),
278
+ children: /* @__PURE__ */ n(K, {})
256
279
  }
257
280
  ),
258
281
  /* @__PURE__ */ n(
259
- N,
282
+ M,
260
283
  {
261
- copy: l,
262
- logo: i,
263
- nav: r,
264
- onNavigate: () => a.setOpenMobile(!1),
284
+ copy: m,
285
+ logo: a,
286
+ nav: i,
287
+ onNavigate: () => l.setOpenMobile(!1),
265
288
  state: "expanded",
266
289
  title: u
267
290
  }
@@ -275,18 +298,18 @@ function te({
275
298
  /* @__PURE__ */ n(
276
299
  "button",
277
300
  {
278
- "aria-expanded": a.isMobile ? a.openMobile : a.open,
279
- "aria-label": l.toggleSidebar,
301
+ "aria-expanded": l.isMobile ? l.openMobile : l.open,
302
+ "aria-label": m.toggleSidebar,
280
303
  className: "ux-app-header__trigger",
281
304
  type: "button",
282
- onClick: a.toggleSidebar,
283
- children: /* @__PURE__ */ n(F, {})
305
+ onClick: l.toggleSidebar,
306
+ children: /* @__PURE__ */ n(H, {})
284
307
  }
285
308
  ),
286
309
  /* @__PURE__ */ n("span", { "aria-hidden": "true", className: "ux-app-header__separator" }),
287
- /* @__PURE__ */ n("p", { className: "ux-app-header__title", children: S }),
288
- /* @__PURE__ */ n("div", { className: "ux-app-header__slot", children: o }),
289
- /* @__PURE__ */ n(O, {})
310
+ /* @__PURE__ */ n("p", { className: "ux-app-header__title", children: O }),
311
+ /* @__PURE__ */ n("div", { className: "ux-app-header__slot", children: r }),
312
+ /* @__PURE__ */ n(F, {})
290
313
  ] }),
291
314
  /* @__PURE__ */ n("main", { className: "ux-main", children: e })
292
315
  ] })
@@ -294,6 +317,147 @@ function te({
294
317
  }
295
318
  ) });
296
319
  }
320
+ let ne = 0;
321
+ function re() {
322
+ return document.documentElement.classList.contains("dark") ? "dark" : "neutral";
323
+ }
324
+ function ae() {
325
+ const e = getComputedStyle(document.documentElement).fontFamily.trim();
326
+ return e === "" ? "sans-serif" : e;
327
+ }
328
+ function ie(e) {
329
+ const t = e ? Number.parseFloat(getComputedStyle(e).fontSize) : Number.NaN;
330
+ return !Number.isFinite(t) || t <= 0 ? 16 : Math.min(Math.max(t, 10), 20);
331
+ }
332
+ async function oe(e, t) {
333
+ try {
334
+ const { default: r } = await import("mermaid");
335
+ r.initialize({
336
+ startOnLoad: !1,
337
+ securityLevel: "strict",
338
+ theme: re(),
339
+ themeVariables: { fontFamily: ae(), fontSize: `${ie(t)}px` }
340
+ });
341
+ const { svg: a } = await r.render(`ux-mermaid-${++ne}`, e);
342
+ return a;
343
+ } catch {
344
+ return null;
345
+ }
346
+ }
347
+ function se({ source: e }) {
348
+ const [t, r] = x(null), a = v(null), i = v(0);
349
+ return g(() => {
350
+ const o = async () => {
351
+ const s = ++i.current, c = await oe(e, a.current);
352
+ i.current === s && r(c);
353
+ };
354
+ o();
355
+ const u = new MutationObserver(() => void o());
356
+ return u.observe(document.documentElement, { attributes: !0, attributeFilter: ["class"] }), () => {
357
+ i.current += 1, u.disconnect();
358
+ };
359
+ }, [e]), t === null ? /* @__PURE__ */ n(
360
+ "pre",
361
+ {
362
+ className: "ux-markdown__mermaid-source",
363
+ "data-mermaid": "source",
364
+ ref: (o) => {
365
+ a.current = o;
366
+ },
367
+ children: /* @__PURE__ */ n("code", { className: "language-mermaid", children: e })
368
+ }
369
+ ) : /* @__PURE__ */ n(
370
+ "div",
371
+ {
372
+ "aria-label": "Mermaid diagram",
373
+ className: "ux-markdown__mermaid",
374
+ "data-mermaid": "diagram",
375
+ dangerouslySetInnerHTML: { __html: t },
376
+ ref: (o) => {
377
+ a.current = o;
378
+ },
379
+ role: "img"
380
+ }
381
+ );
382
+ }
383
+ let p = null, b = null;
384
+ const y = /* @__PURE__ */ new Set();
385
+ function le(e) {
386
+ return y.add(e), () => {
387
+ y.delete(e);
388
+ };
389
+ }
390
+ function ce() {
391
+ return p !== null;
392
+ }
393
+ const ue = /^ {0,3}(?:```|~~~)\s*math\b/m;
394
+ function de(e) {
395
+ return e.includes("$") || e.includes("\\(") || e.includes("\\[") || ue.test(e);
396
+ }
397
+ function me() {
398
+ return b ?? (b = Promise.all([import("remark-math-extended"), import("rehype-katex")]).then(([e, t]) => {
399
+ p = {
400
+ remarkPlugins: [e.default],
401
+ rehypePlugins: [
402
+ [t.default, { strict: "ignore", errorColor: "var(--ux-destructive)" }]
403
+ ]
404
+ };
405
+ for (const r of y)
406
+ r();
407
+ }).catch(() => {
408
+ b = null;
409
+ })), b;
410
+ }
411
+ function he(e) {
412
+ const t = e == null ? void 0 : e.children[0];
413
+ if ((t == null ? void 0 : t.type) !== "element" || t.tagName !== "code")
414
+ return null;
415
+ const r = t.properties.className;
416
+ if (!Array.isArray(r) || !r.includes("language-mermaid"))
417
+ return null;
418
+ const a = t.children[0];
419
+ return (a == null ? void 0 : a.type) === "text" ? a.value : null;
420
+ }
421
+ const pe = {
422
+ a: ({ children: e, href: t, node: r, ...a }) => t ? ge(t) ? /* @__PURE__ */ d("a", { ...a, href: t, rel: "noopener noreferrer", target: "_blank", children: [
423
+ e,
424
+ /* @__PURE__ */ n(U, {})
425
+ ] }) : /* @__PURE__ */ n("a", { ...a, href: t, children: e }) : /* @__PURE__ */ n("span", { ...a, children: e }),
426
+ pre: ({ children: e, node: t, ...r }) => {
427
+ const a = he(t);
428
+ return a === null ? /* @__PURE__ */ n("pre", { ...r, children: e }) : /* @__PURE__ */ n(se, { source: a });
429
+ },
430
+ // A wide table scrolls on its own; the transcript or article around it never grows a horizontal
431
+ // scrollbar, so the columns past the fold stay reachable.
432
+ table: ({ children: e, node: t, ...r }) => /* @__PURE__ */ n("div", { className: "ux-markdown__table", children: /* @__PURE__ */ n("table", { ...r, children: e }) })
433
+ }, fe = /* @__PURE__ */ new Set(["http", "https", "mailto"]);
434
+ function ge(e) {
435
+ return /^[a-z][a-z0-9+.-]*:/i.test(e) || e.startsWith("//");
436
+ }
437
+ function be(e) {
438
+ const t = e.trim(), r = t.indexOf(":");
439
+ if (r === -1)
440
+ return t;
441
+ const a = t.slice(0, r).toLowerCase();
442
+ return /^[a-z][a-z0-9+.-]*$/.test(a) ? fe.has(a) ? t : "" : t;
443
+ }
444
+ const S = [A], ve = [];
445
+ function Pe({ content: e }) {
446
+ const t = L(le, ce, () => !1);
447
+ return g(() => {
448
+ !t && de(e) && me();
449
+ }, [e, t]), /* @__PURE__ */ n("div", { className: "ux-markdown", children: /* @__PURE__ */ n(
450
+ I,
451
+ {
452
+ components: pe,
453
+ rehypePlugins: t && p ? p.rehypePlugins : ve,
454
+ remarkPlugins: t && p ? [...S, ...p.remarkPlugins] : S,
455
+ urlTransform: be,
456
+ children: e
457
+ }
458
+ ) });
459
+ }
297
460
  export {
298
- te as AppLayout
461
+ Ce as AppLayout,
462
+ Pe as Markdown
299
463
  };
@@ -0,0 +1,3 @@
1
+ import type { MarkdownProps } from "./types.js";
2
+ import "katex/dist/katex.min.css";
3
+ export declare function Markdown({ content }: MarkdownProps): import("react").JSX.Element;
@@ -0,0 +1,3 @@
1
+ export declare function Mermaid({ source }: {
2
+ source: string;
3
+ }): import("react").JSX.Element;
package/dist/styles.css CHANGED
@@ -13,6 +13,10 @@
13
13
  --ux-foreground: var(--foreground, #0f172a);
14
14
  --ux-border: var(--border, #e2e8f0);
15
15
  --ux-accent: var(--accent, var(--muted, #f1f5f9));
16
+ --ux-muted: var(--muted, #f1f5f9);
17
+ --ux-muted-foreground: var(--muted-foreground, #64748b);
18
+ --ux-primary: var(--primary, #2563eb);
19
+ --ux-destructive: var(--destructive, #dc2626);
16
20
  --ux-ring: var(--ring, #2563eb);
17
21
  --ux-sidebar: var(--sidebar, #fafafa);
18
22
  --ux-sidebar-foreground: var(--sidebar-foreground, var(--foreground, #0f172a));
@@ -27,6 +31,10 @@
27
31
  --ux-foreground: var(--foreground, #f2f2f2);
28
32
  --ux-border: var(--border, #383838);
29
33
  --ux-accent: var(--accent, var(--muted, #303030));
34
+ --ux-muted: var(--muted, #262626);
35
+ --ux-muted-foreground: var(--muted-foreground, #a3a3a3);
36
+ --ux-primary: var(--primary, #60a5fa);
37
+ --ux-destructive: var(--destructive, #f87171);
30
38
  --ux-ring: var(--ring, #d4d4d4);
31
39
  --ux-sidebar: var(--sidebar, #161616);
32
40
  --ux-sidebar-foreground: var(--sidebar-foreground, var(--foreground, #f2f2f2));
@@ -462,3 +470,236 @@
462
470
  transition: none;
463
471
  }
464
472
  }
473
+
474
+ /* --- Markdown --- */
475
+
476
+ /*
477
+ * One typographic scale for every Markdown surface in the family: replies, checkpoints, manuals,
478
+ * documents and articles all render through <Markdown>. Every size here is relative to the
479
+ * inherited font size and no measure is imposed, so the same content keeps identical proportions
480
+ * inside a 14px chat bubble and a 16px article page — the host container decides the column, this
481
+ * block decides the rhythm.
482
+ */
483
+
484
+ .ux-markdown {
485
+ min-width: 0;
486
+ line-height: 1.7;
487
+ overflow-wrap: break-word;
488
+ }
489
+
490
+ .ux-markdown > :first-child {
491
+ margin-top: 0;
492
+ }
493
+
494
+ .ux-markdown > :last-child {
495
+ margin-bottom: 0;
496
+ }
497
+
498
+ .ux-markdown p,
499
+ .ux-markdown ul,
500
+ .ux-markdown ol,
501
+ .ux-markdown blockquote,
502
+ .ux-markdown pre,
503
+ .ux-markdown .ux-markdown__table,
504
+ .ux-markdown .ux-markdown__mermaid {
505
+ margin: 0.75em 0;
506
+ }
507
+
508
+ .ux-markdown h1,
509
+ .ux-markdown h2,
510
+ .ux-markdown h3,
511
+ .ux-markdown h4,
512
+ .ux-markdown h5,
513
+ .ux-markdown h6 {
514
+ margin: 1.5em 0 0.5em;
515
+ font-weight: 600;
516
+ line-height: 1.3;
517
+ letter-spacing: -0.01em;
518
+ text-wrap: balance;
519
+ }
520
+
521
+ .ux-markdown h1 {
522
+ font-size: 1.5em;
523
+ }
524
+
525
+ .ux-markdown h2 {
526
+ font-size: 1.25em;
527
+ }
528
+
529
+ .ux-markdown h3 {
530
+ font-size: 1.125em;
531
+ }
532
+
533
+ .ux-markdown h4,
534
+ .ux-markdown h5,
535
+ .ux-markdown h6 {
536
+ font-size: 1em;
537
+ }
538
+
539
+ .ux-markdown ul,
540
+ .ux-markdown ol {
541
+ padding-left: 1.5em;
542
+ }
543
+
544
+ .ux-markdown ul {
545
+ list-style: disc;
546
+ }
547
+
548
+ .ux-markdown ol {
549
+ list-style: decimal;
550
+ }
551
+
552
+ .ux-markdown li {
553
+ margin: 0.25em 0;
554
+ }
555
+
556
+ .ux-markdown li > ul,
557
+ .ux-markdown li > ol {
558
+ margin: 0.25em 0;
559
+ }
560
+
561
+ /* GFM task lists: the marker column disappears and the checkbox takes its place. */
562
+ .ux-markdown ul.contains-task-list {
563
+ padding-left: 0.25em;
564
+ list-style: none;
565
+ }
566
+
567
+ .ux-markdown li.task-list-item {
568
+ list-style: none;
569
+ }
570
+
571
+ .ux-markdown li.task-list-item > input[type="checkbox"] {
572
+ margin: 0 0.4em 0 0;
573
+ vertical-align: 0.05em;
574
+ }
575
+
576
+ .ux-markdown blockquote {
577
+ padding-left: 1em;
578
+ color: var(--ux-muted-foreground);
579
+ border-left: 2px solid var(--ux-border);
580
+ }
581
+
582
+ .ux-markdown blockquote > :first-child {
583
+ margin-top: 0;
584
+ }
585
+
586
+ .ux-markdown blockquote > :last-child {
587
+ margin-bottom: 0;
588
+ }
589
+
590
+ .ux-markdown hr {
591
+ margin: 2em 0;
592
+ border: 0;
593
+ border-top: 1px solid var(--ux-border);
594
+ }
595
+
596
+ .ux-markdown a {
597
+ color: var(--ux-primary);
598
+ text-decoration: underline;
599
+ text-underline-offset: 0.2em;
600
+ }
601
+
602
+ .ux-markdown a:hover {
603
+ text-decoration-thickness: 2px;
604
+ }
605
+
606
+ /* Sized in `em` so the marker tracks the text it follows instead of sitting at a fixed 12px. */
607
+ .ux-markdown .ux-markdown__external-link {
608
+ display: inline-block;
609
+ width: 0.75em;
610
+ height: 0.75em;
611
+ margin-left: 0.2em;
612
+ vertical-align: -0.05em;
613
+ color: var(--ux-muted-foreground);
614
+ }
615
+
616
+ .ux-markdown code {
617
+ padding: 0.15em 0.35em;
618
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
619
+ font-size: 0.875em;
620
+ background: var(--ux-muted);
621
+ border-radius: 0.25rem;
622
+ }
623
+
624
+ .ux-markdown pre {
625
+ padding: 0.875em 1em;
626
+ overflow-x: auto;
627
+ overscroll-behavior-x: contain;
628
+ line-height: 1.55;
629
+ background: var(--ux-muted);
630
+ border-radius: 0.5rem;
631
+ }
632
+
633
+ .ux-markdown pre code {
634
+ padding: 0;
635
+ font-size: 0.8125em;
636
+ background: none;
637
+ border-radius: 0;
638
+ }
639
+
640
+ .ux-markdown .ux-markdown__table {
641
+ max-width: 100%;
642
+ overflow-x: auto;
643
+ overscroll-behavior-x: contain;
644
+ }
645
+
646
+ /* Wide tables scroll; short ones still fill the column rather than shrinking to their content. */
647
+ .ux-markdown table {
648
+ width: 100%;
649
+ min-width: max-content;
650
+ font-size: 0.9375em;
651
+ text-align: left;
652
+ border-collapse: collapse;
653
+ }
654
+
655
+ .ux-markdown th,
656
+ .ux-markdown td {
657
+ padding: 0.4em 0.75em;
658
+ vertical-align: top;
659
+ border-bottom: 1px solid var(--ux-border);
660
+ }
661
+
662
+ .ux-markdown th {
663
+ font-weight: 600;
664
+ }
665
+
666
+ .ux-markdown tr:last-child td {
667
+ border-bottom: 0;
668
+ }
669
+
670
+ .ux-markdown img {
671
+ max-width: 100%;
672
+ height: auto;
673
+ border-radius: 0.375rem;
674
+ }
675
+
676
+ .ux-markdown del {
677
+ color: var(--ux-muted-foreground);
678
+ }
679
+
680
+ .ux-markdown strong {
681
+ font-weight: 600;
682
+ }
683
+
684
+ /* Display math scrolls inside its own line instead of widening the surface around it. */
685
+ .ux-markdown .katex-display {
686
+ padding: 0.25em 0;
687
+ overflow-x: auto;
688
+ overflow-y: hidden;
689
+ }
690
+
691
+ .ux-markdown .katex-error {
692
+ color: var(--ux-destructive);
693
+ }
694
+
695
+ .ux-markdown .ux-markdown__mermaid {
696
+ overflow-x: auto;
697
+ overscroll-behavior-x: contain;
698
+ }
699
+
700
+ .ux-markdown .ux-markdown__mermaid svg {
701
+ display: block;
702
+ max-width: none;
703
+ height: auto;
704
+ margin: 0 auto;
705
+ }
package/dist/types.d.ts CHANGED
@@ -17,3 +17,6 @@ export type AppLayoutProps = {
17
17
  pageTitle?: ReactNode;
18
18
  children: ReactNode;
19
19
  };
20
+ export type MarkdownProps = {
21
+ content: string;
22
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zccz14/ux",
3
- "version": "0.3.0",
4
- "description": "Unified AppLayout shell for the NTNL app family: one animated icon sidebar, a fixed-size logo that doubles as the page favicon, and the Linkit-integrated AppHeader.",
3
+ "version": "0.5.0",
4
+ "description": "Unified app shell and Markdown renderer for the NTNL app family: one animated icon sidebar, a fixed-size logo that doubles as the page favicon, the Linkit-integrated AppHeader, and the single Markdown surface every app renders through.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "scripts": {
35
35
  "build": "vite build && tsc -p tsconfig.build.json && cp src/styles.css dist/styles.css",
36
- "test": "vitest run && node scripts/check-styles.mjs",
36
+ "test": "vitest run && node scripts/check-styles.mjs && node scripts/check-package.mjs",
37
37
  "typecheck": "tsc --noEmit",
38
38
  "pack:check": "npm pack --dry-run"
39
39
  },
@@ -44,10 +44,19 @@
44
44
  "react-dom": "^18.0.0 || ^19.0.0",
45
45
  "react-router-dom": "^7.0.0"
46
46
  },
47
+ "dependencies": {
48
+ "katex": "^0.16.47",
49
+ "mermaid": "^11.17.2",
50
+ "react-markdown": "^10.1.0",
51
+ "rehype-katex": "^7.0.1",
52
+ "remark-gfm": "^4.0.1",
53
+ "remark-math-extended": "^6.1.1"
54
+ },
47
55
  "devDependencies": {
48
56
  "@base-ui/react": "^1.6.0",
49
57
  "@testing-library/jest-dom": "^6.9.1",
50
58
  "@testing-library/react": "^16.3.0",
59
+ "@types/hast": "^3.0.5",
51
60
  "@types/react": "^19.1.16",
52
61
  "@types/react-dom": "^19.1.9",
53
62
  "auth-mini-react-components": "^0.6.10",