@polderlabs/bizar 4.4.7 → 4.4.9

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.
@@ -29,8 +29,8 @@
29
29
  }
30
30
  })();
31
31
  </script>
32
- <script type="module" crossorigin src="/assets/main-C4Dq4wTz.js"></script>
33
- <link rel="modulepreload" crossorigin href="/assets/mobile-CaZ0gEeJ.js">
32
+ <script type="module" crossorigin src="/assets/main-CEazNxxy.js"></script>
33
+ <link rel="modulepreload" crossorigin href="/assets/mobile-Dl1q7Cyq.js">
34
34
  <link rel="stylesheet" crossorigin href="/assets/mobile-CsZQAswA.css">
35
35
  <link rel="stylesheet" crossorigin href="/assets/main-Nq8Dq3VR.css">
36
36
  </head>
@@ -12,8 +12,8 @@
12
12
  <link rel="preconnect" href="https://fonts.googleapis.com" />
13
13
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
14
14
  <link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
15
- <script type="module" crossorigin src="/assets/mobile-CQTXbuHq.js"></script>
16
- <link rel="modulepreload" crossorigin href="/assets/mobile-CaZ0gEeJ.js">
15
+ <script type="module" crossorigin src="/assets/mobile-DSb-t42Y.js"></script>
16
+ <link rel="modulepreload" crossorigin href="/assets/mobile-Dl1q7Cyq.js">
17
17
  <link rel="stylesheet" crossorigin href="/assets/mobile-CsZQAswA.css">
18
18
  </head>
19
19
  <body>
@@ -199,6 +199,19 @@ function parseJsxValue(src) {
199
199
  }
