anentrypoint-design 0.0.413 → 0.0.415

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.
Files changed (103) hide show
  1. package/dist/247420.js +63 -13
  2. package/dist/app-shell.css +40 -594
  3. package/dist/colors_and_type.css +607 -47
  4. package/dist/index.html +227 -277
  5. package/dist/preview/buttons.html +39 -28
  6. package/dist/preview/colors-core.html +18 -8
  7. package/dist/preview/colors-lore.html +26 -17
  8. package/dist/preview/colors-semantic.html +24 -18
  9. package/dist/preview/dateline.html +6 -5
  10. package/dist/preview/dropzone.html +4 -3
  11. package/dist/preview/file-grid.html +10 -9
  12. package/dist/preview/file-row.html +14 -13
  13. package/dist/preview/file-toolbar.html +4 -3
  14. package/dist/preview/file-viewer.html +4 -3
  15. package/dist/preview/header.html +26 -24
  16. package/dist/preview/icons-unicode.html +8 -5
  17. package/dist/preview/index-row.html +17 -9
  18. package/dist/preview/inputs.html +4 -3
  19. package/dist/preview/manifesto.html +34 -17
  20. package/dist/preview/motion-default.js +77 -57
  21. package/dist/preview/rules.html +4 -3
  22. package/dist/preview/spacing.html +13 -12
  23. package/dist/preview/stamps-lore.html +5 -4
  24. package/dist/preview/stamps.html +4 -3
  25. package/dist/preview/theme-ink.html +7 -6
  26. package/dist/preview/type-display.html +14 -5
  27. package/dist/preview/type-mono.html +6 -5
  28. package/dist/preview/type-prose.html +12 -10
  29. package/dist/preview/type-scale.html +4 -3
  30. package/dist/preview/wordmarks.html +10 -7
  31. package/dist/site/content/globals/site.yaml +5 -3
  32. package/dist/site/content/pages/freddie.yaml +2 -2
  33. package/dist/site/content/pages/home.yaml +55 -55
  34. package/dist/site/theme.mjs +96 -348
  35. package/dist/slides/index.html +13 -13
  36. package/dist/src/bootstrap.js +10 -1
  37. package/dist/src/components/chat.js +43 -195
  38. package/dist/src/components/community.js +18 -166
  39. package/dist/src/components/content.js +30 -210
  40. package/dist/src/components/files-modals.js +14 -106
  41. package/dist/src/components/files.js +15 -116
  42. package/dist/src/components/freddie/helpers.js +53 -8
  43. package/dist/src/components/freddie.js +35 -25
  44. package/dist/src/components/shell.js +16 -114
  45. package/dist/src/components/theme-toggle.js +69 -25
  46. package/dist/src/components.js +92 -13
  47. package/dist/src/deck-stage.js +6 -4
  48. package/dist/src/highlight.js +51 -7
  49. package/dist/src/index.js +58 -12
  50. package/dist/src/kits/os/about-app.js +2 -3
  51. package/dist/src/kits/os/app-panes.css +46 -70
  52. package/dist/src/kits/os/browser-app.js +28 -1
  53. package/dist/src/kits/os/files-app.js +7 -0
  54. package/dist/src/kits/os/freddie/pages-chat.js +90 -75
  55. package/dist/src/kits/os/freddie/pages-core.js +9 -9
  56. package/dist/src/kits/os/freddie/pages-os.js +8 -8
  57. package/dist/src/kits/os/freddie/pages-tools.js +14 -14
  58. package/dist/src/kits/os/freddie/routes.js +21 -19
  59. package/dist/src/kits/os/freddie-dashboard.css +42 -21
  60. package/dist/src/kits/os/freddie-dashboard.js +6 -6
  61. package/dist/src/kits/os/icons.js +38 -16
  62. package/dist/src/kits/os/index.js +11 -0
  63. package/dist/src/kits/os/launcher.css +26 -10
  64. package/dist/src/kits/os/launcher.js +3 -0
  65. package/dist/src/kits/os/shell.js +72 -108
  66. package/dist/src/kits/os/terminal-app.js +1 -1
  67. package/dist/src/kits/os/theme.css +1330 -150
  68. package/dist/src/kits/os/wm.css +68 -15
  69. package/dist/src/kits/os/wm.js +93 -17
  70. package/dist/src/markdown.js +121 -12
  71. package/dist/src/motion.js +18 -2
  72. package/dist/src/page-html.js +62 -134
  73. package/dist/src/theme.js +106 -13
  74. package/dist/src/web-components/ds-chat.js +80 -9
  75. package/dist/ui_kits/aicat/app.js +54 -6
  76. package/dist/ui_kits/aicat/index.html +2 -0
  77. package/dist/ui_kits/blog/index.html +33 -17
  78. package/dist/ui_kits/chat/app.js +90 -22
  79. package/dist/ui_kits/chat/index.html +2 -0
  80. package/dist/ui_kits/community/app.js +86 -24
  81. package/dist/ui_kits/community/index.html +1 -0
  82. package/dist/ui_kits/dashboard/app.js +123 -34
  83. package/dist/ui_kits/dashboard/index.html +1 -0
  84. package/dist/ui_kits/docs/index.html +43 -33
  85. package/dist/ui_kits/error_404/app.js +78 -23
  86. package/dist/ui_kits/file_browser/README.md +1 -1
  87. package/dist/ui_kits/file_browser/app.js +45 -10
  88. package/dist/ui_kits/file_browser/index.html +1 -0
  89. package/dist/ui_kits/gallery/app.js +130 -51
  90. package/dist/ui_kits/homepage/app.js +156 -138
  91. package/dist/ui_kits/project_page/app.js +142 -125
  92. package/dist/ui_kits/project_page/index.html +2 -1
  93. package/dist/ui_kits/search/app.js +52 -15
  94. package/dist/ui_kits/settings/app.js +166 -39
  95. package/dist/ui_kits/signin/app.js +175 -49
  96. package/dist/ui_kits/signin/index.html +1 -0
  97. package/dist/ui_kits/slide_deck/app.js +83 -49
  98. package/dist/ui_kits/system_primer/app.js +57 -40
  99. package/dist/ui_kits/terminal/app.js +160 -43
  100. package/package.json +1 -1
  101. package/src/page-html/client-script.js +40 -11
  102. package/src/page-html/page-styles.js +22 -1
  103. package/src/page-html.js +10 -0
