@zccz14/ux 0.3.0 → 0.4.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,45 @@ 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$`, `$$…$$`, `\(…\)`, `\[…\]` and `math`-tagged code fences, because models emit all of them. KaTeX's stylesheet and fonts are imported by the component itself, so a formula is never unstyled. Broken math degrades to one `.katex-error` span carrying the parser message; math that is still streaming — no closing delimiter yet — stays source instead of flickering.
121
+ - **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.
122
+ - **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.
123
+ - **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.
124
+ - **Raw HTML** — never rendered. The pipeline has no `rehype-raw`, so HTML in the source is shown as text.
125
+
126
+ ### Typography is owned here
127
+
128
+ 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.
129
+
130
+ ### Why the pipeline is a runtime dependency
131
+
132
+ `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 pipeline away, with one deliberate exception: `katex/dist/katex.min.css` is a module-level side effect of the renderer (about 3.6 KB gzipped, plus fonts the browser fetches only when a formula appears). `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.
133
+
134
+ ## Migrating an app from its local Markdown renderer
135
+
136
+ 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.
137
+ 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.
138
+ 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.
139
+ 4. Keep the KaTeX stylesheet import out of the app: the component owns it.
140
+
105
141
  ## Styling
106
142
 
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`.
143
+ 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
144
 
109
145
  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
146
 
@@ -121,9 +157,9 @@ Theme state itself is not the shell's business: whatever toggles `.dark` on `<ht
121
157
  ## Development
122
158
 
123
159
  ```sh
124
- npm install
125
- npm run typecheck
126
- npm test
127
- npm run build
128
- npm run pack:check
160
+ pnpm install
161
+ pnpm run typecheck
162
+ pnpm test
163
+ pnpm run build
164
+ pnpm run pack:check
129
165
  ```
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,65 +1,70 @@
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";
1
+ import { jsxs as u, jsx as n, Fragment as L } from "react/jsx-runtime";
2
+ import { useEffect as v, useRef as f, useState as g, useCallback as y, useSyncExternalStore as C } from "react";
3
3
  import { Dialog as h } from "@base-ui/react/dialog";
