@typecaast/capture 0.0.8 → 0.1.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/dist/index.cjs CHANGED
@@ -2,9 +2,7 @@
2
2
 
3
3
  var createDOMPurify = require('dompurify');
4
4
  var zod = require('zod');
5
- var react = require('react');
6
- var reactDom = require('react-dom');
7
- var jsxRuntime = require('react/jsx-runtime');
5
+ var skinKit = require('@typecaast/skin-kit');
8
6
 
9
7
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
10
8
 
@@ -72,6 +70,7 @@ var ALLOWED_ATTR = [
72
70
  "class",
73
71
  "style",
74
72
  "role",
73
+ "contenteditable",
75
74
  "alt",
76
75
  "src",
77
76
  "srcset",
@@ -153,8 +152,11 @@ function sanitizeHtml(html, opts = {}) {
153
152
  return purify.sanitize(html, {
154
153
  ALLOWED_TAGS,
155
154
  ALLOWED_ATTR,
156
- // Keep our slot marker even though data-* is otherwise dropped.
157
- ADD_ATTR: ["data-tc-slot"],
155
+ // Keep our slot marker even though data-* is otherwise dropped, and
156
+ // keep `contenteditable` so the composer-detection heuristic can find
157
+ // div-based composers (Slack/PostHog/etc. fake the textarea with a
158
+ // contenteditable div). The distiller strips it later, after marking.
159
+ ADD_ATTR: ["data-tc-slot", "contenteditable"],
158
160
  ALLOW_DATA_ATTR: false,
159
161
  ALLOW_ARIA_ATTR: true,
160
162
  FORBID_TAGS: [
@@ -210,7 +212,18 @@ var skinDraftSchema = zod.z.object({
210
212
  /** Theme the capture was taken under, when known. */
211
213
  theme: zod.z.enum(["light", "dark"]).optional(),
212
214
  /** Suggested canvas from the captured element's box. */
213
- canvas: zod.z.object({ width: zod.z.number().int(), height: zod.z.number().int() }).optional()
215
+ canvas: zod.z.object({ width: zod.z.number().int(), height: zod.z.number().int() }).optional(),
216
+ /**
217
+ * Snapshot of the page context the draft was taken from. Used by the
218
+ * slot-template renderer to expose a `--captured-viewport-width` CSS
219
+ * variable so authored CSS can ratio-scale against the original
220
+ * viewport instead of the (much smaller) playback canvas.
221
+ */
222
+ capturedAt: zod.z.object({
223
+ viewportWidth: zod.z.number().int().optional(),
224
+ viewportHeight: zod.z.number().int().optional(),
225
+ pixelRatio: zod.z.number().optional()
226
+ }).optional()
214
227
  }),
215
228
  /**
216
229
  * Slotted, sanitized HTML per region. Elements carry inline `style`
@@ -239,7 +252,13 @@ var skinDraftSchema = zod.z.object({
239
252
  typing: slotReportSchema
240
253
  }),
241
254
  /** Human-readable warnings (hidden content dropped, slots missing, …). */
242
- warnings: zod.z.array(zod.z.string())
255
+ warnings: zod.z.array(zod.z.string()),
256
+ /**
257
+ * Stylesheets the matched-CSS capture couldn't read (typically blocked by
258
+ * CORS). Informational — the slot template still renders, just without
259
+ * the rules those sheets defined.
260
+ */
261
+ cssSkipped: zod.z.array(zod.z.string()).optional()
243
262
  });
244
263
  function detectionScore(draft) {
245
264
  const checks = [
@@ -337,10 +356,36 @@ var INLINE_PROPS = [
337
356
  "gap",
338
357
  "display",
339
358
  "flex-direction",
359
+ "flex-wrap",
340
360
  "align-items",
361
+ "align-self",
341
362
  "justify-content",
363
+ "justify-self",
342
364
  "box-shadow",
343
- "opacity"
365
+ "opacity",
366
+ // Layout-bearing properties (added 2026-Q2 to fix Tailwind-style captures):
367
+ "width",
368
+ "max-width",
369
+ "min-width",
370
+ "height",
371
+ "max-height",
372
+ "min-height",
373
+ "flex",
374
+ "flex-grow",
375
+ "flex-shrink",
376
+ "flex-basis",
377
+ "position",
378
+ "top",
379
+ "right",
380
+ "bottom",
381
+ "left",
382
+ "z-index",
383
+ "overflow",
384
+ "overflow-x",
385
+ "overflow-y",
386
+ "white-space",
387
+ "overflow-wrap",
388
+ "word-break"
344
389
  ];
345
390
  var HIDDEN_CLASS_RE = /\b(sr-only|visually-hidden|hidden)\b/;
346
391
  function getWindow2(win) {
@@ -363,6 +408,25 @@ function isHidden(el, win) {
363
408
  }
364
409
  return false;
365
410
  }
411
+ function pxValue(v) {
412
+ if (!v) return null;
413
+ const m = /^(-?\d+(?:\.\d+)?)px$/.exec(v.trim());
414
+ return m ? Number(m[1]) : null;
415
+ }
416
+ function normaliseDesktopMargins(decls, cs) {
417
+ const left = pxValue(cs.getPropertyValue("margin-left"));
418
+ const right = pxValue(cs.getPropertyValue("margin-right"));
419
+ if (left == null || right == null) return;
420
+ if (left < 24 || right < 24) return;
421
+ const ratio = Math.max(left, right) / Math.min(left, right);
422
+ if (ratio > 1.1) return;
423
+ const top = pxValue(cs.getPropertyValue("margin-top")) ?? 0;
424
+ const bot = pxValue(cs.getPropertyValue("margin-bottom")) ?? 0;
425
+ for (let i = decls.length - 1; i >= 0; i--) {
426
+ if (decls[i]?.startsWith("margin:")) decls.splice(i, 1);
427
+ }
428
+ decls.push(`margin: ${top}px auto ${bot}px`);
429
+ }
366
430
  function inlineStyles(orig, clone, win) {
367
431
  const cs = win.getComputedStyle?.(orig);
368
432
  if (!cs) return;
@@ -372,6 +436,7 @@ function inlineStyles(orig, clone, win) {
372
436
  if (v && v !== "none" && v !== "normal" && v.trim() !== "")
373
437
  decls.push(`${prop}: ${v}`);
374
438
  }
439
+ normaliseDesktopMargins(decls, cs);
375
440
  if (decls.length) clone.setAttribute("style", decls.join("; "));
376
441
  }
377
442
  function pruneAndInline(orig, clone, win, inline, dropped) {
@@ -437,6 +502,10 @@ var TIME_TEXT_RE = /^\s*(\d{1,2}:\d{2}(\s?[ap]\.?m\.?)?|\d+\s*(m|min|h|hr|d|days
437
502
  function classOf(el) {
438
503
  return el.getAttribute("class") ?? "";
439
504
  }
505
+ function stripContenteditable(el) {
506
+ if (el.hasAttribute("contenteditable")) el.removeAttribute("contenteditable");
507
+ for (const child of el.children) stripContenteditable(child);
508
+ }
440
509
  function markSlot(el, slot, token) {
441
510
  el.setAttribute(SLOT_ATTR, slot);
442
511
  el.textContent = token;
@@ -508,12 +577,51 @@ function slotifyRow(row) {
508
577
  }
509
578
  return { html: row.outerHTML, detected };
510
579
  }
511
- var COMPOSER_RE = /\b(composer|compose|reply|message-?box|message-?input|input-?box|textbox|editor|prompt)\b/i;
580
+ var COMPOSER_RE = /\b(composer|compose|reply|message-?box|message-?input|input-?box|textbox|editor|prompt|chat-?input)\b/i;
581
+ var COMPOSER_ARIA_RE = /\b(compose|reply|message|prompt|input|chat)\b/i;
582
+ function blockAncestor(node, root) {
583
+ let cur = node;
584
+ while (cur.parentElement && cur.parentElement !== root) {
585
+ const parent = cur.parentElement;
586
+ const style = (parent.getAttribute("style") ?? "").toLowerCase();
587
+ if (/display\s*:\s*(flex|grid)/.test(style)) return parent;
588
+ cur = parent;
589
+ }
590
+ return cur;
591
+ }
512
592
  function findComposer(root, exclude) {
513
- const candidates = [...root.querySelectorAll("*")].filter(
593
+ const all = [...root.querySelectorAll("*")].filter(
514
594
  (e) => !exclude.contains(e) && !e.contains(exclude)
515
595
  );
516
- return candidates.find((e) => e.getAttribute("role") === "textbox") ?? candidates.find((e) => e.hasAttribute("contenteditable")) ?? candidates.find((e) => COMPOSER_RE.test(classOf(e))) ?? null;
596
+ const byAria = all.find((e) => {
597
+ const label = e.getAttribute("aria-label");
598
+ return label && COMPOSER_ARIA_RE.test(label);
599
+ });
600
+ if (byAria) return blockAncestor(byAria, root);
601
+ const byRole = all.find((e) => e.getAttribute("role") === "textbox");
602
+ if (byRole) return blockAncestor(byRole, root);
603
+ const byCe = all.find((e) => e.hasAttribute("contenteditable"));
604
+ if (byCe) return blockAncestor(byCe, root);
605
+ const byClass = all.find((e) => COMPOSER_RE.test(classOf(e)));
606
+ if (byClass) return byClass;
607
+ let cursor = exclude;
608
+ while (cursor && cursor !== root) {
609
+ let sib = cursor.nextElementSibling;
610
+ while (sib) {
611
+ const style = (sib.getAttribute("style") ?? "").toLowerCase();
612
+ const isBlock = !/display\s*:\s*inline/.test(style);
613
+ const hasButtonish = sib.querySelector(
614
+ "[role='button'], [contenteditable]"
615
+ );
616
+ const txt = (sib.textContent ?? "").trim();
617
+ if (isBlock && (hasButtonish || txt.length === 0 || txt.length < 200)) {
618
+ return sib;
619
+ }
620
+ sib = sib.nextElementSibling;
621
+ }
622
+ cursor = cursor.parentElement;
623
+ }
624
+ return null;
517
625
  }
518
626
  function emptyReport() {
519
627
  return { found: false, detected: [], confidence: 0 };
@@ -585,7 +693,7 @@ function distill(root, opts = {}) {
585
693
  if (composer) {
586
694
  const c = composer.cloneNode(true);
587
695
  c.setAttribute(SLOT_ATTR, "composer");
588
- c.removeAttribute("contenteditable");
696
+ stripContenteditable(c);
589
697
  c.textContent = "{{composer}}";
590
698
  composerHtml = c.outerHTML;
591
699
  detection.composer = {
@@ -598,6 +706,10 @@ function distill(root, opts = {}) {
598
706
  "No composer detected \u2014 add one by hand if the skin needs it."
599
707
  );
600
708
  }
709
+ if (frameHtml)
710
+ frameHtml = frameHtml.replace(/\s+contenteditable="[^"]*"/g, "");
711
+ if (messageHtml)
712
+ messageHtml = messageHtml.replace(/\s+contenteditable="[^"]*"/g, "");
601
713
  } else {
602
714
  warnings.push(
603
715
  "No repeating message row found \u2014 capture a tighter subtree around the thread."
@@ -615,17 +727,19 @@ function distill(root, opts = {}) {
615
727
  name: opts.name ?? "Captured skin",
616
728
  ...opts.sourceUrl ? { sourceUrl: opts.sourceUrl } : {},
617
729
  ...opts.theme ? { theme: opts.theme } : {},
618
- ...canvas ? { canvas } : {}
730
+ ...canvas ? { canvas } : {},
731
+ ...opts.capturedAt ? { capturedAt: opts.capturedAt } : {}
619
732
  },
620
733
  slots: {
621
734
  ...frameHtml ? { frame: frameHtml } : {},
622
735
  ...messageHtml ? { message: messageHtml } : {},
623
736
  ...composerHtml ? { composer: composerHtml } : {}
624
737
  },
625
- css: "",
738
+ css: opts.css ?? "",
626
739
  tokens,
627
740
  detection,
628
- warnings
741
+ warnings,
742
+ ...opts.cssSkipped?.length ? { cssSkipped: opts.cssSkipped } : {}
629
743
  };
630
744
  }
631
745
  function pathTo(root, target) {
@@ -646,52 +760,94 @@ function nodeAtPath(root, path) {
646
760
  }
647
761
  return node;
648
762
  }
649
- function slug(s) {
650
- return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "captured";
763
+
764
+ // src/css-capture.ts
765
+ function subtreeMatches(root, selector) {
766
+ if (selector === ":root" || selector === "html" || selector === "body")
767
+ return false;
768
+ try {
769
+ return root.querySelector(selector) != null;
770
+ } catch {
771
+ return false;
772
+ }
651
773
  }
652
- function contentToText(content) {
653
- const out = [];
654
- for (const node of content) {
655
- if (node.type === "text" && Array.isArray(node.spans)) {
656
- for (const span of node.spans) {
657
- out.push(span.value ?? span.label ?? "");
774
+ function walkRules(rules, root, out, size) {
775
+ for (let i = 0; i < rules.length; i++) {
776
+ if (size.bytes >= size.cap) return;
777
+ const rule = rules[i];
778
+ if (rule.type === 1) {
779
+ const r = rule;
780
+ const sel = r.selectorText;
781
+ if (sel && sel.split(",").some((s) => subtreeMatches(root, s.trim()))) {
782
+ const text = r.cssText;
783
+ out.push(text);
784
+ size.bytes += text.length + 1;
785
+ }
786
+ continue;
787
+ }
788
+ const grouping = rule;
789
+ if (grouping.cssRules) {
790
+ const inner = [];
791
+ const innerSize = { bytes: 0, cap: size.cap - size.bytes };
792
+ walkRules(grouping.cssRules, root, inner, innerSize);
793
+ if (inner.length > 0) {
794
+ const prelude = preludeFor(rule);
795
+ const wrapped = `${prelude} { ${inner.join(" ")} }`;
796
+ out.push(wrapped);
797
+ size.bytes += wrapped.length + 1;
658
798
  }
659
- } else if (node.type === "image") {
660
- out.push(node.alt ?? "\u{1F5BC}");
799
+ continue;
800
+ }
801
+ if (rule.type === 5 || rule.type === 7) {
802
+ const text = rule.cssText;
803
+ out.push(text);
804
+ size.bytes += text.length + 1;
661
805
  }
662
806
  }
663
- return out.join("");
664
- }
665
- function initials(name) {
666
- return name.split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]?.toUpperCase() ?? "").join("");
667
- }
668
- function fmtTime(atMs) {
669
- const total = Math.floor(atMs / 1e3);
670
- const m = Math.floor(total / 60);
671
- const s = total % 60;
672
- return `${m}:${String(s).padStart(2, "0")}`;
673
- }
674
- function styleText(tokens, css) {
675
- const vars = Object.entries(tokens.colors ?? {}).map(([k, v]) => `--${k}: ${v};`).join(" ");
676
- return `:host{all:initial; display:block; width:100%; height:100%; ${vars}}
677
- *{box-sizing:border-box;}
678
- ${css}`;
679
- }
680
- function fillInto(host, templateHtml, values) {
681
- host.innerHTML = templateHtml;
682
- for (const node of host.querySelectorAll(`[${SLOT_ATTR}]`)) {
683
- const slot = node.getAttribute(SLOT_ATTR);
684
- if (slot && slot in values) node.textContent = values[slot] ?? "";
807
+ }
808
+ function preludeFor(rule) {
809
+ const r = rule;
810
+ switch (r.type) {
811
+ case 4:
812
+ return `@media ${r.media?.mediaText ?? "all"}`;
813
+ case 12:
814
+ return `@supports ${r.conditionText ?? "all"}`;
815
+ case 13:
816
+ return `@container ${r.conditionText ?? ""}`;
817
+ case 15:
818
+ return `@layer ${r.name ?? ""}`;
819
+ default:
820
+ return rule.cssText.replace(/\{[\s\S]*$/, "").trim();
685
821
  }
686
822
  }
687
- function revealStyle(progress) {
688
- const p = Math.max(0, Math.min(1, progress));
823
+ function captureMatchedCss(root, doc, opts = {}) {
824
+ const cap = opts.maxBytes ?? 256 * 1024;
825
+ const out = [];
826
+ const size = { bytes: 0, cap };
827
+ const skipped = [];
828
+ const sheets = doc.styleSheets;
829
+ for (let i = 0; i < sheets.length; i++) {
830
+ if (size.bytes >= cap) break;
831
+ const sheet = sheets[i];
832
+ let rules;
833
+ try {
834
+ rules = sheet.cssRules;
835
+ } catch {
836
+ skipped.push(sheet.href ?? "(inline)");
837
+ continue;
838
+ }
839
+ if (!rules) continue;
840
+ walkRules(rules, root, out, size);
841
+ }
689
842
  return {
690
- opacity: p,
691
- transform: `translateY(${(1 - p) * 6}px)`,
692
- willChange: "opacity, transform"
843
+ css: out.join("\n"),
844
+ skipped,
845
+ truncated: size.bytes >= cap
693
846
  };
694
847
  }
848
+ function slug(s) {
849
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "captured";
850
+ }
695
851
  var DEFAULT_CAPS = {
696
852
  events: {},
697
853
  content: {},
@@ -700,119 +856,27 @@ var DEFAULT_CAPS = {
700
856
  readReceipts: false
701
857
  };
702
858
  function templateSkinFromDraft(draft, opts = {}) {
703
- const safe = {
704
- frame: draft.slots.frame ? sanitizeHtml(draft.slots.frame) : void 0,
705
- message: draft.slots.message ? sanitizeHtml(draft.slots.message) : void 0,
706
- composer: draft.slots.composer ? sanitizeHtml(draft.slots.composer) : void 0
707
- };
708
- const lightTokens = { colors: draft.tokens.colors ?? {} };
709
- const darkTokens = draft.darkTokens ? { colors: draft.darkTokens.colors ?? {} } : lightTokens;
710
- const cssByTheme = {
711
- light: styleText(lightTokens, draft.css ?? ""),
712
- dark: styleText(darkTokens, draft.css ?? "")
713
- };
714
- const supportsThemes = draft.darkTokens ? ["light", "dark"] : [draft.meta.theme ?? "light"];
715
- const Frame = ({
716
- theme,
717
- children
718
- }) => {
719
- const hostRef = react.useRef(null);
720
- const [mount, setMount] = react.useState(null);
721
- react.useLayoutEffect(() => {
722
- const host = hostRef.current;
723
- if (!host) return;
724
- const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" });
725
- shadow.innerHTML = "";
726
- const style = host.ownerDocument.createElement("style");
727
- style.textContent = theme === "dark" ? cssByTheme.dark : cssByTheme.light;
728
- shadow.appendChild(style);
729
- const wrapper = host.ownerDocument.createElement("div");
730
- wrapper.style.width = "100%";
731
- wrapper.style.height = "100%";
732
- wrapper.innerHTML = safe.frame ?? `<div ${SLOT_ATTR}="messages"></div>`;
733
- shadow.appendChild(wrapper);
734
- const slot = wrapper.querySelector(`[${SLOT_ATTR}="messages"]`) ?? wrapper;
735
- slot.textContent = "";
736
- setMount(slot);
737
- }, [theme]);
738
- return /* @__PURE__ */ jsxRuntime.jsx("div", { ref: hostRef, style: { width: "100%", height: "100%" }, children: mount ? reactDom.createPortal(children, mount) : null });
739
- };
740
- const Message = ({ message, author }) => {
741
- const ref = react.useRef(null);
742
- react.useLayoutEffect(() => {
743
- const el = ref.current;
744
- if (!el || !safe.message) return;
745
- fillInto(el, safe.message, {
746
- author: author.name,
747
- avatar: initials(author.name),
748
- body: contentToText(message.content),
749
- time: fmtTime(message.atMs)
750
- });
751
- }, [message, author]);
752
- return /* @__PURE__ */ jsxRuntime.jsx("div", { ref, style: revealStyle(message.revealProgress) });
753
- };
754
- const SystemMessage = ({ message }) => /* @__PURE__ */ jsxRuntime.jsx(
755
- "div",
756
- {
757
- style: { ...revealStyle(message.revealProgress), textAlign: "center" },
758
- children: contentToText(message.content)
759
- }
760
- );
761
- const TypingIndicator = ({ author }) => /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { opacity: 0.7, fontStyle: "italic" }, children: [
762
- author.name,
763
- " is typing\u2026"
764
- ] });
765
- const Composer = ({ composer }) => {
766
- const ref = react.useRef(null);
767
- react.useLayoutEffect(() => {
768
- const el = ref.current;
769
- if (!el) return;
770
- if (safe.composer) {
771
- fillInto(el, safe.composer, { composer: composer.text });
772
- } else {
773
- el.textContent = composer.text;
774
- }
775
- }, [composer]);
776
- return /* @__PURE__ */ jsxRuntime.jsx("div", { ref });
777
- };
778
- const Avatar = ({
779
- participant,
780
- size = 36
781
- }) => /* @__PURE__ */ jsxRuntime.jsx(
782
- "div",
783
- {
784
- style: {
785
- width: size,
786
- height: size,
787
- borderRadius: "50%",
788
- display: "grid",
789
- placeItems: "center",
790
- background: "var(--color-1, #ccc)",
791
- fontSize: size * 0.4
792
- },
793
- children: initials(participant.name)
794
- }
795
- );
796
- const components = {
797
- Frame,
798
- Message,
799
- SystemMessage,
800
- TypingIndicator,
801
- Reaction: () => null,
802
- Composer,
803
- Avatar
804
- };
805
- return {
806
- id: opts.id ?? slug(draft.meta.name),
859
+ const safeDraft = {
807
860
  meta: {
808
861
  name: draft.meta.name,
809
- defaultCanvas: draft.meta.canvas ?? { width: 420, height: 720 },
810
- supportsThemes,
811
- capabilities: opts.capabilities ?? DEFAULT_CAPS
862
+ theme: draft.meta.theme,
863
+ canvas: draft.meta.canvas,
864
+ capturedAt: draft.meta.capturedAt
865
+ },
866
+ slots: {
867
+ frame: draft.slots.frame ? sanitizeHtml(draft.slots.frame) : void 0,
868
+ message: draft.slots.message ? sanitizeHtml(draft.slots.message) : void 0,
869
+ composer: draft.slots.composer ? sanitizeHtml(draft.slots.composer) : void 0,
870
+ typing: draft.slots.typing ? sanitizeHtml(draft.slots.typing) : void 0
812
871
  },
813
- components,
814
- tokens: { light: lightTokens, dark: darkTokens }
872
+ css: draft.css ?? "",
873
+ tokens: { colors: draft.tokens.colors ?? {} },
874
+ darkTokens: draft.darkTokens ? { colors: draft.darkTokens.colors ?? {} } : void 0
815
875
  };
876
+ return skinKit.slotSkinFromDraft(safeDraft, {
877
+ id: opts.id ?? slug(draft.meta.name),
878
+ capabilities: opts.capabilities ?? DEFAULT_CAPS
879
+ });
816
880
  }
817
881
 
818
882
  // src/merge.ts
@@ -830,6 +894,7 @@ function mergeThemeDrafts(light, dark) {
830
894
  }
831
895
 
832
896
  exports.SLOT_TOKENS = SLOT_TOKENS;
897
+ exports.captureMatchedCss = captureMatchedCss;
833
898
  exports.detectionScore = detectionScore;
834
899
  exports.distill = distill;
835
900
  exports.mergeThemeDrafts = mergeThemeDrafts;