@@ -8,28 +8,40 @@
8
8
  position: fixed;
9
9
  inset: 0;
10
10
  pointer-events: none;
11
- z-index: 9000;
11
+ z-index: var(--z-window);
12
12
  }
13
13
 
14
14
  .wm-win {
15
15
  position: absolute;
16
16
  min-width: 200px;
17
17
  min-height: 120px;
18
+ /* Never exceed the .wm-root desktop area — a window sized on a large
19
+ monitor must stay recoverable when the viewport shrinks. */
20
+ max-width: 100%;
21
+ max-height: 100%;
18
22
  background: var(--os-bg-1);
19
23
  color: var(--os-fg);
20
24
  border: none;
21
- border-radius: var(--r-3, 18px);
25
+ border-radius: var(--os-radius, var(--r-2, 10px));
22
26
  display: flex;
23
27
  flex-direction: column;
24
28
  pointer-events: auto;
25
29
  overflow: hidden;
26
30
  }
27
31
  .wm-win.wm-focused { box-shadow: inset 4px 0 0 var(--os-accent); }
32
+ /* Promote to a compositor layer only for the duration of a drag/resize so the
33
+ per-frame left/top/width/height writes don't force a main-thread layout each
34
+ pointermove. Released on pointerup (class removed) to avoid the permanent
35
+ will-change memory/perf anti-pattern. */
36
+ .wm-win.wm-dragging { will-change: left, top; }
37
+ .wm-win.wm-resizing { will-change: width, height; }
28
38
 
29
39
  .wm-bar {
30
40
  display: flex;
31
41
  align-items: center;
32
- padding: 6px 10px;
42
+ min-height: 36px;
43
+ box-sizing: border-box;
44
+ padding: 8px 12px;
33
45
  background: var(--os-bg-2);
34
46
  cursor: move;
35
47
  user-select: none;
@@ -39,14 +51,16 @@
39
51
  }