4
- import { Tooltip as m } from "@base-ui/react/tooltip";
4
+ import { Tooltip as p } from "@base-ui/react/tooltip";
5
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 = {
6
+ import { useLocation as w, NavLink as z } from "react-router-dom";
7
+ import T from "react-markdown";
8
+ import P from "rehype-katex";
9
+ import F from "remark-gfm";
10
+ import I from "remark-math-extended";
11
+ import "katex/dist/katex.min.css";
12
+ const $ = {
8
13
  closeNavigation: "Close navigation",
9
14
  navigation: "Navigation",
10
15
  toggleSidebar: "Toggle sidebar"
11
- }, T = {
16
+ }, A = {
12
17
  closeNavigation: "关闭导航",
13
18
  navigation: "导航",
14
19
  toggleSidebar: "切换侧边栏"
15
20
  };
16
- function A(e) {
17
- return e.toLowerCase().startsWith("zh") ? T : P;
21
+ function W(e) {
22
+ return e.toLowerCase().startsWith("zh") ? A : $;
18
23
  }
19
- const $ = 'link[rel~="icon"]';
20
- function R(e) {
24
+ const R = 'link[rel~="icon"]';
25
+ function j(e) {
21
26
  const t = e.cloneNode(!0);
22
27
  return t.setAttribute("xmlns", "http://www.w3.org/2000/svg"), t.style.color = getComputedStyle(e).color, new XMLSerializer().serializeToString(t);
23
28
  }
24
- function W(e) {
29
+ function B(e) {
25
30
  if (e instanceof SVGSVGElement)
26
31
  return {
27
- href: `data:image/svg+xml,${encodeURIComponent(R(e))}`,
32
+ href: `data:image/svg+xml,${encodeURIComponent(j(e))}`,
28
33
  type: "image/svg+xml"
29
34
  };
30
- const { currentSrc: t, src: o } = e, i = t || o;
31
- return /\.svg([?#]|$)/i.test(i) ? { href: i, type: "image/svg+xml" } : { href: i };
35
+ const { currentSrc: t, src: a } = e, r = t || a;
36
+ return /\.svg([?#]|$)/i.test(r) ? { href: r, type: "image/svg+xml" } : { href: r };
32
37
  }
33
- function j() {
34
- const e = document.querySelector($);
38
+ function q() {
39
+ const e = document.querySelector(R);
35
40
  if (e)
36
41
  return e;
37
42
  const t = document.createElement("link");
38
43
  return t.rel = "icon", document.head.append(t), t;
39
44
  }
40
- function z(e, t) {
41
- k(() => {
45
+ function D(e, t) {
46
+ v(() => {
42
47
  if (!t)
43
48
  return;
44
- const o = () => {
45
- var l;
46
- const p = (l = e.current) == null ? void 0 : l.querySelector("svg, img");
47
- if (!p)
49
+ const a = () => {
50
+ var m;
51
+ const i = (m = e.current) == null ? void 0 : m.querySelector("svg, img");
52
+ if (!i)
48
53
  return;
49
- const { href: u, type: s } = W(p), c = j();
50
- s ? c.type = s : c.removeAttribute("type"), c.getAttribute("href") !== u && c.setAttribute("href", u);
54
+ const { href: d, type: s } = B(i), l = q();
55
+ s ? l.type = s : l.removeAttribute("type"), l.getAttribute("href") !== d && l.setAttribute("href", d);
51
56
  };
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);
57
+ a();
58
+ const r = new MutationObserver(a);
59
+ r.observe(document.documentElement, { attributes: !0, attributeFilter: ["class", "style"] }), e.current && r.observe(e.current, { childList: !0, subtree: !0 });
60
+ const o = window.matchMedia("(prefers-color-scheme: dark)");
61
+ return o.addEventListener("change", a), () => {
62
+ r.disconnect(), o.removeEventListener("change", a);
58
63
  };
59
64
  }, [t, e]);
60
65
  }
61
- function B() {
62
- return /* @__PURE__ */ d(
66
+ function K() {
67
+ return /* @__PURE__ */ u(
63
68
  "svg",
64
69
  {
65
70
  "aria-hidden": "true",
@@ -76,8 +81,8 @@ function B() {
76
81
  }
77
82
  );
78
83
  }
79
- function F() {
80
- return /* @__PURE__ */ d(
84
+ function V() {
85
+ return /* @__PURE__ */ u(
81
86
  "svg",
82
87
  {
83
88
  "aria-hidden": "true",
@@ -94,81 +99,101 @@ function F() {
94
99
  }
95
100
  );
96
101
  }
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;
102
+ function G() {
103
+ return /* @__PURE__ */ u(
104
+ "svg",
105
+ {
106
+ "aria-hidden": "true",
107
+ className: "ux-markdown__external-link",
108
+ fill: "none",
109
+ stroke: "currentColor",
110
+ strokeLinecap: "round",
111
+ strokeLinejoin: "round",
112
+ strokeWidth: "2",
113
+ viewBox: "0 0 24 24",
114
+ children: [
115
+ /* @__PURE__ */ n("path", { d: "M15 3h6v6" }),
116
+ /* @__PURE__ */ n("path", { d: "M10 14 21 3" }),
117
+ /* @__PURE__ */ n("path", { d: "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" })
118
+ ]
119
+ }
120
+ );
121
+ }
122
+ const b = "sidebar_state", H = 3600 * 24 * 7, _ = "(max-width: 767px)";
123
+ function U() {
124
+ const e = document.cookie.split("; ").find((t) => t.startsWith(`${b}=`));
125
+ return e ? e.slice(b.length + 1) !== "false" : !0;
101
126
  }
102
- function K(e) {
103
- document.cookie = `${f}=${e}; path=/; max-age=${q}`;
127
+ function Q(e) {
128
+ document.cookie = `${b}=${e}; path=/; max-age=${H}`;
104
129
  }
105
- function G(e) {
106
- const t = window.matchMedia(w);
130
+ function X(e) {
131
+ const t = window.matchMedia(_);
107
132
  return t.addEventListener("change", e), () => t.removeEventListener("change", e);
108
133
  }
109
- function V() {
134
+ function J() {
110
135
  return C(
111
- G,
112
- () => window.matchMedia(w).matches,
136
+ X,
137
+ () => window.matchMedia(_).matches,
113
138
  () => !1
114
139
  );
115
140
  }
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(() => {
141
+ function Y() {
142
+ const e = J(), [t, a] = g(U), [r, o] = g(!1), i = y((s) => {
143
+ a(s), Q(s);
144
+ }, []), d = y(() => {
120
145
  if (e) {
121
- r((s) => !s);
146
+ o((s) => !s);
122
147
  return;
123
148
  }
124
- p(!t);
125
- }, [e, t, p]);
126
- return k(() => {
127
- function s(c) {
128
- (c.metaKey || c.ctrlKey) && c.key === "b" && (c.preventDefault(), u());
149
+ i(!t);
150
+ }, [e, t, i]);
151
+ return v(() => {
152
+ function s(l) {
153
+ (l.metaKey || l.ctrlKey) && l.key === "b" && (l.preventDefault(), d());
129
154
  }
130
155
  return window.addEventListener("keydown", s), () => window.removeEventListener("keydown", s);
131
- }, [u]), { isMobile: e, open: t, openMobile: i, setOpenMobile: r, toggleSidebar: u };
156
+ }, [d]), { isMobile: e, open: t, openMobile: r, setOpenMobile: o, toggleSidebar: d };
132
157
  }
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;
158
+ function M(e, t) {
159
+ let a;
160
+ for (const r of e)
161
+ for (const o of r.items)
162
+ (o.to === "/" ? t === "/" : t === o.to || t.startsWith(`${o.to}/`)) && (!a || o.to.length > a.to.length) && (a = o);
163
+ return a;
139
164
  }
140
- function U({
165
+ function Z({
141
166
  collapsed: e,
142
167
  end: t,
143
- item: o,
144
- onNavigate: i
168
+ item: a,
169
+ onNavigate: r
145
170
  }) {
146
- return /* @__PURE__ */ d(m.Root, { children: [
147
- /* @__PURE__ */ d(
148
- m.Trigger,
171
+ return /* @__PURE__ */ u(p.Root, { children: [
172
+ /* @__PURE__ */ u(
173
+ p.Trigger,
149
174
  {
150
175
  render: /* @__PURE__ */ n(
151
- I,
176
+ z,
152
177
  {
153
178
  className: "ux-nav-item",
154
179
  end: t,
155
- onClick: i,
156
- to: o.to
180
+ onClick: r,
181
+ to: a.to
157
182
  }
158
183
  ),
159
184
  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 })
185
+ /* @__PURE__ */ n("span", { className: "ux-nav-item__icon", children: a.icon }),
186
+ /* @__PURE__ */ n("span", { className: "ux-nav-item__text", children: a.label })
162
187
  ]
163
188
  }
164
189
  ),
165
- /* @__PURE__ */ n(m.Portal, { children: /* @__PURE__ */ n(
166
- m.Positioner,
190
+ /* @__PURE__ */ n(p.Portal, { children: /* @__PURE__ */ n(
191
+ p.Positioner,
167
192
  {
168
193
  className: "ux-tooltip-positioner",
169
194
  side: "right",
170
195
  sideOffset: 6,
171
- children: /* @__PURE__ */ n(m.Popup, { className: "ux-tooltip", hidden: !e, children: o.label })
196
+ children: /* @__PURE__ */ n(p.Popup, { className: "ux-tooltip", hidden: !e, children: a.label })
172
197
  }
173
198
  ) })
174
199
  ] });
@@ -176,116 +201,116 @@ function U({
176
201
  function N({
177
202
  copy: e,
178
203
  logo: t,
179
- logoRef: o,
180
- nav: i,
181
- onNavigate: r,
182
- state: p,
183
- title: u
204
+ logoRef: a,
205
+ nav: r,
206
+ onNavigate: o,
207
+ state: i,
208
+ title: d
184
209
  }) {
185
- const { pathname: s } = y(), c = _(i, s);
186
- return /* @__PURE__ */ d(L, { children: [
187
- /* @__PURE__ */ d("div", { className: "ux-sidebar__header", children: [
188
- /* @__PURE__ */ n("span", { className: "ux-sidebar__logo", ref: o, children: t }),
189
- /* @__PURE__ */ n("span", { className: "ux-sidebar__title", children: u })
210
+ const { pathname: s } = w(), l = M(r, s);
211
+ return /* @__PURE__ */ u(L, { children: [
212
+ /* @__PURE__ */ u("div", { className: "ux-sidebar__header", children: [
213
+ /* @__PURE__ */ n("span", { className: "ux-sidebar__logo", ref: a, children: t }),
214
+ /* @__PURE__ */ n("span", { className: "ux-sidebar__title", children: d })
190
215
  ] }),
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) => (
216
+ /* @__PURE__ */ n("nav", { "aria-label": e.navigation, className: "ux-sidebar__nav", children: r.map((m) => /* @__PURE__ */ u("div", { className: "ux-nav-group", children: [
217
+ /* @__PURE__ */ n("div", { className: "ux-nav-group__label", children: m.label }),
218
+ m.items.map((c) => (
194
219
  // NavLink would mark a parent section current on its children's
195
220
  // routes; follow matchActiveItem instead so only the deepest
196
221
  // match (or the exact path) stays marked.
197
222
  /* @__PURE__ */ n(
198
- U,
223
+ Z,
199
224
  {
200
- collapsed: p === "collapsed",
201
- end: a !== c || a.to === s,
202
- item: a,
203
- onNavigate: r
225
+ collapsed: i === "collapsed",
226
+ end: c !== l || c.to === s,
227
+ item: c,
228
+ onNavigate: o
204
229
  },
205
- a.to
230
+ c.to
206
231
  )
207
232
  ))
208
- ] }, l.label)) })
233
+ ] }, m.label)) })
209
234
  ] });
210
235
  }
211
- function te({
236
+ function _e({
212
237
  children: e,
213
238
  favicon: t = !0,
214
- headerSlot: o,
215
- logo: i,
216
- nav: r,
217
- pageTitle: p,
218
- title: u
239
+ headerSlot: a,
240
+ logo: r,
241
+ nav: o,
242
+ pageTitle: i,
243
+ title: d
219
244
  }) {
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(
245
+ var k;
246
+ const { pathname: s } = w(), { lang: l } = E(), m = W(l), c = Y(), x = f(null), S = i ?? ((k = M(o, s)) == null ? void 0 : k.label);
247
+ return D(x, t), /* @__PURE__ */ n(p.Provider, { delay: 0, children: /* @__PURE__ */ u(
223
248
  "div",
224
249
  {
225
250
  className: "ux-app-layout",
226
- "data-state": a.open ? "expanded" : "collapsed",
251
+ "data-state": c.open ? "expanded" : "collapsed",
227
252
  children: [
228
253
  /* @__PURE__ */ n("aside", { className: "ux-sidebar", children: /* @__PURE__ */ n(
229
254
  N,
230
255
  {
231
- copy: l,
232
- logo: i,
233
- logoRef: b,
234
- nav: r,
235
- state: a.open ? "expanded" : "collapsed",
236
- title: u
256
+ copy: m,
257
+ logo: r,
258
+ logoRef: x,
259
+ nav: o,
260
+ state: c.open ? "expanded" : "collapsed",
261
+ title: d
237
262
  }
238
263
  ) }),
239
- a.isMobile ? /* @__PURE__ */ n(
264
+ c.isMobile ? /* @__PURE__ */ n(
240
265
  h.Root,
241
266
  {
242
- open: a.openMobile,
243
- onOpenChange: a.setOpenMobile,
244
- children: /* @__PURE__ */ d(h.Portal, { children: [
267
+ open: c.openMobile,
268
+ onOpenChange: c.setOpenMobile,
269
+ children: /* @__PURE__ */ u(h.Portal, { children: [
245
270
  /* @__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 }),
271
+ /* @__PURE__ */ u(h.Popup, { className: "ux-drawer", children: [
272
+ /* @__PURE__ */ n(h.Title, { className: "ux-visually-hidden", children: m.navigation }),
248
273
  /* @__PURE__ */ n(
249
274
  "button",
250
275
  {
251
- "aria-label": l.closeNavigation,
276
+ "aria-label": m.closeNavigation,
252
277
  className: "ux-drawer__close",
253
278
  type: "button",
254
- onClick: () => a.setOpenMobile(!1),
255
- children: /* @__PURE__ */ n(B, {})
279
+ onClick: () => c.setOpenMobile(!1),
280
+ children: /* @__PURE__ */ n(K, {})
256
281
  }
257
282
  ),
258
283
  /* @__PURE__ */ n(
259
284
  N,
260
285
  {
261
- copy: l,
262
- logo: i,
263
- nav: r,
264
- onNavigate: () => a.setOpenMobile(!1),
286
+ copy: m,
287
+ logo: r,
288
+ nav: o,
289
+ onNavigate: () => c.setOpenMobile(!1),
265
290
  state: "expanded",
266
- title: u
291
+ title: d
267
292
  }
268
293
  )
269
294
  ] })
270
295
  ] })
271
296
  }
272
297
  ) : null,
273
- /* @__PURE__ */ d("div", { className: "ux-shell", children: [
274
- /* @__PURE__ */ d("header", { className: "ux-app-header", children: [
298
+ /* @__PURE__ */ u("div", { className: "ux-shell", children: [
299
+ /* @__PURE__ */ u("header", { className: "ux-app-header", children: [
275
300
  /* @__PURE__ */ n(
276
301
  "button",
277
302
  {
278
- "aria-expanded": a.isMobile ? a.openMobile : a.open,
279
- "aria-label": l.toggleSidebar,
303
+ "aria-expanded": c.isMobile ? c.openMobile : c.open,
304
+ "aria-label": m.toggleSidebar,
280
305
  className: "ux-app-header__trigger",
281
306
  type: "button",
282
- onClick: a.toggleSidebar,
283
- children: /* @__PURE__ */ n(F, {})
307
+ onClick: c.toggleSidebar,
308
+ children: /* @__PURE__ */ n(V, {})
284
309
  }
285
310
  ),
286
311
  /* @__PURE__ */ n("span", { "aria-hidden": "true", className: "ux-app-header__separator" }),
287
312
  /* @__PURE__ */ n("p", { className: "ux-app-header__title", children: S }),
288
- /* @__PURE__ */ n("div", { className: "ux-app-header__slot", children: o }),
313
+ /* @__PURE__ */ n("div", { className: "ux-app-header__slot", children: a }),
289
314
  /* @__PURE__ */ n(O, {})
290
315
  ] }),
291
316
  /* @__PURE__ */ n("main", { className: "ux-main", children: e })
@@ -294,6 +319,116 @@ function te({
294
319
  }
295
320
  ) });
296
321
  }
322
+ let ee = 0;
323
+ function te() {
324
+ return document.documentElement.classList.contains("dark") ? "dark" : "neutral";
325
+ }
326
+ function ne() {
327
+ const e = getComputedStyle(document.documentElement).fontFamily.trim();
328
+ return e === "" ? "sans-serif" : e;
329
+ }
330
+ function re(e) {
331
+ const t = e ? Number.parseFloat(getComputedStyle(e).fontSize) : Number.NaN;
332
+ return !Number.isFinite(t) || t <= 0 ? 16 : Math.min(Math.max(t, 10), 20);
333
+ }
334
+ async function ae(e, t) {
335
+ try {
336
+ const { default: a } = await import("mermaid");
337
+ a.initialize({
338
+ startOnLoad: !1,
339
+ securityLevel: "strict",
340
+ theme: te(),
341
+ themeVariables: { fontFamily: ne(), fontSize: `${re(t)}px` }
342
+ });
343
+ const { svg: r } = await a.render(`ux-mermaid-${++ee}`, e);
344
+ return r;
345
+ } catch {
346
+ return null;
347
+ }
348
+ }
349
+ function oe({ source: e }) {
350
+ const [t, a] = g(null), r = f(null), o = f(0);
351
+ return v(() => {
352
+ const i = async () => {
353
+ const s = ++o.current, l = await ae(e, r.current);
354
+ o.current === s && a(l);
355
+ };
356
+ i();
357
+ const d = new MutationObserver(() => void i());
358
+ return d.observe(document.documentElement, { attributes: !0, attributeFilter: ["class"] }), () => {
359
+ o.current += 1, d.disconnect();
360
+ };
361
+ }, [e]), t === null ? /* @__PURE__ */ n(
362
+ "pre",
363
+ {
364
+ className: "ux-markdown__mermaid-source",
365
+ "data-mermaid": "source",
366
+ ref: (i) => {
367
+ r.current = i;
368
+ },
369
+ children: /* @__PURE__ */ n("code", { className: "language-mermaid", children: e })
370
+ }
371
+ ) : /* @__PURE__ */ n(
372
+ "div",
373
+ {
374
+ "aria-label": "Mermaid diagram",
375
+ className: "ux-markdown__mermaid",
376
+ "data-mermaid": "diagram",
377
+ dangerouslySetInnerHTML: { __html: t },
378
+ ref: (i) => {
379
+ r.current = i;
380
+ },
381
+ role: "img"
382
+ }
383
+ );
384
+ }
385
+ function ie(e) {
386
+ const t = e == null ? void 0 : e.children[0];
387
+ if ((t == null ? void 0 : t.type) !== "element" || t.tagName !== "code")
388
+ return null;
389
+ const a = t.properties.className;
390
+ if (!Array.isArray(a) || !a.includes("language-mermaid"))
391
+ return null;
392
+ const r = t.children[0];
393
+ return (r == null ? void 0 : r.type) === "text" ? r.value : null;
394
+ }
395
+ const se = {
396
+ a: ({ children: e, href: t, node: a, ...r }) => t ? le(t) ? /* @__PURE__ */ u("a", { ...r, href: t, rel: "noopener noreferrer", target: "_blank", children: [
397
+ e,
398
+ /* @__PURE__ */ n(G, {})
399
+ ] }) : /* @__PURE__ */ n("a", { ...r, href: t, children: e }) : /* @__PURE__ */ n("span", { ...r, children: e }),
400
+ pre: ({ children: e, node: t, ...a }) => {
401
+ const r = ie(t);
402
+ return r === null ? /* @__PURE__ */ n("pre", { ...a, children: e }) : /* @__PURE__ */ n(oe, { source: r });
403
+ },
404
+ // A wide table scrolls on its own; the transcript or article around it never grows a horizontal
405
+ // scrollbar, so the columns past the fold stay reachable.
406
+ table: ({ children: e, node: t, ...a }) => /* @__PURE__ */ n("div", { className: "ux-markdown__table", children: /* @__PURE__ */ n("table", { ...a, children: e }) })
407
+ }, ce = /* @__PURE__ */ new Set(["http", "https", "mailto"]);
408
+ function le(e) {
409
+ return /^[a-z][a-z0-9+.-]*:/i.test(e) || e.startsWith("//");
410
+ }
411
+ function de(e) {
412
+ const t = e.trim(), a = t.indexOf(":");
413
+ if (a === -1)
414
+ return t;
415
+ const r = t.slice(0, a).toLowerCase();
416
+ return /^[a-z][a-z0-9+.-]*$/.test(r) ? ce.has(r) ? t : "" : t;
417
+ }
418
+ const ue = [F, I], me = [[P, { strict: "ignore", errorColor: "var(--ux-destructive)" }]];
419
+ function Me({ content: e }) {
420
+ return /* @__PURE__ */ n("div", { className: "ux-markdown", children: /* @__PURE__ */ n(
421
+ T,
422
+ {
423
+ components: se,
424
+ rehypePlugins: me,
425
+ remarkPlugins: ue,
426
+ urlTransform: de,
427
+ children: e
428
+ }
429
+ ) });
430
+ }
297
431
  export {
298
- te as AppLayout
432
+ _e as AppLayout,
433
+ Me as Markdown
299
434
  };
@@ -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.4.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",