@bicharts/chart-mcp 0.6.52 → 0.6.55

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bicharts/chart-mcp",
3
- "version": "0.6.52",
3
+ "version": "0.6.55",
4
4
  "description": "MCP (Model Context Protocol) stdio server for BIC AI charts: profile a dataset locally, then generate chart code that has passed the BIC backend's render gates. Works with any MCP-capable client (Claude Code, Claude Desktop, Cursor, Copilot Studio). Requires a BIC trial or paid account.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -41,17 +41,21 @@
41
41
  "prestart": "node build.mjs",
42
42
  "start": "node dist/index.mjs"
43
43
  },
44
- "dependencies": {
45
- "@modelcontextprotocol/sdk": "^1.0.0",
46
- "papaparse": "^5.4.1",
47
- "zod": "^3.23.0"
48
- },
44
+ "// dependencies": "NONE, deliberately. dist/ bundles every module the server uses, so a runtime dependency installs nothing it needs - it only makes npx fetch that package (and its tree) before the first start. Everything the bundle carries is a devDependency; scripts/verify-package.mjs fails the release if a dependency appears or a shipped file imports anything but Node's built-ins.",
49
45
  "devDependencies": {
50
46
  "@bicharts/chart-host": "^0.6.52",
51
47
  "@bicharts/shape-core": "^0.6.52",
48
+ "@modelcontextprotocol/sdk": "^1.0.0",
52
49
  "@types/node": "^20.0.0",
53
50
  "@types/papaparse": "^5.3.14",
51
+ "d3": "^7.9.0",
52
+ "d3-hexbin": "^0.2.2",
53
+ "d3-sankey": "^0.12.3",
54
+ "d3-voronoi-treemap": "^1.1.2",
54
55
  "esbuild": "^0.28.1",
55
- "typescript": "^5.4.0"
56
+ "happy-dom": "^20.14.5",
57
+ "papaparse": "^5.4.1",
58
+ "typescript": "^5.4.0",
59
+ "zod": "^3.23.0"
56
60
  }
57
61
  }
@@ -59,7 +59,7 @@ on the same picture, not a looser one.
59
59
  | Flippable multi-card | 1 continuous | <=12 categories, needs 180x120 px | D3 | Best when more headline numbers compete for one tile than fit at once: several measures side by side, or one or two measures paged through a small category, with the details on hover |
60
60
  | Funnel chart | 1 continuous | <=12 categories, needs 460x340 px | D3, PLOTLY | Best for conversion/attrition pipelines: signup funnels, sales pipeline stages, checkout flow drop-off |
61
61
  | Gantt chart | 1 categorical, 2 temporal | <=60 categories, needs 420x260 px | D3 | Best for project and phase schedules: tasks with a start and an end date, optionally grouped by phase, workstream or owner |
62
- | Gauge | 1 continuous, a bounded scale | needs 140x140 px | D3 | One value against a ceiling the data states - a percentage, a rate, or a measure that has a target |
62
+ | Gauge | 1 continuous, a bounded scale | needs 140x140 px | D3 | A value against a ceiling the data states - a percentage, a rate, or a measure that has a target - overall or one dial per category; a constant value is a valid reading |
63
63
  | Grouped bar chart | 2 categorical, 1 continuous | needs 700x480 px | D3, Matplotlib, Plotly, Seaborn | Requires one categorical, one subcategory, and one numeric variable |
64
64
  | Heatmap | 2 categorical, 1 continuous | <=50 categories, needs 460x340 px | D3, Matplotlib, Plotly, Seaborn | Requires two categorical or ordinal fields for the rows and columns, and one measure for the cell colour |
65
65
  | Hexbin plot | 2 continuous | needs 280x280 px | D3, Matplotlib, Plotly, Seaborn | Requires two continuous variables |
@@ -71,7 +71,7 @@ on the same picture, not a looser one.
71
71
  | KDE plot (1D & 2D) | 1 continuous | needs 400x300 px | D3, Matplotlib, Plotly, Seaborn, Statsmodels | Requires one continuous variable (or two for 2D KDE) |