40
52
  .wm-title {
41
53
  flex: 1;
54
+ min-width: 0;
42
55
  font: 12px var(--os-font);
56
+ color: var(--os-fg-2);
43
57
  white-space: nowrap;
44
58
  overflow: hidden;
45
59
  text-overflow: ellipsis;
46
60
  text-transform: lowercase;
47
61
  }
48
62
 
49
- .wm-btns { display: flex; gap: 4px; }
63
+ .wm-btns { display: flex; gap: 6px; }
50
64
  .wm-btn {
51
65
  width: 22px;
52
66
  height: 22px;
@@ -60,10 +74,11 @@
60
74
  display: inline-flex;
61
75
  align-items: center;
62
76
  justify-content: center;
63
- border-radius: var(--r-1, 6px);
77
+ border-radius: var(--os-radius-sm, var(--r-1, 6px));
64
78
  transition: background 100ms ease, color 100ms ease;
79
+ touch-action: manipulation;
65
80
  }
66
- .wm-btn:hover { background: var(--panel-hover, var(--os-bg-2)); color: var(--os-fg); }
81
+ .wm-btn:hover { background: var(--panel-hover, var(--os-bg-1)); color: var(--os-fg); }
67
82
 
68
83
  .wm-body {
69
84
  flex: 1;
@@ -76,20 +91,45 @@
76
91
  position: absolute;
77
92
  right: 0;
78
93
  bottom: 0;
79
- width: 14px;
80
- height: 14px;
94
+ width: 16px;
95
+ height: 16px;
96
+ padding: 2px;
97
+ box-sizing: border-box;
81
98
  cursor: nwse-resize;
82
99
  background: transparent;
83
100
  color: var(--os-fg-3);
84
101
  opacity: 0.4;
85
- font: 10px var(--os-mono);
86
- line-height: 1;
87
- display: flex;
88
- align-items: flex-end;
89
- justify-content: flex-end;
90
102
  touch-action: none;
91
103
  }
92
- .wm-resize::after { content: '\25E2'; }
104
+ .wm-resize:hover { opacity: 0.7; }
105
+ /* Resize grip drawn with CSS (two diagonal hairlines) instead of a decorative
106
+ * unicode content glyph; keeps the affordance, drops the glyph tell. */
107
+ .wm-resize::after {
108
+ content: '';
109
+ position: absolute;
110
+ right: 3px;
111
+ bottom: 3px;
112
+ width: 9px;
113
+ height: 9px;
114
+ background:
115
+ linear-gradient(135deg, transparent 0 45%, currentColor 45% 55%, transparent 55% 100%),
116
+ linear-gradient(135deg, transparent 0 70%, currentColor 70% 80%, transparent 80% 100%);
117
+ }
118
+
119
+ /* Edge + corner resize hit-zones. Invisible (no glyph) — they only widen the
120
+ * grab area beyond the SE .wm-resize grip. `width`/`height` are the grab
121
+ * thickness; corners overlap edges and win via later source order + size. */
122
+ .wm-edge { position: absolute; touch-action: none; z-index: var(--z-raised); }
123
+ .wm-edge[data-dir='n'] { top: -3px; left: 8px; right: 8px; height: 8px; cursor: ns-resize; }
124
+ .wm-edge[data-dir='s'] { bottom: -3px; left: 8px; right: 8px; height: 8px; cursor: ns-resize; }
125
+ .wm-edge[data-dir='e'] { right: -3px; top: 8px; bottom: 8px; width: 8px; cursor: ew-resize; }
126
+ .wm-edge[data-dir='w'] { left: -3px; top: 8px; bottom: 8px; width: 8px; cursor: ew-resize; }
127
+ .wm-edge[data-dir='ne'] { top: -3px; right: -3px; width: 14px; height: 14px; cursor: nesw-resize; }
128
+ .wm-edge[data-dir='nw'] { top: -3px; left: -3px; width: 14px; height: 14px; cursor: nwse-resize; }
129
+ .wm-edge[data-dir='sw'] { bottom: -3px; left: -3px; width: 14px; height: 14px; cursor: nesw-resize; }
130
+
131
+ .wm-win.wm-min .wm-edge,
132
+ .wm-win.wm-max .wm-edge { display: none; }
93
133
 
94
134
  .wm-win.wm-min .wm-body,
95
135
  .wm-win.wm-min .wm-resize { display: none; }
