anentrypoint-design 0.0.322 → 0.0.324

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 (45) hide show
  1. package/README.md +22 -2
  2. package/app-shell.css +24 -4782
  3. package/colors_and_type.css +43 -0
  4. package/dist/247420.css +74 -3
  5. package/dist/247420.js +16 -16
  6. package/package.json +14 -8
  7. package/src/bootstrap.js +10 -1
  8. package/src/components/chat-message-parts.js +355 -0
  9. package/src/components/chat.js +23 -320
  10. package/src/components/data-density.js +15 -10
  11. package/src/components/files-modals.js +14 -2
  12. package/src/components/files.js +5 -1
  13. package/src/components/freddie/helpers.js +46 -8
  14. package/src/css/app-shell/base.css +157 -0
  15. package/src/css/app-shell/catalog-theme.css +144 -0
  16. package/src/css/app-shell/chat-basic.css +12 -0
  17. package/src/css/app-shell/chat-polish.css +459 -0
  18. package/src/css/app-shell/data-density.css +150 -0
  19. package/src/css/app-shell/files.css +709 -0
  20. package/src/css/app-shell/hero-content.css +407 -0
  21. package/src/css/app-shell/kits-appended.css +361 -0
  22. package/src/css/app-shell/loading-alerts.css +120 -0
  23. package/src/css/app-shell/panel-row.css +177 -0
  24. package/src/css/app-shell/primitives.css +197 -0
  25. package/src/css/app-shell/responsive.css +266 -0
  26. package/src/css/app-shell/responsive2-workspace.css +465 -0
  27. package/src/css/app-shell/row-print.css +75 -0
  28. package/src/css/app-shell/sidebar-misc.css +79 -0
  29. package/src/css/app-shell/states-interactions.css +696 -0
  30. package/src/css/app-shell/topbar.css +319 -0
  31. package/src/deck-stage.js +6 -4
  32. package/src/highlight.js +20 -4
  33. package/src/index.js +15 -5
  34. package/src/kits/os/freddie/pages-chat.js +1 -1
  35. package/src/kits/os/freddie/pages-core.js +1 -1
  36. package/src/kits/os/freddie/pages-os.js +1 -1
  37. package/src/kits/os/freddie/pages-tools.js +1 -1
  38. package/src/kits/os/wm.js +38 -0
  39. package/src/kits/slides/deck-stage-overlay.js +68 -0
  40. package/src/kits/slides/deck-stage-state.js +81 -0
  41. package/src/kits/slides/deck-stage-style.js +117 -0
  42. package/src/kits/slides/deck-stage.js +167 -0
  43. package/src/kits/slides/index.js +5 -0
  44. package/src/markdown.js +89 -11
  45. package/src/kits/os/freddie/helpers.js +0 -33