72
72
  | Lag plot (scatter of value vs lag) | 1 continuous, 20+ time points | needs 280x280 px | D3, Matplotlib, Plotly, Seaborn, Statsmodels | Requires a datetime series with one continuous variable |
73
73
  | Line chart | 1 continuous | needs 400x300 px | D3, Matplotlib, Plotly, Seaborn | One or more continuous series over an ordered/temporal axis |
74
- | Linear gauge | 1 continuous, a bounded scale | needs 180x50 px | D3 | One value against a ceiling the data states, in a short wide tile - percent complete, utilisation, progress to a target |
74
+ | Linear gauge | 1 continuous, a bounded scale | needs 180x50 px | D3 | A value against a ceiling the data states, overall or one track per category, in a short wide tile - percent complete, utilisation, progress to a target; a constant value is a valid reading |
75
75
  | Lollipop chart | 1 categorical, 1 continuous | <=50 categories, needs 600x420 px | D3, PLOTLY | Best as a cleaner bar replacement for ranking many categories by a single measure |
76
76
  | Marimekko / Mosaic plot | 2 categorical, 1 continuous | needs 320x280 px | D3 | Best for 3-10 outer × 3-10 inner categories with a positive measure |
77
77
  | Mermaid diagram | 1 categorical | <=150 categories, needs 360x240 px | D3 | A source and a target column (a process, a hand-off chain, a dependency list), two to four nested categories (an org or product hierarchy), or a date beside an event name (milestones, releases, history) - up to a few dozen boxes |
@@ -87,12 +87,13 @@ on the same picture, not a looser one.
87
87
  | Pareto chart | 1 categorical | <=30 categories, needs 320x280 px | D3, MATPLOTLIB, PLOTLY | Best for finding the vital few: defect/complaint/incident counts by cause, revenue concentration by product or customer (the 80/20 view) |
88
88
  | Pie chart | 1 categorical | <=10 categories, needs 280x280 px | D3, Matplotlib, Plotly | Requires one categorical variable with proportions |
89
89
  | Polar plot | 1 continuous | needs 299x283 px | D3, Matplotlib, Plotly | Requires angle and radius (two continuous variables) |
90
- | Progress ring | 1 continuous, a bounded scale | needs 160x160 px | D3 | One value against a ceiling the data states, in a small or square tile - percent complete, attainment, utilisation |
90
+ | Progress ring | 1 continuous, a bounded scale | needs 160x160 px | D3 | A value against a ceiling the data states, overall or one ring per category, in a small or square tile - percent complete, attainment, utilisation; a constant value is a valid reading |
91
91
  | Radar (spider) chart | 1 continuous | <=8 axes, needs 700x480 px | D3, Matplotlib, Plotly | Requires multiple continuous variables across categories |
92
92
  | Range-compare time series | 1 continuous, 15+ time points | needs 480x320 px | D3 | Best for a single measure tracked over many dates when the story is a chosen window: drag the two handles to total a period and compare its endpoints, like revenue across a campaign or sensor readings around an incident |
93
93
  | Regression plot (line with CI) | 2 continuous | needs 520x380 px | D3, Matplotlib, Plotly, Seaborn, Statsmodels | Requires two continuous variables - a predictor and a response |
94
94
  | Ridgeline plots | 1 categorical, 1 continuous | <=25 categories, needs 280x280 px | D3, Matplotlib, Plotly, Seaborn | Best with many observations per category |
95
95
  | Rose (Coxcomb) chart | 1 categorical | <=24 categories, needs 300x300 px | D3 | Best for cyclical or directional categories: wind direction, hour of day, month of year, compass sectors - anywhere the categories wrap around rather than running left to right |
96
+ | Rotating carousel | 1 continuous | <=12 categories, needs 320x200 px | D3 | Best when each category value deserves its own small chart and the tile has room for only one at a time; the reader turns it, or it turns itself |
96
97
  | Rug plot | 1 continuous | needs 360x280 px | D3, Matplotlib, Plotly, Seaborn | Requires one continuous variable |
