@bicharts/chart-mcp 0.6.51 → 0.6.54

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.51",
3
+ "version": "0.6.54",
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
- "@bicharts/chart-host": "^0.6.51",
51
- "@bicharts/shape-core": "^0.6.51",
46
+ "@bicharts/chart-host": "^0.6.52",
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 |
@@ -460,6 +460,16 @@ A Fabric App built from Rayfin's `dataapp` template is a React app over a semant
460
460
  path above applies unchanged; these are the places a data app differs, each one measured on a real
461
461
  build.
462
462
 
463
+ **0. Once, right after scaffolding: `setup_fabric_app`.** It installs `@bicharts/chart-host` and `d3`, adds
464
+ `src/lib/bic-chart-options.ts` (`useBicChartOptions()`: the app's Fabric theme - palette, foreground,
465
+ background - as chart options, so charts follow light and dark) and `src/components/bic-chart-panel.tsx`
466
+ (`<BicChartPanel result={useSemanticModelQuery(...)}>`: a box that measures itself and shows the query's
467
+ loading, error and empty states), and ignores `.bic/`. It also puts every `@microsoft/rayfin-*` package on one version
468
+ (the template installs one too old to deploy, and `rayfin init` later upgrades only some of them - its reply lists every
469
+ `package.json` range it changed; an app already on one deployable version is left alone), and adds
470
+ `.agents/skills/regiabi-charts/SKILL.md` beside the template's skills, for agents that read that folder. It never
471
+ overwrites a file, and its reply ends with a worked example.
472
+
463
473
  **1. Pull each chart's rows at the chart's GRAIN, with a query written for that chart.** One row per
464
474
  country for a country map, one per route for a flow map, one per month for a trend. The template's
465
475
  writing-phase command is `npx fabric-app-data query <alias> --query '<DAX>'`. Don't pull a fact
@@ -479,6 +489,15 @@ name. Pass the model's metadata as you rename: `isMeasure`, `format` and `descri
479
489
  `INFO.VIEW.MEASURES()` / `INFO.VIEW.COLUMNS()`) go on each column in `data`, which is the context a
480
490
  Power BI visual gets for free.
481
491
 
492
+ **Steps 1-3 are one call: `query_semantic_model`.** Give it the chart's DAX (`dax`, or a `.dax` file) and a
493
+ `name`; it runs the query through the project's own `fabric-app-data` CLI, renames the headers (dropping the
494
+ table and any symbols: `[Return Rate %]` becomes `ReturnRate`), saves every row to `.bic/data/<name>.csv`,
495
+ reads each column's role, format and description from the model (`INFO.VIEW.MEASURES()` / `INFO.VIEW.COLUMNS()`),
496
+ and profiles the result. Its reply ends with the arguments for the next two calls - `csv_path`, `measures`,
497
+ `dimensions`, `formats`, `descriptions` - so pass them to `list_eligible_charts` and `generate_chart` as they
498
+ are. The CLI returns at most 1,000 rows; the tool says so when it cut a result, and the fix is a query at the
499
+ chart's grain.
500
+
482
501
  **4. Ask before choosing, then choose what answers the question.** `list_eligible_charts` on that
483
502
  data. The catalogue goes past the template's own charts, and these are the ones people describe
484
503
  without naming:
@@ -495,7 +514,30 @@ when your table is small, and it says when `name_filter` hid description matches
495
514
 
496
515
  **5. Render live rows through the group, with the binding the generation returned.** The template
497
516
  queries at run time (`useSemanticModelQuery`), so the payload is rebuilt from live rows every time -
498
- never hand-built. `generate_chart` returns `binding` (`geo`, `point`, `destination`); pass all three:
517
+ never hand-built. **With `out_dir`, a D3 chart arrives with `chart.component.tsx` beside `chart.js`: a component that
518
+ already does all of this** - the columns, the binding (`geo`, `point`, `destination`), the geometry kind, the
519
+ d3 plugins the chart calls, and `rowsFromTable`, which maps a DAX result's `Table[Column]` and `[Measure]`
520
+ headers onto the chart's plain names. Use it as it is:
521
+
522
+ ```tsx
523
+ // The component is named after the chart type ("Bivariate World Choropleth" -> BivariateWorldChoroplethChart).
524
+ import { BivariateWorldChoroplethChart, rowsFromTable } from "@/charts/country-map/chart.component";
525
+
526
+ import { BicChartPanel } from "@/components/bic-chart-panel";
527
+ import { useBicChartOptions } from "@/lib/bic-chart-options";
528
+
529
+ const result = useSemanticModelQuery({ connection: "<alias>", query: QUERY }); // the DAX it was generated on
530
+ const options = useBicChartOptions();
531
+ <BicChartPanel result={result}>
532
+ {(table, size) => ( // table = the live { columns, rows } result
533
+ <BivariateWorldChoroplethChart table={table} {...size} options={options}
534
+ onSelectRows={picked => onPick(picked[0]?.CountryCode ?? null)} />
535
+ )}
536
+ </BicChartPanel>
537
+ ```
538
+
539
+ It is rewritten with the chart on every generation, so keep app logic outside it. What it does, if you
540
+ host by hand instead - `generate_chart` returns `binding`; pass all three:
499
541
 