@@ -0,0 +1,81 @@
1
+ export const STORAGE_PREFIX = 'deck-stage:slide:';
2
+ const VALIDATE_ATTR = 'no_overflowing_text,no_overlapping_text,slide_sized_text';
3
+ const pad2 = (n) => String(n).padStart(2, '0');
4
+
5
+ export function loadNotes(deckEl) {
6
+ const tag = document.getElementById('speaker-notes');
7
+ if (!tag) return [];
8
+ try {
9
+ const parsed = JSON.parse(tag.textContent || '[]');
10
+ return Array.isArray(parsed) ? parsed : [];
11
+ } catch (e) {
12
+ console.warn('[deck-stage] Failed to parse #speaker-notes JSON:', e);
13
+ return [];
14
+ }
15
+ }
16
+
17
+ export function restoreIndex(deckEl, slides) {
18
+ const storageKey = STORAGE_PREFIX + (location.pathname || '/');
19
+ try {
20
+ const raw = localStorage.getItem(storageKey);
21
+ if (raw == null) return 0;
22
+ const n = parseInt(raw, 10);
23
+ if (Number.isFinite(n) && n >= 0 && n < slides.length) return n;
24
+ } catch (e) { /* ignore */ }
25
+ return 0;
26
+ }
27
+
28
+ export function persistIndex(deckEl, index) {
29
+ const storageKey = STORAGE_PREFIX + (location.pathname || '/');
30
+ try { localStorage.setItem(storageKey, String(index)); } catch (e) {}
31
+ }
32
+
33
+ export function collectSlides(deckEl, slot) {
34
+ const assigned = slot.assignedElements({ flatten: true });
35
+ const slides = assigned.filter((el) => {
36
+ const tag = el.tagName;
37
+ return tag !== 'TEMPLATE' && tag !== 'SCRIPT' && tag !== 'STYLE';
38
+ });
39
+ slides.forEach((slide, i) => {
40
+ const n = i + 1;
41
+ let label = slide.getAttribute('data-label');
42
+ if (!label) {
43
+ const existing = slide.getAttribute('data-screen-label');
44
+ if (existing) label = existing.replace(/^\s*\d+\s*/, '').trim() || existing;
45
+ }
46
+ if (!label) {
47
+ const heading = slide.querySelector('h1, h2, h3, [data-title]');
48
+ if (heading) label = (heading.textContent || '').trim().slice(0, 40);
49
+ }
50
+ if (!label) label = 'Slide';
51
+ slide.setAttribute('data-screen-label', `${pad2(n)} ${label}`);
52
+ if (!slide.hasAttribute('data-om-validate')) slide.setAttribute('data-om-validate', VALIDATE_ATTR);
53
+ slide.setAttribute('data-deck-slide', String(i));
54
+ });
55
+ return slides;
56
+ }
57
+
58
+ export function applyIndex(deckEl, { index, prevIndex, slides, countEl, showOverlay = true, broadcast = true, reason = 'init', flashFn = null } = {}) {
59
+ if (!slides.length) return;
60
+ const prev = prevIndex == null ? -1 : prevIndex;
61
+ const curr = index;
62
+ slides.forEach((s, i) => {
63
+ if (i === curr) s.setAttribute('data-deck-active', '');
64
+ else s.removeAttribute('data-deck-active');
65
+ });
66
+ if (countEl) countEl.textContent = String(curr + 1);
67
+ persistIndex(deckEl, curr);
68
+ if (broadcast) {
69
+ try { window.postMessage({ slideIndexChanged: curr }, '*'); } catch (e) {}
70
+ deckEl.dispatchEvent(new CustomEvent('slidechange', {
71
+ detail: {
72
+ index: curr, previousIndex: prev, total: slides.length,
73
+ slide: slides[curr] || null,
74
+ previousSlide: prev >= 0 ? (slides[prev] || null) : null,
75
+ reason
76
+ },
77
+ bubbles: true, composed: true
78
+ }));
79
+ }
80
+ if (showOverlay && flashFn) flashFn();
81
+ }
@@ -0,0 +1,117 @@
1
+ export const STYLESHEET = `
2
+ :host {
3
+ position: fixed;
4
+ inset: 0;
5
+ display: block;
6
+ background: var(--panel-0, #000);
7
+ color: var(--panel-text, #fff);
8
+ font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif;
9
+ overflow: hidden;
10
+ }
11
+ .stage { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
12
+ .canvas {
13
+ position: relative;
14
+ transform-origin: center center;
15
+ flex-shrink: 0;
16
+ background: var(--panel-1, #fff);
17
+ will-change: transform;
18
+ }
19
+ ::slotted(*) {
20
+ position: absolute !important;
21
+ inset: 0 !important;
22
+ width: 100% !important;
23
+ height: 100% !important;
24
+ box-sizing: border-box !important;
25
+ overflow: hidden;
26
+ opacity: 0;
27
+ pointer-events: none;
28
+ visibility: hidden;
29
+ }
30
+ ::slotted([data-deck-active]) { opacity: 1; pointer-events: auto; visibility: visible; }
31
+ .tapzones {
32
+ position: fixed; inset: 0; display: flex; z-index: 2147482000; pointer-events: none;
33
+ }
34
+ .tapzone { flex: 1; pointer-events: auto; -webkit-tap-highlight-color: transparent; }
35
+ @media (hover: hover) and (pointer: fine) { .tapzones { display: none; } }
36
+ .overlay {
37
+ position: fixed;
38
+ left: 50%; bottom: 22px;
39
+ transform: translate(-50%, 6px) scale(0.92);
40
+ filter: blur(6px);
41
+ display: flex; align-items: center;
42
+ gap: 4px; padding: 4px;
43
+ background: var(--scrim-media, rgba(19,19,24,0.85)); color: var(--on-color, #fff);
44
+ border-radius: 999px;
45
+ font-size: 14px;
46
+ font-feature-settings: "tnum" 1;
47
+ letter-spacing: 0.01em;
48
+ opacity: 0; pointer-events: none;
49
+ transition: opacity 260ms ease, transform 260ms cubic-bezier(.2,.8,.2,1), filter 260ms ease;
50
+ transform-origin: center bottom;
51
+ z-index: 2147483000;
52
+ user-select: none;
53
+ }
54
+ .overlay[data-visible] {
55
+ opacity: 1; pointer-events: auto;
56
+ transform: translate(-50%, 0) scale(1);
57
+ filter: blur(0);
58
+ }
59
+ .btn {
60
+ appearance: none; -webkit-appearance: none;
61
+ background: transparent; border: 0; margin: 0; padding: 0;
62
+ color: inherit; font: inherit; cursor: default;
63
+ display: inline-flex; align-items: center; justify-content: center;
64
+ height: 28px; min-width: 28px;
65
+ border-radius: 999px;
66
+ color: color-mix(in oklab, var(--on-color, #fff) 72%, transparent);
67
+ transition: background 140ms ease, color 140ms ease;
68
+ -webkit-tap-highlight-color: transparent;
69
+ }
70
+ .btn:hover { background: color-mix(in oklab, var(--on-color, #fff) 12%, transparent); color: var(--on-color, #fff); }
71
+ .btn:active { background: color-mix(in oklab, var(--on-color, #fff) 18%, transparent); }
72
+ .btn:focus, .btn:focus-visible { outline: none; }
73
+ .btn::-moz-focus-inner { border: 0; }
74
+ .btn svg { width: 14px; height: 14px; display: block; }
75
+ .btn.reset {
76
+ font-size: 13px; font-weight: 500; letter-spacing: 0.02em;
77
+ padding: 0 10px 0 12px; gap: 6px;
78
+ color: color-mix(in oklab, var(--on-color, #fff) 72%, transparent);
79
+ }
80
+ .btn.reset .kbd {
81
+ display: inline-flex; align-items: center; justify-content: center;
82
+ min-width: 16px; height: 16px; padding: 0 4px;
83
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
84
+ font-size: 12px; line-height: 1;
85
+ color: color-mix(in oklab, var(--on-color, #fff) 88%, transparent);
86
+ background: color-mix(in oklab, var(--on-color, #fff) 12%, transparent);
87
+ border-radius: 4px;
88
+ }
89
+ .count {
90
+ font-variant-numeric: tabular-nums;
91
+ color: var(--on-color, #fff); font-weight: 500;
92
+ padding: 0 8px; min-width: 42px;
93
+ text-align: center; font-size: 14px;
94
+ }
95
+ .count .sep { color: color-mix(in oklab, var(--on-color, #fff) 45%, transparent); margin: 0 3px; font-weight: 400; }
96
+ .count .total { color: color-mix(in oklab, var(--on-color, #fff) 55%, transparent); }
97
+ .divider { width: 1px; height: 14px; background: color-mix(in oklab, var(--on-color, #fff) 18%, transparent); margin: 0 2px; }
98
+ @media print {
99
+ :host { position: static; inset: auto; background: none; overflow: visible; color: inherit; }
100
+ .stage { position: static; display: block; }
101
+ .canvas { transform: none !important; width: auto !important; height: auto !important; background: none; will-change: auto; }
102
+ ::slotted(*) {
103
+ position: relative !important;
104
+ inset: auto !important;
105
+ width: var(--deck-design-w) !important;
106
+ height: var(--deck-design-h) !important;
107
+ box-sizing: border-box !important;
108
+ opacity: 1 !important;
109
+ visibility: visible !important;
110
+ pointer-events: auto;
111
+ break-after: page; page-break-after: always; break-inside: avoid;
112
+ overflow: hidden;
113
+ }
114
+ ::slotted(*:last-child) { break-after: auto; page-break-after: auto; }
115
+ .overlay, .tapzones { display: none !important; }
116
+ }
117
+ `;
@@ -0,0 +1,167 @@
1
+ import { STYLESHEET } from './deck-stage-style.js';
2
+ import { buildOverlay, buildTapzones, handleDeckKey } from './deck-stage-overlay.js';
3
+ import { loadNotes, restoreIndex, persistIndex, collectSlides, applyIndex, STORAGE_PREFIX } from './deck-stage-state.js';
4
+
5
+ const DESIGN_W_DEFAULT = 1920;
6
+ const DESIGN_H_DEFAULT = 1080;
7
+ const OVERLAY_HIDE_MS = 1800;
8
+
9
+ class DeckStage extends HTMLElement {
10
+ static get observedAttributes() { return ['width', 'height', 'noscale', 'fill']; }
11
+
12
+ constructor() {
13
+ super();
14
+ this._root = this.attachShadow({ mode: 'open' });
15
+ this._index = 0;
16
+ this._slides = [];
17
+ this._notes = [];
18
+ this._hideTimer = null;
19
+ this._mouseIdleTimer = null;
20
+ this._storageKey = STORAGE_PREFIX + (location.pathname || '/');
21
+ this._onKey = this._onKey.bind(this);
22
+ this._onResize = this._onResize.bind(this);
23
+ this._onSlotChange = this._onSlotChange.bind(this);
24
+ this._onMouseMove = this._onMouseMove.bind(this);
25
+ }
26
+
27
+ get designWidth() { return parseInt(this.getAttribute('width'), 10) || DESIGN_W_DEFAULT; }
28
+ get designHeight() { return parseInt(this.getAttribute('height'), 10) || DESIGN_H_DEFAULT; }
29
+
30
+ connectedCallback() {
31
+ this._render();
32
+ this._notes = loadNotes(this);
33
+ this._syncPrintPageRule();
34
+ window.addEventListener('keydown', this._onKey);
35
+ window.addEventListener('resize', this._onResize);
36
+ window.addEventListener('mousemove', this._onMouseMove, { passive: true });
37
+ }
38
+
39
+ disconnectedCallback() {
40
+ window.removeEventListener('keydown', this._onKey);
41
+ window.removeEventListener('resize', this._onResize);
42
+ window.removeEventListener('mousemove', this._onMouseMove);
43
+ if (this._hideTimer) clearTimeout(this._hideTimer);
44
+ if (this._mouseIdleTimer) clearTimeout(this._mouseIdleTimer);
45
+ }
46
+
47
+ attributeChangedCallback() {
48
+ if (!this._canvas) return;
49
+ this._canvas.style.width = this.designWidth + 'px';
50
+ this._canvas.style.height = this.designHeight + 'px';
51
+ this._canvas.style.setProperty('--deck-design-w', this.designWidth + 'px');
52
+ this._canvas.style.setProperty('--deck-design-h', this.designHeight + 'px');
53
+ this._fit();
54
+ this._syncPrintPageRule();
55
+ }
56
+
57
+ _render() {
58
+ const style = document.createElement('style');
59
+ style.textContent = STYLESHEET;
60
+ const stage = document.createElement('div');
61
+ stage.className = 'stage';
62
+ const canvas = document.createElement('div');
63
+ canvas.className = 'canvas';
64
+ canvas.style.width = this.designWidth + 'px';
65
+ canvas.style.height = this.designHeight + 'px';
66
+ canvas.style.setProperty('--deck-design-w', this.designWidth + 'px');
67
+ canvas.style.setProperty('--deck-design-h', this.designHeight + 'px');
68
+ const slot = document.createElement('slot');
69
+ slot.addEventListener('slotchange', this._onSlotChange);
70
+ canvas.appendChild(slot);
71
+ stage.appendChild(canvas);
72
+
73
+ const tapzones = buildTapzones({
74
+ onBack: (e) => { e.preventDefault(); this._go(this._index - 1, 'tap'); },
75
+ onForward: (e) => { e.preventDefault(); this._go(this._index + 1, 'tap'); }
76
+ });
77
+ const overlay = buildOverlay({
78
+ onPrev: () => this._go(this._index - 1, 'click'),
79
+ onNext: () => this._go(this._index + 1, 'click'),
80
+ onReset: () => this._go(0, 'click')
81
+ });
82
+
83
+ this._root.append(style, stage, tapzones, overlay);
84
+ this._canvas = canvas;
85
+ this._slot = slot;
86
+ this._overlay = overlay;
87
+ this._countEl = overlay.querySelector('.current');
88
+ this._totalEl = overlay.querySelector('.total');
89
+ }
90
+
91
+ _syncPrintPageRule() {
92
+ const id = 'deck-stage-print-page';
93
+ let tag = document.getElementById(id);
94
+ if (!tag) {
95
+ tag = document.createElement('style');
96
+ tag.id = id;
97
+ document.head.appendChild(tag);
98
+ }
99
+ tag.textContent =
100
+ '@page { size: ' + this.designWidth + 'px ' + this.designHeight + 'px; margin: 0; } ' +
101
+ '@media print { html, body { margin: 0 !important; padding: 0 !important; background: none !important; overflow: visible !important; height: auto !important; } ' +
102
+ '* { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }';
103
+ }
104
+
105
+ _onSlotChange() {
106
+ this._slides = collectSlides(this, this._slot);
107
+ this._index = restoreIndex(this, this._slides);
108
+ applyIndex(this, {
109
+ index: this._index, prevIndex: this._prevIndex, slides: this._slides,
110
+ countEl: this._countEl, showOverlay: false, broadcast: true, reason: 'init',
111
+ flashFn: () => this._flashOverlay()
112
+ });
113
+ this._fit();
114
+ }
115
+
116
+
117
+ _flashOverlay() {
118
+ if (!this._overlay) return;
119
+ this._overlay.setAttribute('data-visible', '');
120
+ if (this._hideTimer) clearTimeout(this._hideTimer);
121
+ this._hideTimer = setTimeout(() => this._overlay.removeAttribute('data-visible'), OVERLAY_HIDE_MS);
122
+ }
123
+
124
+ _fit() {
125
+ if (!this._canvas) return;
126
+ if (this.hasAttribute('noscale')) { this._canvas.style.transform = 'none'; return; }
127
+ const wRatio = window.innerWidth / this.designWidth;
128
+ const hRatio = window.innerHeight / this.designHeight;
129
+ // Default "contain" (Math.min) always shows the full design canvas,
130
+ // letterboxing on a mismatched aspect ratio. Opt-in fill="cover"
131
+ // (Math.max) instead fills the viewport edge-to-edge for wide
132
+ // presentation displays, cropping the canvas to the stage's
133
+ // existing `overflow:hidden` — never changes designWidth/designHeight,
134
+ // so the @page print-size sync in _syncPrintPageRule is unaffected.
135
+ const s = this.getAttribute('fill') === 'cover' ? Math.max(wRatio, hRatio) : Math.min(wRatio, hRatio);
136
+ this._canvas.style.transform = `scale(${s})`;
137
+ }
138
+
139
+ _onResize() { this._fit(); }
140
+ _onMouseMove() { this._flashOverlay(); }
141
+ _onKey(e) { if (handleDeckKey(this, e)) { e.preventDefault(); this._flashOverlay(); } }
142
+
143
+ _go(i, reason = 'api') {
144
+ if (!this._slides.length) return;
145
+ const clamped = Math.max(0, Math.min(this._slides.length - 1, i));
146
+ if (clamped === this._index) { this._flashOverlay(); return; }
147
+ this._index = clamped;
148
+ applyIndex(this, {
149
+ index: this._index, prevIndex: this._prevIndex, slides: this._slides,
150
+ countEl: this._countEl, showOverlay: true, broadcast: true, reason,
151
+ flashFn: () => this._flashOverlay()
152
+ });
153
+ }
154
+
155
+ get index() { return this._index; }
156
+ get length() { return this._slides.length; }
157
+ goTo(i) { this._go(i, 'api'); }
158
+ next() { this._go(this._index + 1, 'api'); }
159
+ prev() { this._go(this._index - 1, 'api'); }
160
+ reset() { this._go(0, 'api'); }
161
+ }
162
+
163
+ if (typeof customElements !== 'undefined' && !customElements.get('deck-stage')) {
164
+ customElements.define('deck-stage', DeckStage);
165
+ }
166
+
167
+ export { DeckStage };
@@ -0,0 +1,5 @@
1
+ // slides kit — presentation-deck web component (title/content/quote/
2
+ // comparison/receipt/end template pattern). Side-effect import registers the
3
+ // <deck-stage> custom element; DeckStage itself is also exported for direct
4
+ // use/typing by a consumer that wants the class reference.
5
+ export { DeckStage } from './deck-stage.js';
package/src/markdown.js CHANGED
@@ -1,6 +1,10 @@
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.
4
8
 