97
98
  | Sankey alluvial flow | 2 categorical, 1 continuous | <=10 categories, <=80 edges, needs 420x280 px | D3, Matplotlib, Plotly | Requires at least two categorical variables |
98
99
  | Scatter plot | 2 continuous | needs 460x340 px | D3, Matplotlib, Plotly, Seaborn | Requires two continuous variables |
@@ -108,7 +109,7 @@ on the same picture, not a looser one.
108
109
  | Sunburst chart | 2 categorical, 1 continuous | needs 400x300 px | D3, Matplotlib, Plotly | Requires hierarchical categorical data |
109
110
  | Tabular with embedded | 1 categorical, 1 continuous | <=40 categories, needs 420x200 px | D3 | Best for a small set of items compared across several attributes at once (a forecast/roster/scorecard table) |
110
111
  | Ternary plot | 3 continuous | needs 320x320 px | D3 | Three measures that add up to the same total on every row - product mix, budget allocation, vote share, channel split |
111
- | Thermometer | 1 continuous, a bounded scale | needs 90x220 px | D3 | One value against a ceiling the data states, in a tall narrow tile - a sidebar rail or a column of KPIs down a page edge |
112
+ | Thermometer | 1 continuous, a bounded scale | needs 90x220 px | D3 | A value against a ceiling the data states, overall or one tube per category, in a tall narrow tile - a sidebar rail or a column of KPIs down a page edge; a constant value is a valid reading |
112
113
  | Time series plot | 1 continuous | needs 460x340 px | D3, Plotly | Requires a datetime axis with at least one continuous variable |
113
114
  | Trail | 2 continuous | needs 360x220 px | D3 | Best when a series has both a level and a weight: price with traded volume, sentiment with mention count, a metric with the sample size behind it |
114
115
  | Treemap | 1 categorical, 1 continuous | needs 240x220 px | D3, Matplotlib, Plotly | Requires hierarchical categorical data with values |
@@ -21,6 +21,16 @@ Skip this if you already have a table. If you are holding a semantic model, a wa
21
21
  a database, **you** pick the fields — the tools take a table, not a model, and they cannot see
22
22
  what you can. Three decisions, and the third is the one that goes wrong.
23
23
 
24
+ **Asked which charts suit the model itself** ("good chart types for this dataset", "what could we
25
+ chart here")? Over a semantic model in a Fabric App, `suggest_charts` answers in one call: it reads
26
+ the model's tables, measures and relationships, finds its natural views (measures by place, flows
27
+ between places, measures over time, measures by category), queries each at its grain, keeps the
28
+ measures that vary there and asks the eligibility engine what fits each. Answer from its reply -
29
+ the counts in it are the model's own - and build the one the person picks from the rows it saved
30
+ (`.bic/data/<view>.csv`, the DAX beside it). Pass `views` (`columns` as `Table[Column]`, `measures`
31
+ by name, or your own `dax`) to ask about other groupings. Elsewhere, pull each candidate grouping
32
+ and ask `list_eligible_charts` about each.
33
+
24
34
  **Fields and filters** follow from the question. *"All loans over the last 6 months, highlighting
25
35
  those which defaulted, paying attention to credit score"* wants `LoanId`, `Defaulted`,
26
36
  `CreditScore`, filtered to six months.
@@ -464,8 +474,14 @@ build.
464
474
  `src/lib/bic-chart-options.ts` (`useBicChartOptions()`: the app's Fabric theme - palette, foreground,
465
475
  background - as chart options, so charts follow light and dark) and `src/components/bic-chart-panel.tsx`
466
476
  (`<BicChartPanel result={useSemanticModelQuery(...)}>`: a box that measures itself and shows the query's
467
- loading, error and empty states), and ignores `.bic/`. It never overwrites a file, and its reply ends with a
468
- worked example.
477
+ loading, error and empty states), and ignores `.bic/`. It also puts every `@microsoft/rayfin-*` package on one version -
478
+ npm's latest, the release `rayfin init` installs, so run it before `rayfin init` and init has nothing left to split
479
+ (`rayfin init` installs the latest of only some packages, which leaves two copies of `rayfin-auth` on any other version).
480
+ Its reply lists every `package.json` range it changed and names the Rayfin version installed; an app already on npm's
481
+ latest is left alone. It fixes the template's own `scripts/validate-visual.spec.mjs`, which fails under ESM on a fresh
482
+ scaffold (its reply says so), and adds
483
+ `.agents/skills/regiabi-charts/SKILL.md` beside the template's skills, for agents that read that folder. It never
484
+ overwrites a file, and its reply ends with a worked example.
469
485
 