500
542
  ```tsx
501
543
  import * as d3 from "d3";
@@ -529,18 +571,192 @@ function CountryMap({ table, onPick }) { // table = the live query resu
529
571
  - **`<BicChartGroup destination>` needs `@bicharts/chart-host` 0.6.50 or later.** Before it, a flow
530
572
  map's far end is not built.
531
573
 
574
+ **Layout: a chart is never clipped or squashed.** Charts sit in cards, and a card is where they get cut off.
575
+ - Render every chart inside `BicChartPanel`. It sizes the chart from its cell, never from the chart.
576
+ - Give each chart its minimum: `minChartWidth` and `minChartHeight` (a world map about 560 x 300; a time series wants
577
+ height). Below that the panel scrolls; it never squashes or clips the chart.
578
+ - Take the actual size from the layout - percent, flex or grid `fr` - and never a fixed height smaller than the chart's
579
+ minimum.
580
+ - 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
581
+ `min-height: auto` lets it grow past its card, and the card's `overflow-hidden` slices off the rest.
582
+
532
583
  **6. Cross-filter across queries by KEY, not by row.** Charts fed by different queries don't share a
533
584
  row space, so one group can't span them. Put each query's charts in their own group, turn a click
534
585
  into the model's key (`CountryCode` above), keep it in app state, and filter the other queries with
535
586
  it. Anything saved against a mark - a note, a flag - is keyed the same way, never by `__rowIdx__`
536
587
  (see the row-index hazard above).
537
588
 
589
+ **Notes (or flags) on marks, end to end.** People leave a note on a country, a route, a product, and see
590
+ it on that mark: the notes live in Rayfin's data service, and the badge is the chart's `annotations`.
591
+ Every Rayfin fact here was checked on Rayfin 1.36.1, a preview - where your installed version differs,
592
+ its own docs (`node_modules/@microsoft/rayfin-guide/assets/docs/data/`) win.
593
+
594
+ *a. Turn the data service on.* The data app template ships it off. Run `setup_fabric_app` first -
595
+ `setup_fabric_app` puts every `@microsoft/rayfin-*` package on one version - then:
596
+
597
+ ```bash
598
+ npx rayfin init --services auth,data --auth-methods fabric --dialect mssql --overwrite --project-name <id from rayfin/rayfin.yml> .
599
+ ```
600
+
601
+ Each flag is needed: without `--overwrite` it prints "Initialization cancelled" and exits 0 with no
602
+ reason; without `--project-name` (pass the `id:` already in `rayfin/rayfin.yml`, so nothing is renamed)
603
+ it stops with "--project-name is required in non-interactive mode". It rewrites `rayfin/rayfin.yml`
604
+ and `.gitignore`, so `git diff` both and put back what's yours (on 1.36.1 it dropped the template's
605
+ `src/fabric.generated.ts` line from `.gitignore`). It writes `rayfin/tsconfig.json` - keep its
606
+ `outDir`, `.temp/compiled`, which is where the deploy reads the entities from - and no entities.
607
+
608
+ *b. Make it type-check.* Three edits, each needed on the data app template:
609
+ - `rayfin/tsconfig.json`, in `compilerOptions`: `"tsBuildInfoFile": ".temp/tsconfig.tsbuildinfo"` and
610
+ `"allowImportingTsExtensions": false` (it inherits both from the root: TS6377 and TS5096 otherwise).
611
+ - the root `tsconfig.json`: `"references": [{ "path": "./rayfin" }]`.
612
+ - type the client with your schema: `RayfinClient<AppSchema>` in `src/lib/rayfin-client.ts`
613
+ (`import type { AppSchema } from "../../rayfin/data/schema";`) and in the constructor of
614
+ `src/services/rayfin-auth.service.ts` (TS2345 otherwise).
615
+
616
+ Check with `npx tsc -b`; a bare `tsc --noEmit` reports TS6305 until the `rayfin/` project is built. If a
617
+ TS2345 names two copies of `rayfin-auth`, something installed only some Rayfin packages: run
618
+ `setup_fabric_app` again.
619
+
620
+ *c. The entity, keyed by the model's key - never by a row position.* `rayfin/data/MarkNote.ts`, then
621
+ register it in `rayfin/data/schema.ts` (`export type AppSchema = { MarkNote: MarkNote };`):
622
+
623
+ ```ts
624
+ import { entity, authenticated, uuid, text, date } from '@microsoft/rayfin-core';
625
+
626
+ @entity()
627
+ @authenticated(['read', 'create'])
628
+ export class MarkNote {
629
+ @uuid() id!: string;
630
+ @text({ max: 64 }) chart!: string; // which chart: "country-map", "lanes"
631
+ @text({ max: 128 }) markKey!: string; // the mark's model key (CountryCode)
632
+ @text({ max: 128, optional: true }) markKeyTo?: string; // a route's other end; empty for one mark
633
+ @text({ max: 2000 }) body!: string;
634
+ @text({ max: 320 }) author!: string;
635
+ @date() createdAt!: Date;
636
+ }
637
+ ```
638
+
639
+ Give every entity an explicit permission decorator (without one, any signed-in user gets every
640
+ action), and every `@text` a `max` (without it MSSQL gets `NVARCHAR(MAX)` columns, which can break
641
+ the deployed GraphQL schema). Use `.js` extensions on relative imports between entity files.
642
+
643
+ *d. Read every note, write one.* `.execute()` returns one page (100 rows) and doesn't say there are
644
+ more, so page through:
645
+
646
+ ```ts
647
+ // src/lib/mark-notes.ts
648
+ import type { MarkAnnotation } from "@bicharts/chart-host";
649
+ import { getRayfinClient } from "@/lib/rayfin-client";
650
+ import type { MarkNote } from "../../rayfin/data/MarkNote";
651
+
652
+ const FIELDS = ["id", "chart", "markKey", "markKeyTo", "body", "author", "createdAt"] as const;
653
+
654
+ export async function loadMarkNotes(): Promise<MarkNote[]> { // newest first
655
+ const all: MarkNote[] = [];
656
+ let after: string | undefined;
657
+ do {
658
+ const q = getRayfinClient().data.MarkNote.select([...FIELDS]).orderBy({ createdAt: "desc" }).first(100);
659
+ const page = await (after ? q.after(after) : q).executePaginated();
660
+ all.push(...page.items);
661
+ after = page.hasNextPage ? page.endCursor ?? undefined : undefined;
662
+ } while (after);
663
+ return all;
664
+ }
665
+
666
+ export function addMarkNote(n: Pick<MarkNote, "chart" | "markKey" | "markKeyTo" | "body">, author: string) {
667
+ return getRayfinClient().data.MarkNote.create({ ...n, author, createdAt: new Date() });
668
+ }
669
+ ```
670
+
671
+ In a hook, load inside the effect - `loadMarkNotes().then(all => { if (live) setNotes(all); })` with a
672
+ `live` flag the cleanup clears; the template's lint rejects an effect that calls a state-setting
673
+ function directly. The author is the signed-in user: `useAuth().session?.user?.email`.
674
+
675
+ *e. Badges on the marks: pass `annotations`, never measure the chart.* The host draws each badge on
676
+ its key's mark after every render and keeps it there through filters, re-queries and zooms; it never
677
+ reads a row position. One key column is `column`/`value`. A route is keyed by BOTH of its ends with
678
+ `where` - no key column to add, no query to change and no chart to regenerate:
679
+
680
+ ```ts
681
+ /** One badge per mark: a single key column, or a route's two ends. */
682
+ export function noteBadges(notes: MarkNote[], chart: string, key: [string] | [string, string]): MarkAnnotation[] {
683
+ const byMark = new Map<string, MarkNote[]>();
684
+ for (const n of notes) {
685
+ if (n.chart !== chart) continue;
686
+ const id = n.markKeyTo ? `${n.markKey}|${n.markKeyTo}` : n.markKey;
687
+ byMark.set(id, [...(byMark.get(id) ?? []), n]);
688
+ }
689
+ return [...byMark].map(([id, list]) => ({
690
+ id, label: String(list.length), title: list[0].body,
691
+ ...(key.length === 2
692
+ ? { where: { [key[0]]: list[0].markKey, [key[1]]: list[0].markKeyTo ?? "" } }
693
+ : { column: key[0], value: list[0].markKey }),
694
+ }));
695
+ }
696
+ ```
697
+
698
+ ```tsx
699
+ <BivariateWorldChoroplethChart table={table} {...size} options={options}
700
+ annotations={noteBadges(notes, "country-map", ["CountryCode"])}
701
+ onAnnotationClick={a => setOpenMark(a.id ?? null)} />
702
+ // a route by both ends: annotations={noteBadges(notes, "lanes", ["OriginCountryCode", "DestinationCountryCode"])},
703
+ // one badge per route, each { where: { OriginCountryCode: n.origin, DestinationCountryCode: n.dest }, label, title }
704
+ ```
705
+
706
+ Don't read `data-row-idx` or bounding boxes to place anything: that breaks on the next redraw, and
707
+ this does it for you.
708
+
709
+ *f. Open the notes over the page, never inside the chart's card.* Open them from `onAnnotationClick`
710
+ for a mark that has notes, and for a mark's first note from your own "Add a note" action on the
711
+ selected mark (`onSelectRows` gives you its key). Show them in a dialog, drawer or popover that
712
+ overlays the page through a portal: a chart's card has a fixed height and `overflow-hidden`, so a
713
+ panel inside it is clipped.
714
+
715
+ ```tsx
716
+ import { useState } from "react";
717
+ import { createPortal } from "react-dom";
718
+
719
+ export function NotesDialog({ title, notes, onAdd, onClose }: {
720
+ title: string; notes: MarkNote[]; onAdd: (body: string) => void; onClose: () => void;
721
+ }) {
722
+ const [draft, setDraft] = useState("");
723
+ return createPortal(
724
+ <div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 1000, display: "grid",
725
+ placeItems: "center", background: "rgb(0 0 0 / 0.35)" }}>
726
+ <div role="dialog" aria-modal="true" aria-label={title} onClick={e => e.stopPropagation()}
727
+ style={{ width: "min(480px, 92vw)", maxHeight: "80vh", overflow: "auto", padding: 16,
728
+ borderRadius: 8, background: "Canvas", color: "CanvasText" }}>
729
+ <h2>{title}</h2>
730
+ <ul>{notes.map(n => <li key={n.id}>{n.body} - {n.author}, {new Date(n.createdAt).toLocaleString()}</li>)}</ul>
731
+ <textarea value={draft} onChange={e => setDraft(e.target.value)} maxLength={2000} />
732
+ <button disabled={!draft.trim()} onClick={() => { onAdd(draft.trim()); setDraft(""); }}>Add note</button>
733
+ <button onClick={onClose}>Close</button>
734
+ </div>
735
+ </div>,
736
+ document.body);
737
+ }
738
+ ```
739
+
740
+ Style it with the app's own tokens. The table and the permissions are created on the next
741
+ `npx rayfin up`, and every later `rayfin up` applies entity changes.
742
+
538
743
  **7. A reader's choice inside a chart is view state.** A what-if chart's rate and horizon are its
539
744
  own controls, reported through `viewState` on `<BicChart>` (`uiState` / `setUiState` in the
540
745
  integration contract). To save a named scenario, store that - don't rebuild the slider beside it.
541
746
 
542
- **8. What stays the template's job:** sign-in, the query hooks, the app's database, `VisualContainer`
543
- headers, deployment (`npx rayfin up`). The chart is a function you render inside them.
747
+ **8. What stays the template's job:** sign-in, the query hooks, the app's database (Rayfin's data
748
+ service - the notes recipe above), `VisualContainer` headers, deployment. The chart is a function you
749
+ render inside them.
750
+
751
+ **9. Check every chart, then deploy.** Check every chart locally with this server's `check_chart` tool,
752
+ at the size the layout gives it - you don't need to deploy to check a chart. Then `npx tsc -b` and the
753
+ tests. Deploying (`npx rayfin up`), on Rayfin 1.36.1:
754
+ - Name the workspace: `npx rayfin up -w "<workspace>"`. Left out, it deploys to My Workspace.
755
+ - The Fabric item is named by `id:` in `rayfin/rayfin.yml`, not `name:`. Two projects with the same
756
+ `id:` in one workspace deploy into one item: give each its own `id:`, or deploy with
757
+ `--item-name <unique-name>`. Pass `--yes` to reuse an existing item only when you know it's yours.
758
+ - Deleted the app outside Rayfin? `rayfin/.deployments.json` still names the deleted item, so the next
759
+ `rayfin up` fails with 404 Not Found. Move that file aside and deploy again.
544
760
 
545
761
  ## 4. Things that will otherwise cost you time
546
762