200
200
  while (i < len) {
201
201
  skipWs();
202
+ // v4.4.8 — After consuming a comma + whitespace, check for the
203
+ // closer before recursing. Without this, an array like `[a, b, ]`
204
+ // would call parseValue on `]`, which falls through to the
205
+ // bareword-identifier branch and returns `{__ident: ''}` as a
206
+ // phantom element. The trailing-comma-then-closer case was the
207
+ // user-visible bug in the v3-to-v4-consolidation glyph: the
208
+ // FileTree's last entry was the bareword phantom, which then
209
+ // crashed the React renderer (components.tsx:609 tried to read
210
+ // `t.bg` on undefined).
211
+ if (arr && src[i] === closer) {
212
+ i += 1;
213
+ return items;
214
+ }
202
215
  if (arr) {
203
216
  items.push(parseValue());
204
217
  } else {
@@ -49,6 +49,142 @@ import {
49
49
  Mockup,
50
50
  } from './components';
51
51
 
52
+ // ---------------------------------------------------------------------------
53
+ // BlockErrorBoundary — v4.4.8
54
+ //
55
+ // A single broken block (e.g. a FileTree whose `data.entries` ended up
56
+ // not being an array because the parser silently produced a phantom
57
+ // bareword entry) used to throw inside React, which propagated up and
58
+ // blanked the whole canvas. The boundary catches throws per-block and
59
+ // renders an inline error card instead. The boundary also exposes its
60
+ // errors via a callback so we can surface them in the top-of-page
61
+ // "render errors" banner.
62
+ // ---------------------------------------------------------------------------
63
+
64
+ interface BlockErrorBoundaryProps {
65
+ blockId: string;
66
+ blockType: string;
67
+ children: React.ReactNode;
68
+ onError: (blockId: string, blockType: string, err: Error, info: React.ErrorInfo) => void;
69
+ }
70
+
71
+ interface BlockErrorBoundaryState {
72
+ err: Error | null;
73
+ }
74
+
75
+ class BlockErrorBoundary extends React.Component<BlockErrorBoundaryProps, BlockErrorBoundaryState> {
76
+ state: BlockErrorBoundaryState = { err: null };
77
+ static getDerivedStateFromError(err: Error): BlockErrorBoundaryState {
78
+ return { err };
79
+ }
80
+ componentDidCatch(err: Error, info: React.ErrorInfo): void {
81
+ try {
82
+ this.props.onError(this.props.blockId, this.props.blockType, err, info);
83
+ } catch {
84
+ /* ignore — the boundary itself must not throw */
85
+ }
86
+ }
87
+ render(): React.ReactNode {
88
+ if (this.state.err) {
89
+ return (
90
+ <div
91
+ className="glyph-block-error"
92
+ role="alert"
93
+ style={{
94
+ border: '1px solid var(--error, #f85149)',
95
+ background: 'rgba(248, 81, 73, 0.06)',
96
+ borderRadius: 8,
97
+ padding: '12px 14px',
98
+ margin: '12px 0',
99
+ color: 'var(--text)',
100
+ fontFamily: 'var(--font-mono, ui-monospace, monospace)',
101
+ fontSize: 12,
102
+ lineHeight: 1.55,
103
+ }}
104
+ >
105
+ <strong style={{ color: 'var(--error, #f85149)', fontFamily: 'var(--font-sans, system-ui, sans-serif)', display: 'block', marginBottom: 4 }}>
106
+ {this.props.blockType} block crashed
107
+ </strong>
108
+ <div style={{ color: 'var(--text-muted)', marginBottom: 6 }}>
109
+ block id: <code>{this.props.blockId}</code>
110
+ </div>
111
+ <pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: 'var(--text)' }}>
112
+ {this.state.err.message}
113
+ </pre>
114
+ </div>
115
+ );
116
+ }
117
+ return this.props.children;
118
+ }
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Block validation — v4.4.8
123
+ //
124
+ // Before handing block data to the component switch, verify that the
125
+ // shape matches the prop contract. Returns `{ ok: true }` when the
126
+ // block can be rendered, or `{ ok: false, error }` when a field is
127
+ // missing or wrong type. The renderer uses the error to skip the
128
+ // component and surface an inline message instead of throwing.
129
+ // ---------------------------------------------------------------------------
130
+
131
+ function validateBlock(b: Block): { ok: true } | { ok: false; error: string } {
132
+ const data = (b.data ?? {}) as Record<string, unknown>;
133
+ switch (b.type) {
134
+ case 'RichText':
135
+ return { ok: true };
136
+ case 'Callout':
137
+ return { ok: true };
138
+ case 'Checklist':
139
+ if (!Array.isArray(data.items)) return { ok: false, error: 'Checklist requires data.items to be an array' };
140
+ return { ok: true };
141
+ case 'Table':
142
+ if (!Array.isArray(data.columns)) return { ok: false, error: 'Table requires data.columns to be an array' };
143
+ if (!Array.isArray(data.rows)) return { ok: false, error: 'Table requires data.rows to be an array' };
144
+ if (data.rows.length > 0 && !Array.isArray(data.rows[0])) return { ok: false, error: 'Table.data.rows[0] must be an array (cell array)' };
145
+ return { ok: true };
146
+ case 'CodeTabs':
147
+ if (!Array.isArray(data.tabs)) return { ok: false, error: 'CodeTabs requires data.tabs to be an array' };
148
+ return { ok: true };
149
+ case 'Decision':
150
+ if (!Array.isArray(data.options)) return { ok: false, error: 'Decision requires data.options to be an array' };
151
+ return { ok: true };
152
+ case 'OpenQuestions':
153
+ if (!Array.isArray(data.questions)) return { ok: false, error: 'OpenQuestions requires data.questions to be an array' };
154
+ return { ok: true };
155
+ case 'FileTree':
156
+ if (!Array.isArray(data.entries)) return { ok: false, error: 'FileTree requires data.entries to be an array' };
157
+ // v4.4.8 — also catch the phantom bareword entry the parser used
158
+ // to emit (the v3-to-v4-consolidation glyph crash). An entry that
159
+ // is not an object means the parser miscounted braces.
160
+ for (const e of data.entries) {
161
+ if (e === null || typeof e !== 'object' || Array.isArray(e)) {
162
+ return { ok: false, error: `FileTree contains a malformed entry: ${JSON.stringify(e)}` };
163
+ }
164
+ }
165
+ return { ok: true };
166
+ case 'Diff':
167
+ if (typeof data.before !== 'string') return { ok: false, error: 'Diff requires data.before to be a string' };
168
+ if (typeof data.after !== 'string') return { ok: false, error: 'Diff requires data.after to be a string' };
169
+ return { ok: true };
170
+ case 'Stat':
171
+ if (data.label === undefined || data.label === null) return { ok: false, error: 'Stat requires data.label' };
172
+ if (data.value === undefined || data.value === null) return { ok: false, error: 'Stat requires data.value' };
173
+ return { ok: true };
174
+ case 'Workflow':
175
+ if (!Array.isArray(data.steps)) return { ok: false, error: 'Workflow requires data.steps to be an array' };
176
+ return { ok: true };
177
+ case 'Mockup':
178
+ if (typeof data.html !== 'string') return { ok: false, error: 'Mockup requires data.html to be a string' };
179
+ return { ok: true };
180
+ case 'Diagram':
181
+ if (typeof data.dataHtml !== 'string') return { ok: false, error: 'Diagram requires data.dataHtml to be a string' };
182
+ return { ok: true };
183
+ default:
184
+ return { ok: false, error: `Unknown block type: ${(b as { type: string }).type}` };
185
+ }
186
+ }
187
+
52
188
  // ---------------------------------------------------------------------------
53
189
  // Types — match the server compiler output
54
190
  // ---------------------------------------------------------------------------
@@ -72,7 +208,7 @@ interface CompiledGlyph {
72
208
  slug: string;
73
209
  frontmatter: Record<string, unknown>;
74
210
  blocks: Block[];
75
- errors: string[];
211
+ errors: Array<{ line?: number; message: string }>;
76
212
  compiledAt: string;
77
213
  }
78
214
 
@@ -162,6 +298,25 @@ export function GlyphRenderer({ slug, onClose, onCommentAdded }: Props) {
162
298
  // Active pin (expanded thread view)
163
299
  const [activePin, setActivePin] = useState<string | null>(null);
164
300
 
301
+ // v4.4.8 — Per-block render errors caught by BlockErrorBoundary. The
302
+ // boundary calls onError(err) which pushes into this array. We render
303
+ // the array at the top of the canvas in a prominent red banner so the
304
+ // user isn't left staring at a blank screen wondering what happened.
305
+ const [blockErrors, setBlockErrors] = useState<
306
+ Array<{ blockId: string; blockType: string; message: string }>
307
+ >([]);
308
+ const reportBlockError = React.useCallback(
309
+ (blockId: string, blockType: string, err: Error) => {
310
+ setBlockErrors((prev) => {
311
+ // dedupe by blockId so the same block crashing twice doesn't
312
+ // duplicate the entry
313
+ if (prev.some((e) => e.blockId === blockId)) return prev;
314
+ return [...prev, { blockId, blockType, message: err.message || String(err) }];
315
+ });
316
+ },
317
+ [],
318
+ );
319
+
165
320
  const canvasRef = useRef<HTMLDivElement>(null);
166
321
 
167
322
  // Section grouping — memoized so we don't re-walk the block array on every render
@@ -289,10 +444,58 @@ export function GlyphRenderer({ slug, onClose, onCommentAdded }: Props) {
289
444
  setFullscreen((v) => !v);
290
445
  }
291
446
 
292
- // Render a single block
447
+ // Render a single block — wrapped in a BlockErrorBoundary so a single
448
+ // broken block (e.g. wrong data shape from a parser bug) shows an inline
449
+ // error card instead of blanking the whole canvas.
293
450
  const renderBlock = (b: Block) => {
294
451
  const id = b.id;
295
452
  const data = (b.data ?? {}) as Record<string, unknown>;
453
+ // Validate data shape BEFORE handing to the component switch. A
454
+ // validation failure shows an inline error card immediately, no
455
+ // need for the boundary to catch it.
456
+ const validation = validateBlock(b);
457
+ if (!validation.ok) {
458
+ return (
459
+ <div
460
+ key={id}
461
+ id={id}
462
+ className="glyph-block-error"
463
+ role="alert"
464
+ style={{
465
+ border: '1px solid var(--error, #f85149)',
466
+ background: 'rgba(248, 81, 73, 0.06)',
467
+ borderRadius: 8,
468
+ padding: '12px 14px',
469
+ margin: '12px 0',
470
+ color: 'var(--text)',
471
+ fontFamily: 'var(--font-mono, ui-monospace, monospace)',
472
+ fontSize: 12,
473
+ lineHeight: 1.55,
474
+ }}
475
+ >
476
+ <strong style={{ color: 'var(--error, #f85149)', fontFamily: 'var(--font-sans, system-ui, sans-serif)', display: 'block', marginBottom: 4 }}>
477
+ {b.type} block invalid
478
+ </strong>
479
+ <div style={{ color: 'var(--text-muted)', marginBottom: 6 }}>
480
+ block id: <code>{id}</code>
481
+ </div>
482
+ <pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{validation.error}</pre>
483
+ </div>
484
+ );
485
+ }
486
+ // Wrap the actual render in an error boundary so even runtime
487
+ // errors (bad component props, undefined data, etc.) don't crash
488
+ // the whole canvas.
489
+ return (
490
+ <BlockErrorBoundary key={id} blockId={id} blockType={b.type} onError={reportBlockError}>
491
+ {renderBlockInner(b, id, data)}
492
+ </BlockErrorBoundary>
493
+ );
494
+ };
495
+
496
+ // Inner renderer — split out so the outer renderBlock can validate +
497
+ // wrap in an error boundary without nesting the switch in the JSX tree.
498
+ const renderBlockInner = (b: Block, id: string, data: Record<string, unknown>) => {
296
499
  switch (b.type) {
297
500
  case 'RichText':
298
501
  return <RichText key={id} id={id}>{b.childrenMarkdown ?? ''}</RichText>;
@@ -506,13 +709,41 @@ export function GlyphRenderer({ slug, onClose, onCommentAdded }: Props) {
506
709
  </div>
507
710
  </header>
508
711
 
509
- {/* Compiler warnings */}
510
- {compiled.errors?.length > 0 && (
511
- <div className="glyph-errors">
512
- <strong>Compiler warnings:</strong>
513
- <ul>
514
- {compiled.errors.map((e, i) => (
515
- <li key={i}>{e}</li>
712
+ {/* v4.4.8 Render errors banner. Two layers:
713
+ 1. Compiler warnings from the MDX parser (e.g. unknown
714
+ tag, unclosed block). Lightweight, yellow border.
715
+ 2. Block render errors caught by BlockErrorBoundary. These
716
+ are the "one block crashed but the rest still rendered"
717
+ cases. Red border, prominent, listed first so the user
718
+ sees them before scrolling. */}
719
+ {(blockErrors.length > 0 || compiled.errors?.length > 0) && (
720
+ <div
721
+ className="glyph-render-errors"
722
+ role="alert"
723
+ style={{
724
+ border: '1px solid var(--error, #f85149)',
725
+ background: 'rgba(248, 81, 73, 0.08)',
726
+ borderRadius: 8,
727
+ padding: '12px 16px',
728
+ margin: '0 0 16px 0',
729
+ color: 'var(--text)',
730
+ }}
731
+ >
732
+ <strong style={{ color: 'var(--error, #f85149)', display: 'block', marginBottom: 8 }}>
733
+ {blockErrors.length > 0
734
+ ? `${blockErrors.length} block${blockErrors.length === 1 ? '' : 's'} failed to render`
735
+ : 'Compiler warnings'}
736
+ </strong>
737
+ <ul style={{ margin: 0, paddingLeft: 20, fontFamily: 'var(--font-mono, ui-monospace, monospace)', fontSize: 12, lineHeight: 1.6 }}>
738
+ {blockErrors.map((e, i) => (
739
+ <li key={`be-${i}`}>
740
+ <strong>{e.blockType}</strong> (<code>{e.blockId}</code>): {e.message}
741
+ </li>
742
+ ))}
743
+ {compiled.errors?.map((e, i) => (
744
+ <li key={`ce-${i}`}>
745
+ {e.line ? `line ${e.line}: ` : ''}{e.message}
746
+ </li>
516
747
  ))}
517
748
  </ul>
518
749
  </div>
@@ -1,174 +1,147 @@
1
1
  ---
2
2
  name: glyph
3
- description: Create and consume Bizar glyphs — visual plan/recap artifacts in `artifacts/<slug>/`. Glyphs are MDX files with frontmatter and a block vocabulary. Use for design proposals, decision recaps, and postmortems.
4
- version: 1
3
+ description: Create and consume Bizar glyphs — visual artifacts at `artifacts/<slug>/`. Use for plans, recaps, design proposals, postmortems, handoffs. Quick reference for the block vocabulary and the 5 things that actually break a glyph.
4
+ version: 3
5
5
  ---
6
6
 
7
- # Glyphs — Visual Plan Artifacts
7
+ # Glyphs
8
8
 
9
- A **glyph** is a visual artifact used for plans, recaps, and design proposals. It's an MDX file in `artifacts/<slug>/artifact.mdx` that the dashboard renders with comment pin overlay and right-click context menu.
10
-
11
- ## When to create a glyph
12
-
13
- - **Design proposal** — you're proposing a new component, system, or workflow
14
- - **Decision recap** — multiple options were considered; one was chosen; the reasoning matters
15
- - **Postmortem** — something broke; here's the timeline and root cause
16
- - **Implementation plan** — breaking down a non-trivial feature into phases
17
- - **Handoff** — work moving from one agent to another
18
-
19
- If it's a one-line fix, don't make a glyph. If it's a 5-minute edit, don't make a glyph. Glyphs are for work that needs visible structure.
20
-
21
- ## File structure
9
+ A **glyph** is an MDX file the dashboard renders with a comment-pin overlay and right-click menu. Three files per glyph:
22
10
 
23
11
  ```
24
12
  artifacts/<slug>/
25
- ├── meta.json # title, slug, status, author, created
26
- ├── artifact.mdx # ← source of truth (git-tracked, diff-friendly)
27
- └── comments.json # ← free-placed pins (mutable)
13
+ ├── meta.json title, status, author, created
14
+ ├── artifact.mdx ← source of truth
15
+ └── comments.json ← free-placed pins (mutable)
28
16
  ```
29
17
 
30
- The `meta.json` is metadata, `artifact.mdx` is the content, `comments.json` is the only mutable side-channel.
18
+ Two locations are scanned: `<projectRoot>/.bizar/artifacts/` (preferred) and `~/.config/opencode/artifacts/` (global).
31
19
 
32
20
  ## Frontmatter
33
21
 
34
22
  ```yaml
35
23
  ---
36
24
  title: "Short, action-oriented title"
37
- brief: "One sentence: what this glyph is about."
25
+ brief: "One sentence."
38
26
  status: draft | review | shipped | archived
39
27
  kind: plan | postmortem | recap | design
40
28
  ---
41
29
  ```
42
30
 
43
- ## Block vocabulary
31
+ ## The blocks at a glance
44
32
 
45
- ```mdx
46
- <RichText id="...">markdown content (headings, lists, code blocks, etc.)</RichText>
33
+ | Block | What it shows | Required `data` |
34
+ | --- | --- | --- |
35
+ | `<RichText>` | Prose, lists, code blocks | none — body is markdown |
36
+ | `<Callout tone="info\|warn\|success\|danger">` | Boxed callout | none (tone defaults to info) |
37
+ | `<Stat label value trend hint />` | Big number with trend | `label`, `value` |
38
+ | `<Checklist items={[...]} />` | Checkbox list | `items: [{id,label,checked}]` |
39
+ | `<Table columns rows />` | Tabular data | `columns: []`, `rows: [[]]` |
40
+ | `<CodeTabs tabs />` | Tabbed code blocks | `tabs: [{id,label,language,code}]` |
41
+ | `<Decision title question options />` | Multi-option choice | `options: [{id,label,detail,recommended?}]` |
42
+ | `<OpenQuestions questions />` | Unanswered questions | `questions: [{id,label,kind,options?}]` |
43
+ | `<FileTree title entries />` | File-by-file change list | `entries: [{path,change,note?}]` |
44
+ | `<Diff before after filename language mode />` | Before/after diff | `before`, `after` |
45
+ | `<Workflow steps connections />` | Node graph | `steps: [{id,label,type}]` |
46
+ | `<Mockup title x y w h html />` | Inline UI mockup | `html` |
47
+ | `<Diagram title dataHtml dataCss />` | Inline SVG | `dataHtml` |
47
48
 
48
- <Callout id="..." tone="info|warn|success|danger">markdown content</Callout>
49
+ Every block needs a unique `id`. `RichText` and `Callout` use open/close tags with markdown children; everything else is self-closing.
49
50
 
50
- <Stat id="..." label="..." value="..." trend="up|down|flat" hint="..." />
51
+ ## Skeleton
51
52
 
52
- <Checklist
53
- id="..."
54
- items={[
55
- { id: "i1", label: "...", checked: true },
56
- { id: "i2", label: "...", checked: false }
57
- ]}
58
- />
59
-
60
- <Table
61
- id="..."
62
- columns={["A", "B"]}
63
- rows={[["x", "y"], ["p", "q"]]}
64
- />
53
+ ```mdx
54
+ <RichText id="overview">
55
+ ## What this covers
65
56
 
66
- <CodeTabs
67
- id="..."
68
- tabs={[
69
- { id: "t1", label: "file.ts", language: "typescript", code: "...", caption: "..." }
70
- ]}
71
- />
57
+ A one-paragraph summary.
58
+ </RichText>
72
59
 
73
- <Decision
74
- id="..."
75
- title="..."
76
- question="..."
77
- options={[
78
- { id: "a", label: "...", detail: "...", recommended: true }
79
- ]}
80
- />
60
+ <Stat id="headline" label="X" value="7" trend="flat" />
81
61
 
82
- <OpenQuestions
83
- id="..."
84
- questions={[
85
- { id: "q1", label: "...", kind: "choice|text|multi", options: ["A", "B"] }
86
- ]}
87
- />
62
+ <Callout id="warning" tone="warn">
63
+ One important thing.
64
+ </Callout>
88
65
 
89
66
  <FileTree
90
- id="..."
91
- title="..."
67
+ id="files"
68
+ title="Files changed"
92
69
  entries={[
93
- { path: "src/foo.ts", change: "added|modified|removed|renamed", note: "..." }
70
+ { path: "src/foo.ts", change: "modified", note: "why" },
71
+ { path: "src/bar.ts", change: "added" },
94
72
  ]}
95
73
  />
96
74
 
97
75
  <Workflow
98
- id="..."
76
+ id="flow"
99
77
  steps={[
100
- { id: "s1", label: "...", type: "task|decision|note" }
78
+ { id: "s1", label: "Step 1", type: "task" },
79
+ { id: "s2", label: "Step 2", type: "task" },
101
80
  ]}
102
81
  connections={[
103
- { from: "s1", to: "s2", label: "yes" }
82
+ { from: "s1", to: "s2" },
104
83
  ]}
105
84
  />
106
-
107
- <Mockup
108
- id="..."
109
- title="..."
110
- x={40} y={120} w={280} h={180}
111
- html="<div class='mockup-card'>...</div>"
112
- />
113
-
114
- <Diagram
115
- id="..."
116
- title="..."
117
- dataHtml="<svg>...</svg>"
118
- dataCss=".diagram-node { fill: var(--bg-1); }"
119
- />
120
85
  ```
121
86
 
122
- ## Common pitfalls
87
+ ## The 5 things that break a glyph
123
88
 
124
- ### FileTree `change` field
89
+ Read this BEFORE writing. Each one is a real bug we've shipped.
125
90
 
126
- `change` must be exactly one of: `added`, `modified`, `removed`, `renamed`. Use the `note` field for nuance ("rewritten from scratch", "kept as-is", "deprecated").
91
+ ### 1. Backticks inside attribute strings
127
92
 
128
- ```mdx
129
- <FileTree
130
- entries={[
131
- { path: "src/chat/Composer.tsx", change: "modified", note: "rewritten as pill composer" }
132
- ]}
133
- />
93
+ The parser scans for `` ` `` to delimit template literals. A literal backtick inside a string — even inside double-quotes — closes the surrounding context and breaks parsing.
94
+
95
+ ```
96
+ WRONG: Run `bizar install` to bootstrap.
97
+ RIGHT: Run 'bizar install' to bootstrap.
98
+ RIGHT: Run the installer to bootstrap.
134
99
  ```
135
100
 
136
- ### MDX heading with `<N`
101
+ ### 2. The parser doesn't track strings when counting braces
137
102
 
138
- A heading like `## Mobile (under 768px)` is fine. But `## Mobile <768px` breaks the MDX validator because `<7` looks like a malformed JSX tag. Always escape or rephrase.
103
+ `{` and `}` inside string values still increment the brace depth walker. With brace-laden strings the walker terminates at the wrong closing brace and the value gets truncated.
139
104
 
140
- ### Block IDs must be unique within a glyph
105
+ **Rule:** keep string values brace-free. If you must use `{...}` in a note, escape or rephrase.
141
106
 
142
- If you copy a `<RichText>` block, change its `id`. The compiler may warn but won't fail.
107
+ ### 3. `FileTree` `change` field must be exact
143
108
 
144
- ### Children vs self-closing
109
+ `change` must be `added` | `modified` | `removed` | `renamed`. Anything else (empty string, `null`, typo) makes the renderer crash on `t.bg`.
145
110
 
146
- Blocks with content (RichText, Callout) use open/close tags with children. Blocks with only data (Stat, Table, FileTree, Decision, etc.) are self-closing.
111
+ ```
112
+ WRONG: change: null, change: "", change: "Modified"
113
+ RIGHT: change: "modified"
114
+ ```
115
+
116
+ When in doubt, use `modified` with a `note` that explains why.
117
+
118
+ ### 4. Two blocks with the same id
147
119
 
148
- ## meta.json example
120
+ Comment pins and error reporting are keyed by block id. Duplicates collide. Each `id` must be unique within a glyph.
149
121
 
150
- ```json
151
- {
152
- "title": "Chat UI — Complete Rewrite (Gemini-Inspired)",
153
- "slug": "chat-ui-rewrite",
154
- "status": "draft",
155
- "author": "drb0rk",
156
- "created": "2026-06-26T20:30:00.000Z",
157
- "lastEdited": "2026-06-26T20:30:00.000Z"
158
- }
122
+ ### 5. MDX heading with `<N`
123
+
124
+ ```
125
+ WRONG: ## Mobile <768px ← <7 looks like a malformed tag
126
+ RIGHT: ## Mobile (under 768px)
127
+ RIGHT: ## Mobile: under 768px
159
128
  ```
160
129
 
161
- ## How to view in the dashboard
130
+ ## Validate before shipping
131
+
132
+ ```bash
133
+ node -e "import('./bizar-dash/src/server/glyphs/mdx-compiler.mjs').then(m => { \
134
+ const fs = require('node:fs'); \
135
+ const out = m.compileGlyphMdxSync(fs.readFileSync('artifacts/<slug>/artifact.mdx','utf8')); \
136
+ console.log('blocks:', out.blocks.length, 'errors:', out.errors.length); \
137
+ out.errors.forEach(e => console.error(' line', e.line, ':', e.message)); \
138
+ })"
139
+ ```
162
140
 
163
- Glyphs are rendered at `/artifacts/<slug>`. The dashboard shows them with:
164
- - Section grouping (by block id prefix: `overview`, `implementation`, `questions`)
165
- - Comment pin overlay (right-click to add)
166
- - Floating toolbar (Send to agent, share)
167
- - Read-only by default; `status: shipped` locks editing
141
+ If errors > 0, fix the MDX before opening the glyph in the browser. When a block DOES fail in the browser, v4.4.8+ shows a red banner with block id + error message — no more blank screen.
168
142
 
169
143
  ## See also
170
144
 
171
- - `bizar-dash/src/web/views/glyphs/components.tsx` — block component implementations
172
- - `bizar-dash/src/server/glyphs/mdx-compiler.mjs` — MDX parser
173
- - `artifacts/sample-plan-login/`a complete worked example
174
- - The user's Obsidian vault for the project's design notes
145
+ - `bizar-dash/src/server/glyphs/mdx-compiler.mjs` — parser
146
+ - `bizar-dash/src/web/views/glyphs/GlyphRenderer.tsx` — React renderer (has the error banner)
147
+ - `bizar-dash/src/web/views/glyphs/components.tsx`block component implementations
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar",
3
- "version": "4.4.7",
3
+ "version": "4.4.9",
4
4
  "description": "Norse-pantheon multi-agent system for opencode — 13 agents across 4 cost tiers with cost-aware routing, plans, and a configurable agent harness. v4 ships as a single npm package bundling the dashboard server, opencode plugin, and typed SDK.",
5
5
  "type": "module",
6
6
  "bin": {