470
486
  **1. Pull each chart's rows at the chart's GRAIN, with a query written for that chart.** One row per
471
487
  country for a country map, one per route for a flow map, one per month for a trend. The template's
@@ -484,7 +500,7 @@ simply offered other charts. Save the result to a file and pass `csv_path`, or p
484
500
  `RevenuePerCapita`, and apply the SAME rename to the live rows at run time - the chart reads columns by
485
501
  name. Pass the model's metadata as you rename: `isMeasure`, `format` and `description` (from
486
502
  `INFO.VIEW.MEASURES()` / `INFO.VIEW.COLUMNS()`) go on each column in `data`, which is the context a
487
- Power BI visual gets for free.
503
+ Power BI visual gets for free. In `INFO.VIEW.COLUMNS()` a column's name is `[Name]`, not `[Column]`.
488
504
 
489
505
  **Steps 1-3 are one call: `query_semantic_model`.** Give it the chart's DAX (`dax`, or a `.dax` file) and a
490
506
  `name`; it runs the query through the project's own `fabric-app-data` CLI, renames the headers (dropping the
@@ -568,40 +584,200 @@ function CountryMap({ table, onPick }) { // table = the live query resu
568
584
  - **`<BicChartGroup destination>` needs `@bicharts/chart-host` 0.6.50 or later.** Before it, a flow
569
585
  map's far end is not built.
570
586
 
587
+ **Layout: a chart is never clipped or squashed.** Charts sit in cards, and a card is where they get cut off.
588
+ - Render every chart inside `BicChartPanel`. It sizes the chart from its cell, never from the chart.
589
+ - Give each chart its minimum: `minChartWidth` and `minChartHeight` (a world map about 560 x 300; a time series wants
590
+ height). Below that the panel scrolls; it never squashes or clips the chart.
591
+ - Take the actual size from the layout - percent, flex or grid `fr` - and never a fixed height smaller than the chart's
592
+ minimum.
593
+ - Put `min-h-0` on every flex child between a card and its chart (`min-w-0` in a row). A flex item's default
594
+ `min-height: auto` lets it grow past its card, and the card's `overflow-hidden` slices off the rest.
595
+
571
596
  **6. Cross-filter across queries by KEY, not by row.** Charts fed by different queries don't share a
572
597
  row space, so one group can't span them. Put each query's charts in their own group, turn a click
573
598
  into the model's key (`CountryCode` above), keep it in app state, and filter the other queries with
574
599
  it. Anything saved against a mark - a note, a flag - is keyed the same way, never by `__rowIdx__`
575
600
  (see the row-index hazard above).
576
601
 
577
- **Notes (or flags) shown ON a mark: pass `annotations`, never measure the chart.** The host draws the
578
- badge on the key's mark after every render and keeps it there through filters, re-queries and zooms;
579
- it never reads a row position. From a notes table keyed by the model's key:
602
+ **Notes (or flags) on marks, end to end.** People leave a note on a country, a route, a product, and see
603
+ it on that mark: the notes live in Rayfin's data service, and the badge is the chart's `annotations`.
604
+ This recipe was checked against Rayfin's own docs for Rayfin 1.36.1 (a preview), the version
605
+ `setup_fabric_app` installs as this is written, and on that version it IS the version-matched source:
606
+ follow it as written, and read Rayfin's docs (`node_modules/@microsoft/rayfin-guide/assets/docs/data/`)
607
+ only for what it doesn't cover. `setup_fabric_app`'s reply names the version it installed; where that
608
+ differs from 1.36.1, Rayfin's docs win.
580
609
 
581
- ```tsx
582
- <BivariateWorldChoroplethChart table={table} {...size} options={options}
583
- annotations={notes.map(n => ({ column: "CountryCode", value: n.countryCode,
584
- label: String(n.count), title: n.latestText }))}
585
- onAnnotationClick={a => openNotes(a.value)} />
610
+ *a. Turn the data service on.* The data app template ships it off. Run `setup_fabric_app` first (it puts every `@microsoft/rayfin-*` package on one version:
611
+ npm's latest, the release `rayfin init` installs), then:
612
+
613
+ ```bash
614
+ npx rayfin init --services auth,data --auth-methods fabric --dialect mssql --overwrite --project-name <id from rayfin/rayfin.yml> .
615
+ ```
616
+
617
+ Each flag is needed: without `--overwrite` it prints "Initialization cancelled" and exits 0 with no
618
+ reason; without `--project-name` (pass the `id:` already in `rayfin/rayfin.yml`, so nothing is renamed)
619
+ it stops with "--project-name is required in non-interactive mode". It rewrites `rayfin/rayfin.yml`
620
+ and `.gitignore`, so `git diff` both and put back what's yours (on 1.36.1 it dropped the template's
621
+ `src/fabric.generated.ts` line from `.gitignore`). It writes `rayfin/tsconfig.json` - keep its
622
+ `outDir`, `.temp/compiled`, which is where the deploy reads the entities from - and no entities.
623
+
624
+ *b. Make it type-check.* Three edits, each needed on the data app template:
625
+ - `rayfin/tsconfig.json`, in `compilerOptions`: `"tsBuildInfoFile": ".temp/tsconfig.tsbuildinfo"` and
626
+ `"allowImportingTsExtensions": false` (it inherits both from the root: TS6377 and TS5096 otherwise).
627
+ - the root `tsconfig.json`: `"references": [{ "path": "./rayfin" }]`.
628
+ - type the client with your schema: `RayfinClient<AppSchema>` in `src/lib/rayfin-client.ts`
629
+ (`import type { AppSchema } from "../../rayfin/data/schema";`) and in the constructor of
630
+ `src/services/rayfin-auth.service.ts` (TS2345 otherwise).
631
+
632
+ Check with `npx tsc -b`; a bare `tsc --noEmit` reports TS6305 until the `rayfin/` project is built. If a
633
+ TS2345 names two copies of `rayfin-auth`, something installed only some Rayfin packages: run
634
+ `setup_fabric_app` again.
635
+
636
+ *c. The entity, keyed by the model's key - never by a row position.* `rayfin/data/MarkNote.ts`, then
637
+ register it in `rayfin/data/schema.ts` (`export type AppSchema = { MarkNote: MarkNote };`):
638
+
639
+ ```ts
640
+ import { entity, authenticated, uuid, text, date } from '@microsoft/rayfin-core';
641
+
642
+ @entity()
643
+ @authenticated(['read', 'create'])
644
+ export class MarkNote {
645
+ @uuid() id!: string;
646
+ @text({ max: 64 }) chart!: string; // which chart: "country-map", "lanes"
647
+ @text({ max: 128 }) markKey!: string; // the mark's model key (CountryCode)
648
+ @text({ max: 128, optional: true }) markKeyTo?: string; // a route's other end; empty for one mark
649
+ @text({ max: 2000 }) body!: string;
650
+ @text({ max: 320 }) author!: string;
651
+ @date() createdAt!: Date;
652
+ }
653
+ ```
654
+
655
+ Give every entity an explicit permission decorator (without one, any signed-in user gets every
656
+ action), and every `@text` a `max` (without it MSSQL gets `NVARCHAR(MAX)` columns, which can break
657
+ the deployed GraphQL schema). Use `.js` extensions on relative imports between entity files.
658
+
659
+ *d. Read every note, write one.* `.execute()` returns one page (100 rows) and doesn't say there are
660
+ more, so page through:
661
+
662
+ ```ts
663
+ // src/lib/mark-notes.ts
664
+ import type { MarkAnnotation } from "@bicharts/chart-host";
665
+ import { getRayfinClient } from "@/lib/rayfin-client";
666
+ import type { MarkNote } from "../../rayfin/data/MarkNote";
667
+
668
+ const FIELDS = ["id", "chart", "markKey", "markKeyTo", "body", "author", "createdAt"] as const;
669
+
670
+ export async function loadMarkNotes(): Promise<MarkNote[]> { // newest first
671
+ const all: MarkNote[] = [];
672
+ let after: string | undefined;
673
+ do {
674
+ const q = getRayfinClient().data.MarkNote.select([...FIELDS]).orderBy({ createdAt: "desc" }).first(100);
675
+ const page = await (after ? q.after(after) : q).executePaginated();
676
+ all.push(...page.items);
677
+ after = page.hasNextPage ? page.endCursor ?? undefined : undefined;
678
+ } while (after);
679
+ return all;
680
+ }
681
+
682
+ export function addMarkNote(n: Pick<MarkNote, "chart" | "markKey" | "markKeyTo" | "body">, author: string) {
683
+ return getRayfinClient().data.MarkNote.create({ ...n, author, createdAt: new Date() });
684
+ }
586
685
  ```
587
686
 
588
- A route is keyed by BOTH of its ends with `where` - no key column to add, no query to change and no
589
- chart to regenerate:
687
+ In a hook, load inside the effect - `loadMarkNotes().then(all => { if (live) setNotes(all); })` with a
688
+ `live` flag the cleanup clears; the template's lint rejects an effect that calls a state-setting
689
+ function directly. The author is the signed-in user: `useAuth().session?.user?.email`.
690
+
691
+ *e. Badges on the marks: pass `annotations`, never measure the chart.* The host draws each badge on
692
+ its key's mark after every render and keeps it there through filters, re-queries and zooms; it never
693
+ reads a row position. One key column is `column`/`value`. A route is keyed by BOTH of its ends with
694
+ `where` - no key column to add, no query to change and no chart to regenerate:
695
+
696
+ ```ts
697
+ /** One badge per mark: a single key column, or a route's two ends. */
698
+ export function noteBadges(notes: MarkNote[], chart: string, key: [string] | [string, string]): MarkAnnotation[] {
699
+ const byMark = new Map<string, MarkNote[]>();
700
+ for (const n of notes) {
701
+ if (n.chart !== chart) continue;
702
+ const id = n.markKeyTo ? `${n.markKey}|${n.markKeyTo}` : n.markKey;
703
+ byMark.set(id, [...(byMark.get(id) ?? []), n]);
704
+ }
705
+ return [...byMark].map(([id, list]) => ({
706
+ id, label: String(list.length), title: list[0].body,
707
+ ...(key.length === 2
708
+ ? { where: { [key[0]]: list[0].markKey, [key[1]]: list[0].markKeyTo ?? "" } }
709
+ : { column: key[0], value: list[0].markKey }),
710
+ }));
711
+ }
712
+ ```
590
713
 
591
714
  ```tsx
592
- annotations={laneNotes.map(n => ({ where: { OriginCountryCode: n.origin, DestinationCountryCode: n.dest },
593
- label: String(n.count), title: n.latestText }))}
715
+ <BivariateWorldChoroplethChart table={table} {...size} options={options}
716
+ annotations={noteBadges(notes, "country-map", ["CountryCode"])}
717
+ onAnnotationClick={a => setOpenMark(a.id ?? null)} />
718
+ // a route by both ends: annotations={noteBadges(notes, "lanes", ["OriginCountryCode", "DestinationCountryCode"])},
719
+ // one badge per route, each { where: { OriginCountryCode: n.origin, DestinationCountryCode: n.dest }, label, title }
594
720
  ```
595
721
 
596
722
  Don't read `data-row-idx` or bounding boxes to place anything: that breaks on the next redraw, and
597
723
  this does it for you.
598
724
 
725
+ *f. Open the notes over the page, never inside the chart's card.* Open them from `onAnnotationClick`
726
+ for a mark that has notes, and for a mark's first note from your own "Add a note" action on the
727
+ selected mark (`onSelectRows` gives you its key). Show them in a dialog, drawer or popover that
728
+ overlays the page through a portal: a chart's card has a fixed height and `overflow-hidden`, so a
729
+ panel inside it is clipped.
730
+
731
+ ```tsx
732
+ import { useState } from "react";
733
+ import { createPortal } from "react-dom";
734
+
735
+ export function NotesDialog({ title, notes, onAdd, onClose }: {
736
+ title: string; notes: MarkNote[]; onAdd: (body: string) => void; onClose: () => void;
737
+ }) {
738
+ const [draft, setDraft] = useState("");
739
+ return createPortal(
740
+ <div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 1000, display: "grid",
741
+ placeItems: "center", background: "rgb(0 0 0 / 0.35)" }}>
742
+ <div role="dialog" aria-modal="true" aria-label={title} onClick={e => e.stopPropagation()}
743
+ style={{ width: "min(480px, 92vw)", maxHeight: "80vh", overflow: "auto", padding: 16,
744
+ borderRadius: 8, background: "Canvas", color: "CanvasText" }}>
745
+ <h2>{title}</h2>
746
+ <ul>{notes.map(n => <li key={n.id}>{n.body} - {n.author}, {new Date(n.createdAt).toLocaleString()}</li>)}</ul>
747
+ <textarea value={draft} onChange={e => setDraft(e.target.value)} maxLength={2000} />
748
+ <button disabled={!draft.trim()} onClick={() => { onAdd(draft.trim()); setDraft(""); }}>Add note</button>
749
+ <button onClick={onClose}>Close</button>
750
+ </div>
751
+ </div>,
752
+ document.body);
753
+ }
754
+ ```
755
+
756
+ Style it with the app's own tokens. The table and the permissions are created on the next
757
+ `npx rayfin up`, and every later `rayfin up` applies entity changes.
758
+
759
+ Don't stand up a backend to test notes; the deploy creates the tables. Finish with `npx tsc -b` and the tests, and say the notes go live on deploy.
760
+
599
761
  **7. A reader's choice inside a chart is view state.** A what-if chart's rate and horizon are its
600
762
  own controls, reported through `viewState` on `<BicChart>` (`uiState` / `setUiState` in the
601
763
  integration contract). To save a named scenario, store that - don't rebuild the slider beside it.
602
764
 
603
- **8. What stays the template's job:** sign-in, the query hooks, the app's database, `VisualContainer`
604
- headers, deployment (`npx rayfin up`). The chart is a function you render inside them.
765
+ **8. What stays the template's job:** sign-in, the query hooks, the app's database (Rayfin's data
766
+ service - the notes recipe above), `VisualContainer` headers, deployment. The chart is a function you
767
+ render inside them.
768
+
769
+ **9. Check every chart, then deploy.** Check every chart locally with this server's `check_chart` tool,
770
+ at the size the panel will give it - by default it reads the chart's `minChartWidth` and `minChartHeight`
771
+ off its `BicChartPanel`, so pass no size unless you mean another one. A `WARNING` line in its reply is a
772
+ label likely cut off at that size: deal with it before you call the chart done.
773
+ You don't need to deploy to check a chart. Then `npx tsc -b` and the tests. Deploying (`npx rayfin up`), on Rayfin 1.36.1:
774
+ - Signed in? `npx rayfin login status` (a subcommand, not a `--status` flag); `npx rayfin login` if not.
775
+ - Name the workspace: `npx rayfin up -w "<workspace>"`. Left out, it deploys to My Workspace.
776
+ - The Fabric item is named by `id:` in `rayfin/rayfin.yml`, not `name:`. Two projects with the same
777
+ `id:` in one workspace deploy into one item: give each its own `id:`, or deploy with
778
+ `--item-name <unique-name>`. Pass `--yes` to reuse an existing item only when you know it's yours.
779
+ - Deleted the app outside Rayfin? `rayfin/.deployments.json` still names the deleted item, so the next
780
+ `rayfin up` fails with 404 Not Found. Move that file aside and deploy again.
605
781
 
606
782
  ## 4. Things that will otherwise cost you time
607
783