typebulb 0.10.3 → 0.10.4

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.
@@ -825,6 +825,12 @@ const defaultFence: MdRenderRule = md.renderer.rules.fence
825
825
  md.renderer.rules.fence = (tokens: MdToken[], idx: number, opts: unknown, env: unknown, self: MdRenderer) => {
826
826
  const t = tokens[idx]
827
827
  const lang = (t.info ?? '').trim().toLowerCase()
828
+ // Live embeds (svg, mermaid) are an assistant medium — they render only in assistant turns. In a
829
+ // user turn (env.userMessage, set by userMarkdown) they fall through to a plain source code block:
830
+ // the svg chop and the mermaid lane-breakout both assume the transparent assistant flow and clash
831
+ // with the opaque, padded, rounded user card, and a user's pasted/quoted markup reads better as its
832
+ // literal source anyway. KaTeX/@mentions/links still render in user turns (those aren't gated here).
833
+ const live = !(env as { userMessage?: boolean })?.userMessage
828
834
  // Note: no `bulb` case here. Live embeds are split out of the text before markdown runs
829
835
  // (splitBulbSegments → BulbEmbed), so a ````bulb```` fence reaching markdown is illustrative
830
836
  // source — it falls through to defaultFence like any other unrecognised fence.
@@ -833,10 +839,10 @@ md.renderer.rules.fence = (tokens: MdToken[], idx: number, opts: unknown, env: u
833
839
  // it goes through DOMPurify's svg profile first — geometry survives, the script
834
840
  // surface is stripped. Lets the agent draw anything (smiley, plot from an
835
841
  // equation) without an iframe, since it's static markup, not executed code.
836
- if (lang === 'svg') {
837
- return `<div class="svg-embed">${DOMPurify.sanitize(t.content, { USE_PROFILES: { svg: true, svgFilters: true } })}</div>`
842
+ if (lang === 'svg' && live) {
843
+ return `<div class="embed svg-embed">${DOMPurify.sanitize(t.content, { USE_PROFILES: { svg: true, svgFilters: true } })}</div>`
838
844
  }
839
- if (lang === 'mermaid') {
845
+ if (lang === 'mermaid' && live) {
840
846
  try {
841
847
  // The library's internal theme vars share names with the bulb's (--fg/--bg/
842
848
  // --muted/--border/--accent), so passing var(--fg) here would emit a
@@ -885,6 +891,36 @@ function themeMermaidNodes(root: Element) {
885
891
  }
886
892
  }
887
893
 
894
+ // Fit each raw ```svg``` embed to its content. A raw <svg> with only a viewBox has NO intrinsic size,
895
+ // so it defaults to width:100% and stretches to fill the column — which both makes the container's
896
+ // justify-content:center moot (where the art sits *inside* the viewBox is all that positions it, so a
897
+ // drawing parked off-centre in an oversized box reads as left/right-aligned) and blows a small drawing
898
+ // up to a column-wide wall. Two coupled steps fix both: (1) tighten the viewBox to the content's bbox,
899
+ // then (2) set the display width to that bbox's user-unit extent as px (1 unit ≈ 1px), capped by the
900
+ // CSS max-width:100%. Now it renders small when small (and the now-narrower svg actually centres), and
901
+ // a genuinely large drawing still caps at the column. getBBox is geometry-only — it ignores stroke,
902
+ // which paints up to half its width *outside* the geometry — so the viewBox is padded by the largest
903
+ // painted half-stroke (clipping depends on stroke width, not drawing size; a fixed % loses to a thick
904
+ // stroke on a small drawing). The +1 is a hairline guard for anti-aliasing / curve overshoot; pad is 0
905
+ // when nothing strokes (fills never overflow). Skips a node that isn't measurable (zero-area, or laid
906
+ // out inside a collapsed turn → getBBox throws / returns 0), leaving it as authored.
907
+ function fitSvgEmbeds(root: Element) {
908
+ for (const svg of root.querySelectorAll<SVGSVGElement>('.svg-embed svg')) {
909
+ try {
910
+ const b = svg.getBBox()
911
+ if (!b.width || !b.height) continue
912
+ let half = 0
913
+ for (const el of svg.querySelectorAll<SVGElement>('*')) {
914
+ const cs = getComputedStyle(el) // computed, so it catches attribute, CSS, and inherited widths
915
+ if (cs.stroke && cs.stroke !== 'none') half = Math.max(half, parseFloat(cs.strokeWidth) / 2)
916
+ }
917
+ const pad = half ? half + 1 : 0
918
+ svg.setAttribute('viewBox', `${b.x - pad} ${b.y - pad} ${b.width + pad * 2} ${b.height + pad * 2}`)
919
+ svg.style.width = `${b.width + pad * 2}px` // natural size (units≈px); max-width:100% caps it, height:auto keeps ratio
920
+ } catch { /* not laid out / unmeasurable — leave the authored viewBox */ }
921
+ }
922
+ }
923
+
888
924
  // A relative-path link in assistant markdown is a local file citation (optionally
889
925
  // with a #Lnnn line anchor): open it in the editor, not the browser — the bulb is
890
926
  // served from the project root, so the browser would GET the path and 404. Links
@@ -904,10 +940,11 @@ function onMarkdownClick(e: Event) {
904
940
  // Render markdown into the element via innerHTML. The click handler is bound natively here, not via
905
941
  // domeleon's `onClick`: the anchors are raw innerHTML the vdom never sees, so a delegated handler
906
942
  // wouldn't fire. onMarkdownClick is a stable ref, so re-mounts don't stack duplicate listeners.
907
- const renderMarkdown = (text: string) => (el: Element) => {
943
+ const renderMarkdown = (text: string, env?: { userMessage?: boolean }) => (el: Element) => {
908
944
  try {
909
- el.innerHTML = md.render(text)
945
+ el.innerHTML = md.render(text, env)
910
946
  themeMermaidNodes(el)
947
+ fitSvgEmbeds(el)
911
948
  } catch {
912
949
  ;(el as HTMLElement).textContent = text
913
950
  }
@@ -936,7 +973,7 @@ const atMentionLink = (m: string): string => {
936
973
  const userMarkdown = (msg: Msg) => {
937
974
  const segs = msg.segments ?? [msg.text]
938
975
  const src = segs.join('\n\n---\n\n').replace(AT_MENTION, atMentionLink)
939
- return renderMarkdown(src)
976
+ return renderMarkdown(src, { userMessage: true })
940
977
  }
941
978
 
942
979
  // Split an assistant message into ordered markdown chunks and live ````bulb```` sources, using
@@ -1821,7 +1858,7 @@ class BulbEmbed extends Component {
1821
1858
  }
1822
1859
 
1823
1860
  view() {
1824
- const cls = ['bulb-embed', this.spread ? 'spread' : 'inline', this.showingCode ? 'code-open' : '', this.#compileError ? 'err' : '']
1861
+ const cls = ['embed', 'bulb-embed', this.spread ? 'spread' : 'inline', this.showingCode ? 'code-open' : '', this.#compileError ? 'err' : '']
1825
1862
  const inner =
1826
1863
  this.#compileError ? [`bulb: ${this.#compileError}`]
1827
1864
  : !this.#frame ? ['compiling bulb…']
@@ -2922,8 +2959,9 @@ a.server-port:hover { color: var(--accent); text-decoration: underline; }
2922
2959
  .md .mermaid svg { max-width: none; height: auto; flex: 0 0 auto; }
2923
2960
 
2924
2961
  /* Raw ```svg``` embeds: no breakout — these are drawings (smiley, plot), not wide
2925
- diagrams, so keep them in the prose column, centred and capped at its width. */
2926
- .md .svg-embed { margin: .6rem 0; display: flex; justify-content: center; }
2962
+ diagrams, so keep them in the prose column, centred and capped at its width. They
2963
+ share the `.embed` stripe-chop below (a drawing earns its own row like a bulb). */
2964
+ .md .svg-embed { justify-content: center; }
2927
2965
  .md .svg-embed svg { max-width: 100%; height: auto; }
2928
2966
 
2929
2967
  /* ````bulb```` embeds: a sandboxed nested app. createBulbFrame owns the iframe (auto-height,
@@ -2934,28 +2972,27 @@ a.server-port:hover { color: var(--accent); text-decoration: underline; }
2934
2972
  position:relative anchors the controls overlay in BOTH modes (spread re-declares it for the
2935
2973
  z-index lift). .err is the compile-failure fallback; .bulb-err-strip is a runtime-error strip
2936
2974
  under a live embed (both monospace, muted red). */
2937
- .md .bulb-embed {
2975
+ /* The stripe-chop, shared by every opaque in-column visual artifact (the inline bulb and a raw
2976
+ `svg` — each opts in by carrying `.embed`). An opaque bg over position:relative + z-index:0
2977
+ paints above .bubble::before; a -1rem left extension reaches the stripe in the gutter without a
2978
+ full breakout (padding-left:1rem keeps the content in the prose column, and the box's right edge
2979
+ stays at prose-right, so a bulb's controls anchored there don't move). The vertical gap is opaque
2980
+ padding, not margin: it occludes the stripe top and bottom so the cut is flush with the prose (no
2981
+ stub in a transparent gap), symmetric above/below to separate the artifact from surrounding text.
2982
+ margin top/bottom are zeroed — and the abutting prose margins too (below) — so the gap butts the
2983
+ text. Mermaid is NOT on this path: it's transparent and breaks out, occluding via box-shadow.
2984
+ Per-artifact layout (flex direction, centring) lives on .bulb-embed / .svg-embed. */
2985
+ .md .embed {
2938
2986
  display: flex;
2939
- flex-direction: column;
2940
2987
  position: relative;
2941
2988
  z-index: 0;
2942
- /* Always cut the turn stripe, both modes: an opaque bg over position:relative + z-index:0
2943
- paints above .bubble::before, and a -1rem left extension reaches the stripe in the gutter
2944
- without a full breakout (padding-left:1rem keeps the content in the prose column; the box's
2945
- right edge stays at prose-right, so the controls anchored there don't move). In spread mode the
2946
- iframe's own breakout covers the stripe too. */
2947
2989
  background: var(--bg);
2948
- margin-left: -1rem;
2949
- padding-left: 1rem;
2950
- /* Symmetric vertical gap as opaque padding, not margin: it occludes the stripe top and bottom
2951
- so it's cut flush with the prose (no stub in a transparent gap), and is the same amount above
2952
- and below to separate the bulb from surrounding text. margin top/bottom are zeroed — and the
2953
- abutting prose margins too (below) — so the gap butts against the text. */
2954
- margin-top: 0;
2955
- margin-bottom: 0;
2956
- padding-top: 1.1rem;
2957
- padding-bottom: 1.1rem;
2990
+ margin: 0 0 0 -1rem;
2991
+ padding: 1.1rem 0 1.1rem 1rem;
2958
2992
  }
2993
+ /* A bulb stacks its controls/frame/code vertically; in spread mode the inner iframe's own
2994
+ breakout covers the stripe too (the wrapper still chops in column). */
2995
+ .md .bulb-embed { flex-direction: column; }
2959
2996
  /* Inline (default): cap the embed's height so a tall bulb doesn't run away down the transcript.
2960
2997
  Past the cap the embed scrolls internally (its own overflow, set by the host↔embed protocol —
2961
2998
  the iframe element can't scroll its srcdoc from out here). spread removes the cap. */
@@ -2980,11 +3017,11 @@ a.server-port:hover { color: var(--accent); text-decoration: underline; }
2980
3017
  position: relative;
2981
3018
  z-index: 0;
2982
3019
  }
2983
- /* Kill the transparent margin between the bulb and the prose it abuts (top and bottom):
2984
- the bulb's own opaque padding is the gap, and a leftover prose margin would let the
3020
+ /* Kill the transparent margin between an embed and the prose it abuts (top and bottom):
3021
+ the embed's own opaque padding is the gap, and a leftover prose margin would let the
2985
3022
  turn stripe show through it as a stub past the last/next line of text. */
2986
- .md :has(+ .bulb-embed) { margin-bottom: 0; }
2987
- .md .bulb-embed + * { margin-top: 0; }
3023
+ .md :has(+ .embed) { margin-bottom: 0; }
3024
+ .md .embed + * { margin-top: 0; }
2988
3025
  /* The rounded clip lives on the inner surfaces, not the embed (whose padding is now the
2989
3026
  gap), so the bulb's own card — the iframe, or the code view behind the toggle — is
2990
3027
  what carries the corners. */
@@ -3265,7 +3302,7 @@ a.server-port:hover { color: var(--accent); text-decoration: underline; }
3265
3302
  "beautiful-mermaid": "^1.1.3",
3266
3303
  "dompurify": "^3.2.6",
3267
3304
  "highlight.js": "^11.10.0",
3268
- "typebulb": "^0.10.3"
3305
+ "typebulb": "^0.10.4"
3269
3306
  }
3270
3307
  }
3271
3308
  ```
package/dist/index.js CHANGED
@@ -668,6 +668,6 @@ ${r.trim()}
668
668
  `)}catch(f){console.error("Compile error:",f)}}),F=vt({bulbPath:r,emitter:u}),n){let{name:f,serveDir:b}=n;L=Fn({dir:b,onChange:()=>{console.log(`Local package '${f}' changed. Browser reloading...
669
669
  `),a.emit("reload")}})}}e.open&&await it(g);let _=async()=>{console.log(`
670
670
  Shutting down...`),h.close(),F?.(),L?.(),i(),await qt(process.pid);let u=ve.join(ve.dirname(r),".typebulb","server.mjs");await Bn.rm(u,{force:!0}).catch(()=>{}),process.exit(0)};process.on("SIGINT",_),process.on("SIGTERM",_)}import*as Un from"path";import{EventEmitter as Mo}from"events";async function Wn(r,e,t,n,s){let o=ge(t),i=!1,a=async()=>{let{bulb:c,config:l}=await W(r);i||(mt(o,r,c.server),i=!0),await Nt(c.server,s,n,l.dependencies)};if(console.log(`Running ${Un.basename(r)}...`),await a(),e){console.log(`Watching for changes...
671
- `);let c=new Mo;c.on("reload",async()=>{try{console.log("Re-running..."),await a()}catch(l){console.error("Error:",l)}}),vt({bulbPath:r,emitter:c})}}var Jn="0.10.3";async function jo(){let r=ur(process.argv.slice(2));if(r.version&&(console.log(`typebulb ${Jn}`),process.exit(0)),r.help&&(pr(),process.exit(0)),r.subcommand==="logs"){await _n(r.file||void 0,{follow:r.follow,lines:r.lines});return}if(r.subcommand==="stop"){await Cn(r.file||void 0);return}if(r.subcommand==="skill"){await Pn(Jn);return}if(r.subcommand==="models"){await wn(r.mode);return}if(r.subcommand==="agent"){if(!r.agentTarget){await gn();return}me(r.agentTarget)||(console.error(`Unknown agent '${r.agentTarget}'. Known: ${Wt().join(", ")}.`),process.exit(1));let l=await zt(process.cwd(),r.agentTarget);if(l){console.log(`Viewer '${r.agentTarget}' is already running for this project:
671
+ `);let c=new Mo;c.on("reload",async()=>{try{console.log("Re-running..."),await a()}catch(l){console.error("Error:",l)}}),vt({bulbPath:r,emitter:c})}}var Jn="0.10.4";async function jo(){let r=ur(process.argv.slice(2));if(r.version&&(console.log(`typebulb ${Jn}`),process.exit(0)),r.help&&(pr(),process.exit(0)),r.subcommand==="logs"){await _n(r.file||void 0,{follow:r.follow,lines:r.lines});return}if(r.subcommand==="stop"){await Cn(r.file||void 0);return}if(r.subcommand==="skill"){await Pn(Jn);return}if(r.subcommand==="models"){await wn(r.mode);return}if(r.subcommand==="agent"){if(!r.agentTarget){await gn();return}me(r.agentTarget)||(console.error(`Unknown agent '${r.agentTarget}'. Known: ${Wt().join(", ")}.`),process.exit(1));let l=await zt(process.cwd(),r.agentTarget);if(l){console.log(`Viewer '${r.agentTarget}' is already running for this project:
672
672
  ${l.url}`),r.open&&await it(l.url);return}r.file=r.agentTarget,r.subcommand="run"}if(r.subcommand==="trust"||r.subcommand==="untrust"){await on(r.file||void 0,r.subcommand==="trust");return}let e,t=!1;if(!r.file||r.file==="."){let l=await Hr(process.cwd());l||(console.error("No .bulb.md file found in current directory"),process.exit(1)),e=l}else e=q.resolve(r.file);if(!await Vn.access(e).then(()=>!0,()=>!1)){let l=r.agentTarget?me(r.file):void 0;l?(e=l,t=!0):Wt().includes(r.file)?(console.error(`To open the ${r.file} agent viewer, run: npx typebulb agent:${r.file}`),process.exit(1)):(console.error(`File not found: ${e}`),process.exit(1))}e.endsWith(".bulb.md")||(console.error("File must have .bulb.md extension"),process.exit(1));let s=r.file&&r.file!=="."?r.file:q.relative(process.cwd(),e)||q.basename(e),o=`npx typebulb --trust ${s.includes(" ")?`"${s}"`:s}`;if(r.subcommand==="predict"){await sn(e,o);return}if(t)r.trust=!r.noTrust,r.trust&&console.log("trust: granted (built-in bulb)");else{let l=!r.noTrust&&ot(e);l&&!r.trust&&console.log("trust: granted from memory (run `typebulb untrust` to revoke)"),r.trust=r.noTrust?!1:r.trust||l}let i;try{i=await W(e)}catch{}let a;if(r.local){i&&!(r.local.name in(i.config.dependencies??{}))&&(console.error(`--replace: '${r.local.name}' is not a dependency in this bulb's config.json; nothing to replace.`),process.exit(1)),i&&(!i.bulb.code||r.server)&&console.warn("warning: --replace has no effect in server mode (the override is client-only).");try{a=await lr(r.local)}catch(l){console.error(l instanceof Error?l.message:String(l)),process.exit(1)}console.log(`replace: ${a.name} \u2192 ${q.relative(process.cwd(),a.dir)||"."}`)}if(r.subcommand==="check"){await nn(e,a);return}let c=t?process.cwd():q.dirname(e);if(i&&i.bulb.server&&(!i.bulb.code||r.server)){r.trust||(console.error(`This bulb runs server-side Node code (server.ts), which --trust must authorize:
673
673
  ${o}`),process.exit(1)),await Wn(e,r.watch,r.mode,a,c);return}await Ln(e,r,o,a,c)}jo().catch(r=>{console.error("Error:",r.message),process.exit(1)});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typebulb",
3
- "version": "0.10.3",
3
+ "version": "0.10.4",
4
4
  "description": "Local bulb runner CLI for Typebulb",
5
5
  "license": "MIT",
6
6
  "engines": { "node": ">=18" },