@@ -102,7 +142,19 @@
102
142
  border-radius: 0;
103
143
  }
104
144
 
105
- @media (max-width: 767px) {
145
+ /* Touch: lift resize hit-zones and window buttons toward the 44px floor
146
+ (wm.css must be self-sufficient; theme.css only covers .ds-247420). */
147
+ @media (pointer: coarse) {
148
+ .wm-edge[data-dir='n'], .wm-edge[data-dir='s'] { height: 20px; }
149
+ .wm-edge[data-dir='e'], .wm-edge[data-dir='w'] { width: 20px; }
150
+ .wm-edge[data-dir='ne'], .wm-edge[data-dir='nw'], .wm-edge[data-dir='sw'] { width: 32px; height: 32px; }
151
+ .wm-resize { width: 32px; height: 32px; }
152
+ .wm-btn { min-width: 44px; min-height: 44px; }
153
+ }
154
+
155
+ /* Full-screen windows on phones AND short landscape phones — a width-only
156
+ query misses 812x375 landscape, which cannot host floating chrome. */
157
+ @media (max-width: 767px), ((max-height: 500px) and (orientation: landscape) and (pointer: coarse)) {
106
158
  .wm-win {
107
159
  left: 0 !important;
108
160
  top: 0 !important;
@@ -112,4 +164,5 @@
112
164
  border: none;
113
165
  }
114
166
  .wm-resize { display: none; }
167
+ .wm-edge { display: none; }
115
168
  }
@@ -20,14 +20,40 @@ export function renderWindow(opts = {}) {
20
20
  callbacks = {},
21
21
  } = opts;
22
22
 
23
+ // Keep at least MIN px of the window horizontally inside the container and
24
+ // the titlebar (BAR px) vertically reachable, so a window can always be
25
+ // grabbed by pointer (persisted bounds from a larger viewport included).
26
+ const MIN_VISIBLE = 60;
27
+ const BAR_H = 36;
28
+ function clampBounds(b, p) {
29
+ const pw = p ? p.clientWidth : window.innerWidth;
30
+ const ph = p ? p.clientHeight : window.innerHeight;
31
+ const out = { ...b };
32
+ if (typeof out.w === 'number') out.w = Math.min(out.w, pw);
33
+ if (typeof out.h === 'number') out.h = Math.min(out.h, ph);
34
+ if (typeof out.x === 'number') {
35
+ const w = typeof out.w === 'number' ? out.w : MIN_VISIBLE;
36
+ out.x = Math.max(MIN_VISIBLE - w, Math.min(out.x, pw - MIN_VISIBLE));
37
+ }
38
+ if (typeof out.y === 'number') out.y = Math.max(0, Math.min(out.y, ph - BAR_H));
39
+ return out;
40
+ }
41
+
23
42
  const el = document.createElement('div');
24
43
  el.className = 'wm-win';
25
44
  el.dataset.kind = kind;
26
45
  if (instanceId) el.dataset.instanceId = instanceId;
27
- el.style.left = bounds.x + 'px';
28
- el.style.top = bounds.y + 'px';
29
- el.style.width = bounds.w + 'px';
30
- el.style.height = bounds.h + 'px';
46
+ // Floating window chrome is a dialog surface: role="dialog" (not
47
+ // aria-modal, since sibling windows stay operable — this is a
48
+ // non-modal multi-window desktop, not a blocking modal) + aria-label
49
+ // from the titlebar text so AT announces which window has focus.
50
+ el.setAttribute('role', 'dialog');
51
+ el.setAttribute('aria-label', title);
52
+ const b0 = clampBounds(bounds, null);
53
+ el.style.left = b0.x + 'px';
54
+ el.style.top = b0.y + 'px';
55
+ el.style.width = b0.w + 'px';
56
+ el.style.height = b0.h + 'px';
31
57
 
32
58
  const bar = document.createElement('div');
33
59
  bar.className = 'wm-bar';
@@ -46,27 +72,72 @@ export function renderWindow(opts = {}) {
46
72
  bodyEl.className = 'wm-body';
47
73
  setBodyContent(bodyEl, body);
48
74
 
49
- const resize = document.createElement('div');
50
- resize.className = 'wm-resize';
75
+ // Resize affordances: one grip per edge + corner. `data-dir` carries the
76
+ // direction (n/s/e/w/ne/nw/se/sw) to the consumer's resize math. The SE
77
+ // corner keeps the visible diagonal grip glyph (.wm-resize); the other
78
+ // seven are invisible hit-zones (.wm-edge) styled in wm.css.
79
+ // NOT KEYBOARD ACCESSIBLE: these grips only wire pointerdown (see below);
80
+ // resize math is owned entirely by the consumer's pointermove handler
81
+ // (module comment at top of file), so there is no keydown-driven delta to
82
+ // wire without reaching into consumer-owned drag state. role="separator"
83
+ // + aria-orientation give a screen reader a name for the affordance even
84
+ // though it cannot be operated without a pointer -- an honest partial
85
+ // label, not a claim of full keyboard support.
86
+ const ORIENT = { n: 'horizontal', s: 'horizontal', ne: 'horizontal', nw: 'horizontal', se: 'horizontal', sw: 'horizontal', e: 'vertical', w: 'vertical' };
87
+ const DIRS = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
88
+ const grips = DIRS.map(dir => {
89
+ const g = document.createElement('div');
90
+ g.className = dir === 'se' ? 'wm-resize' : 'wm-edge';
91
+ g.dataset.dir = dir;
92
+ g.setAttribute('role', 'separator');
93
+ g.setAttribute('aria-orientation', ORIENT[dir] || 'horizontal');
94
+ g.setAttribute('aria-label', 'resize ' + dir + ' (pointer only)');
95
+ return g;
96
+ });
51
97
 
52
- el.append(bar, bodyEl, resize);
98
+ el.append(bar, bodyEl, ...grips);
53
99
 
54
100
  minBtn.addEventListener('click', e => { e.stopPropagation(); callbacks.onMinimize && callbacks.onMinimize(); });
55
101
  maxBtn.addEventListener('click', e => { e.stopPropagation(); callbacks.onMaximize && callbacks.onMaximize(); });
56
102
  closeBtn.addEventListener('click', e => { e.stopPropagation(); callbacks.onClose && callbacks.onClose(); });
57
103
 
58
- el.addEventListener('pointerdown', () => callbacks.onFocus && callbacks.onFocus());
104
+ const focus = () => callbacks.onFocus && callbacks.onFocus();
105
+
106
+ el.addEventListener('pointerdown', () => focus());
107
+
108
+ // Basic focus trap: while this window carries .wm-focused, Tab/Shift+Tab
109
+ // cycles only within its own focusable set instead of escaping to a
110
+ // sibling window or the page behind it. Scoped to keydown on `el` itself
111
+ // (additive listener, no DOM structure change) and gated on the class the
112
+ // consumer already toggles via setFocused/applyFocused below, so an
113
+ // unfocused window is completely untouched by this handler.
114
+ el.addEventListener('keydown', e => {
115
+ if (e.key !== 'Tab' || !el.classList.contains('wm-focused')) return;
116
+ const focusable = el.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
117
+ if (!focusable.length) return;
118
+ const first = focusable[0];
119
+ const last = focusable[focusable.length - 1];
120
+ if (e.shiftKey && document.activeElement === first) {
121
+ e.preventDefault();
122
+ last.focus();
123
+ } else if (!e.shiftKey && document.activeElement === last) {
124
+ e.preventDefault();
125
+ first.focus();
126
+ }
127
+ });
59
128
 
60
129
  bar.addEventListener('pointerdown', e => {
61
130
  if (e.target.closest('.wm-btn')) return;
62
- callbacks.onFocus && callbacks.onFocus();
131
+ e.stopPropagation();
132
+ focus();
63
133
  if (callbacks.onDragStart) callbacks.onDragStart(e, { x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight });
64
134
  });
65
135
 
66
- resize.addEventListener('pointerdown', e => {
67
- callbacks.onFocus && callbacks.onFocus();
68
- if (callbacks.onResizeStart) callbacks.onResizeStart(e, { x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight });
69
- });
136
+ grips.forEach(g => g.addEventListener('pointerdown', e => {
137
+ e.stopPropagation();
138
+ focus();
139
+ if (callbacks.onResizeStart) callbacks.onResizeStart(e, { x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight, dir: g.dataset.dir });
140
+ }));
70
141
 
71
142
  applyFocused(el, focused);
72
143
  applyMaximized(el, maximized);
@@ -77,10 +148,14 @@ export function renderWindow(opts = {}) {
77
148
  setTitle(t) { titleEl.textContent = t; },
78
149
  setBody(b) { setBodyContent(bodyEl, b); },
79
150
  setBounds(b) {
80
- if (typeof b.x === 'number') el.style.left = b.x + 'px';
81
- if (typeof b.y === 'number') el.style.top = b.y + 'px';
82
- if (typeof b.w === 'number') el.style.width = b.w + 'px';
83
- if (typeof b.h === 'number') el.style.height = b.h + 'px';
151
+ const c = clampBounds({
152
+ ...b,
153
+ w: typeof b.w === 'number' ? b.w : el.offsetWidth,
154
+ }, el.offsetParent);
155
+ if (typeof c.x === 'number') el.style.left = c.x + 'px';
156
+ if (typeof c.y === 'number') el.style.top = c.y + 'px';
157
+ if (typeof b.w === 'number') el.style.width = c.w + 'px';
158
+ if (typeof b.h === 'number') el.style.height = c.h + 'px';
84
159
  },
85
160
  setFocused(v) { applyFocused(el, v); },
86
161
  setMaximized(v) { applyMaximized(el, v); },
@@ -97,6 +172,7 @@ function mkBtn(label, ttl) {
97
172
  b.className = 'wm-btn';
98
173
  b.textContent = label;
99
174
  b.title = ttl;
175
+ b.setAttribute('aria-label', ttl);
100
176
  return b;
101
177
  }
102
178
 
@@ -1,39 +1,148 @@
1
1
  // Markdown — lazy-loads marked + DOMPurify on first call. Stub-safe:
2
2
  // if loading fails, we fall back to a simple escape-and-linebreak pass so
3
- // the chat doesn't go blank.
3
+ // the chat doesn't go blank. FAIL-CLOSED: on any doubt about whether the
4
+ // sanitizer is actually active (load failure, parse/sanitize throw, or a
5
+ // purifier that doesn't look like a real DOMPurify instance), the render
6
+ // path returns escaped plaintext rather than ever risking raw HTML reaching
7
+ // innerHTML.
8
+
9
+ import { escapeHtml } from './html-escape.js';
4
10
 
5
11
  let _ready = null;
6
12
  let _marked = null;
7
13
  let _purify = null;
14
+ // A failed load is NOT cached forever: we drop _ready so a later render retries,
15
+ // guarded by a small backoff so an offline session doesn't hammer the CDN.
16
+ let _failedAt = 0;
17
+ const RETRY_BACKOFF_MS = 30000;
18
+
19
+ // Pin to exact semver so the CDN cannot silently swap code under us.
20
+ // SRI cannot be applied to dynamic ESM imports in browsers (no importmap
21
+ // integrity support at design time); pinning the version is the best available
22
+ // mitigation for CDN-supply-chain risk on these two dependencies. Both are
23
+ // overridable (see configureMarkdownCdn below) for a consumer that wants to
24
+ // self-host, mirror-pin, or route through a CSP-allowlisted proxy; the
25
+ // defaults below are byte-for-byte unchanged from before, so a zero-config
26
+ // consumer's behavior is untouched.
27
+ const DEFAULT_MARKED_URL = 'https://cdn.jsdelivr.net/npm/marked@15.0.12/+esm';
28
+ const DEFAULT_PURIFY_URL = 'https://cdn.jsdelivr.net/npm/dompurify@3.2.6/+esm';
29
+
30
+ let _markedUrl = DEFAULT_MARKED_URL;
31
+ let _purifyUrl = DEFAULT_PURIFY_URL;
32
+
33
+ // Optional override for where marked/DOMPurify are fetched from. Additive:
34
+ // call before the first render() to take effect (ensureReady() reads these
35
+ // module-level vars lazily on first invocation only, same as before). Every
36
+ // existing consumer that never calls this keeps hitting the pinned jsDelivr
37
+ // URLs above, byte-for-byte. Passing null/undefined for a key resets that
38
+ // one URL back to its default without touching the other.
39
+ export function configureMarkdownCdn({ markedUrl, purifyUrl } = {}) {
40
+ if (markedUrl !== undefined) _markedUrl = markedUrl || DEFAULT_MARKED_URL;
41
+ if (purifyUrl !== undefined) _purifyUrl = purifyUrl || DEFAULT_PURIFY_URL;
42
+ // Force a fresh load on the next render so a runtime override (e.g. a
43
+ // consumer switching to a self-hosted mirror after boot) actually takes.
44
+ _ready = null;
45
+ _failedAt = 0;
46
+ }
47
+
48
+ // Read-only introspection of the URLs actually in effect (defaults or
49
+ // override) — useful for a consumer's own SRI/CSP audit tooling.
50
+ export function getMarkdownCdnConfig() {
51
+ return { markedUrl: _markedUrl, purifyUrl: _purifyUrl };
52
+ }
8
53
 
9
- const MARKED_URL = 'https://cdn.jsdelivr.net/npm/marked@15/+esm';
10
- const PURIFY_URL = 'https://cdn.jsdelivr.net/npm/dompurify@3/+esm';
54
+ // True while the markdown stack is unavailable (escaped-fallback rendering).
55
+ // Consumers (markdown-cache) use this to avoid caching degraded output.
56
+ export function isDegraded() {
57
+ return !_marked || !_purify || typeof _purify.sanitize !== 'function';
58
+ }
11
59
 
12
60
  export async function ensureReady() {
13
61
  if (_ready) return _ready;
62
+ if (_failedAt && Date.now() - _failedAt < RETRY_BACKOFF_MS) return false;
14
63
  _ready = (async () => {
15
64
  try {
16
- const [{ marked }, DOMPurifyMod] = await Promise.all([import(MARKED_URL), import(PURIFY_URL)]);
65
+ const [{ marked }, DOMPurifyMod] = await Promise.all([import(_markedUrl), import(_purifyUrl)]);
66
+ const purify = DOMPurifyMod.default || DOMPurifyMod;
67
+ // Fail closed if either module didn't resolve to something usable —
68
+ // a CDN that 200s with an empty/HTML error-page body can satisfy the
69
+ // dynamic import yet hand back a shape with no .parse/.sanitize.
70
+ if (!marked || typeof marked.parse !== 'function') throw new Error('marked module missing parse()');
71
+ if (!purify || typeof purify.sanitize !== 'function') throw new Error('DOMPurify module missing sanitize()');
17
72
  _marked = marked;
18
- _purify = DOMPurifyMod.default || DOMPurifyMod;
73
+ _purify = purify;
74
+ _failedAt = 0;
19
75
  return true;
20
76
  } catch (err) {
21
77
  console.warn('[247420] markdown loader failed:', err);
78
+ // Reset the cached promise so a later render retries (after backoff).
79
+ // Also drop any partial module refs so isDegraded()/renderMarkdown
80
+ // never treat a half-initialized state as ready.
81
+ _marked = null;
82
+ _purify = null;
83
+ _ready = null;
84
+ _failedAt = Date.now();
22
85
  return false;
23
86
  }
24
87
  })();
25
88
  return _ready;
26
89
  }
27
90
 
28
- function escapeHtml(s) {
29
- return String(s).replace(/[&<>"']/g, (c) => ({
30
- '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
31
- })[c]);
91
+ // Fail-closed plaintext fallback, always wrapped in the same safe container
92
+ // shape the real sanitized output would use (a plain block the caller can
93
+ // innerHTML directly) never bare unwrapped text relying on caller discipline.
94
+ function escapedFallback(src) {
95
+ return escapeHtml(src).replace(/\n/g, '<br>');
32
96
  }
33
97
 
98
+ // The single HTML-entity escape for the whole SDK now lives in html-escape.js
99
+ // (full set incl. quotes, so it is safe in attribute contexts too).
100
+ // Re-exported here for backward compatibility with existing importers of
101
+ // escapeHtml from this module. page-html.js re-exports this as `escape`.
102
+ export { escapeHtml };
103
+
34
104
  export async function renderMarkdown(src) {
35
105
  const ok = await ensureReady();
36
- if (!ok) return escapeHtml(src).replace(/\n/g, '<br>');
37
- const raw = _marked.parse(String(src));
38
- return _purify.sanitize(raw);
106
+ if (!ok) return escapedFallback(src);
107
+ // Fail-closed around the parse+sanitize call itself too, not just the
108
+ // loader: a CDN module that resolved but throws mid-parse (a malformed
109
+ // remote payload, a marked/DOMPurify version mismatch) must never let a
110
+ // partially-produced `raw` string reach the caller unsanitized, and must
111
+ // never propagate an unhandled rejection that a caller might swallow into
112
+ // a raw-HTML fallback of their own.
113
+ try {
114
+ const raw = _marked.parse(String(src));
115
+ if (typeof _purify.sanitize !== 'function') throw new Error('purifier unavailable mid-render');
116
+ return _purify.sanitize(raw, { FORCE_BODY: true });
117
+ } catch (err) {
118
+ console.warn('[247420] markdown render failed, falling back to escaped text:', err);
119
+ // Treat this exactly like a load failure: drop refs and start the
120
+ // backoff so the next call retries a fresh load rather than re-hitting
121
+ // whatever made this one throw.
122
+ _marked = null;
123
+ _purify = null;
124
+ _ready = null;
125
+ _failedAt = Date.now();
126
+ return escapedFallback(src);
127
+ }
128
+ }
129
+
130
+ // Sanitize already-rendered HTML before it touches innerHTML. For any surface
131
+ // that injects host/user-authored HTML (e.g. a wiki page body), this is the
132
+ // single XSS gate — DOMPurify strips scripts/handlers. If the purifier hasn't
133
+ // loaded, we safe-fail by escaping (raw tags show as text, never execute).
134
+ export async function sanitizeHtml(html) {
135
+ const ok = await ensureReady();
136
+ if (!ok) return escapeHtml(html);
137
+ try {
138
+ if (typeof _purify.sanitize !== 'function') throw new Error('purifier unavailable mid-sanitize');
139
+ return _purify.sanitize(String(html), { FORCE_BODY: true });
140
+ } catch (err) {
141
+ console.warn('[247420] sanitizeHtml failed, falling back to escaped text:', err);
142
+ _marked = null;
143
+ _purify = null;
144
+ _ready = null;
145
+ _failedAt = Date.now();
146
+ return escapeHtml(html);
147
+ }
39
148
  }
@@ -11,12 +11,28 @@ export function installMotion() {
11
11
  style.textContent = `
12
12
  @media (prefers-reduced-motion: no-preference) {
13
13
  .ds-247420 [data-anim="in"] {
14
- opacity: 0; transform: translateY(8px);
15
- transition: opacity 320ms cubic-bezier(.2,0,0,1), transform 320ms cubic-bezier(.2,0,0,1);
14
+ opacity: 0; transform: translateY(14px);
15
+ /* Physical signature reveal — a slight spring landing, not a flat fade. */
16
+ transition: opacity var(--dur-reveal, 560ms) var(--ease, cubic-bezier(.2,0,0,1)),
17
+ transform var(--dur-reveal, 560ms) var(--ease-spring, cubic-bezier(0.34,1.56,0.64,1));
16
18
  }
17
19
  .ds-247420 [data-anim="ready"] {
18
20
  opacity: 1; transform: translateY(0);
19
21
  }
22
+ }
23
+ /* [data-motion="reduced"] is the in-app user override (motion-toggle.js) —
24
+ applies the exact same reduced-motion treatment as the OS-level
25
+ prefers-reduced-motion media query above, independent of the OS setting.
26
+ Selector applies regardless of the media query's own match state, so it
27
+ correctly overrides the animated block above on any OS. */
28
+ :root[data-motion="reduced"] .ds-247420 [data-anim="in"],
29
+ .ds-247420[data-motion="reduced"] [data-anim="in"] {
30
+ opacity: 1 !important; transform: translateY(0) !important;
31
+ transition: none !important;
32
+ }
33
+ :root[data-motion="reduced"] .ds-247420 [data-anim="ready"],
34
+ .ds-247420[data-motion="reduced"] [data-anim="ready"] {
35
+ transition: none !important;
20
36
  }`.trim();
21
37
  document.head.appendChild(style);
22
38
  }