5
9
  import { escapeHtml } from './html-escape.js';
6
10
 
@@ -15,14 +19,42 @@ const RETRY_BACKOFF_MS = 30000;
15
19
  // Pin to exact semver so the CDN cannot silently swap code under us.
16
20
  // SRI cannot be applied to dynamic ESM imports in browsers (no importmap
17
21
  // integrity support at design time); pinning the version is the best available
18
- // mitigation for CDN-supply-chain risk on these two dependencies.
19
- const MARKED_URL = 'https://cdn.jsdelivr.net/npm/marked@15.0.12/+esm';
20
- const PURIFY_URL = 'https://cdn.jsdelivr.net/npm/dompurify@3.2.6/+esm';
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
+ }
21
53
 
22
54
  // True while the markdown stack is unavailable (escaped-fallback rendering).
23
55
  // Consumers (markdown-cache) use this to avoid caching degraded output.
24
56
  export function isDegraded() {
25
- return !_marked || !_purify;
57
+ return !_marked || !_purify || typeof _purify.sanitize !== 'function';
26
58
  }
27
59
 
28
60
  export async function ensureReady() {
@@ -30,14 +62,24 @@ export async function ensureReady() {
30
62
  if (_failedAt && Date.now() - _failedAt < RETRY_BACKOFF_MS) return false;
31
63
  _ready = (async () => {
32
64
  try {
33
- 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()');
34
72
  _marked = marked;
35
- _purify = DOMPurifyMod.default || DOMPurifyMod;
73
+ _purify = purify;
36
74
  _failedAt = 0;
37
75
  return true;
38
76
  } catch (err) {
39
77
  console.warn('[247420] markdown loader failed:', err);
40
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;
41
83
  _ready = null;
42
84
  _failedAt = Date.now();
43
85
  return false;
@@ -46,6 +88,13 @@ export async function ensureReady() {
46
88
  return _ready;
47
89
  }
48
90
 
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>');
96
+ }
97
+
49
98
  // The single HTML-entity escape for the whole SDK now lives in html-escape.js
50
99
  // (full set incl. quotes, so it is safe in attribute contexts too).
51
100
  // Re-exported here for backward compatibility with existing importers of
@@ -54,9 +103,28 @@ export { escapeHtml };
54
103
 
55
104
  export async function renderMarkdown(src) {
56
105
  const ok = await ensureReady();
57
- if (!ok) return escapeHtml(src).replace(/\n/g, '<br>');
58
- const raw = _marked.parse(String(src));
59
- return _purify.sanitize(raw, { FORCE_BODY: true });
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
+ }
60
128
  }
61
129
 
62
130
  // Sanitize already-rendered HTML before it touches innerHTML. For any surface
@@ -66,5 +134,15 @@ export async function renderMarkdown(src) {
66
134
  export async function sanitizeHtml(html) {
67
135
  const ok = await ensureReady();
68
136
  if (!ok) return escapeHtml(html);
69
- return _purify.sanitize(String(html), { FORCE_BODY: true });
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
+ }
70
148
  }
@@ -1,33 +0,0 @@
1
- import * as webjsx from '../../../../vendor/webjsx/index.js';
2
- const h = webjsx.createElement;
3
-
4
- export function pre(obj) {
5
- return h('pre', { class: 'fd-pre' }, typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2));
6
- }
7
-
8
- export function form(opts) {
9
- const { fields = [], submit = 'submit', onSubmit } = opts;
10
- return h('form', { class: 'row-form', onsubmit: (ev) => { ev.preventDefault(); onSubmit && onSubmit(ev); } },
11
- ...fields.map(f => f.kind === 'textarea'
12
- ? h('textarea', { name: f.name, placeholder: f.placeholder || '', rows: f.rows || 4 })
13
- : h('input', { name: f.name, type: f.type || 'text', placeholder: f.placeholder || '', value: f.value || '', required: f.required ? 'true' : null })),
14
- h('button', { type: 'submit', class: 'btn-primary' }, submit));
15
- }
16
-
17
- export function getRecentPaths() {
18
- try { return JSON.parse(localStorage.getItem('fd_recent_cwds') || '[]'); } catch { return []; }
19
- }
20
-
21
- export function saveRecentPath(p) {
22
- if (!p) return;
23
- try {
24
- const prev = getRecentPaths().filter(x => x !== p);
25
- localStorage.setItem('fd_recent_cwds', JSON.stringify([p, ...prev].slice(0, 5)));
26
- } catch {}
27
- }
28
-
29
- export function skillLabel(s) {
30
- if (s.shortName) return s.shortName;
31
- const n = s.name || '';
32
- return n.replace(/^gm:/, '').replace(/^software-development$/, 'software dev').replace(/-/g, ' ');
33
- }