@bicharts/chart-mcp 0.6.111 → 0.6.112

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.111",
3
+ "version": "0.6.112",
4
4
  "description": "AI Data Components for Power BI, Excel, Fabric Apps and the web, over MCP: bivariate maps, what-if projections and about 90 everyday chart types, with selection and cross-filtering already wired. It profiles your table locally; the code it writes is yours and runs with no license.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -47,8 +47,8 @@
47
47
  },
48
48
  "// 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
49
  "devDependencies": {
50
- "@bicharts/chart-host": "^0.6.111",
51
- "@bicharts/shape-core": "^0.6.111",
50
+ "@bicharts/chart-host": "^0.6.112",
51
+ "@bicharts/shape-core": "^0.6.112",
52
52
  "@modelcontextprotocol/sdk": "^1.32.1",
53
53
  "@types/node": "^26.6.5",
54
54
  "@types/papaparse": "^5.3.14",
@@ -215,6 +215,20 @@ the next `generate_chart`, or, for a live setting, an option in the emitted comp
215
215
  unattended run), ask nothing, use the defaults, and tell the user which settings were assumed from
216
216
  `settings.applied`. `get_guide` with topic `settings` lists every row with its options.
217
217
 
218
+ **Which direction is good: ask it in that same question, when a chart will judge a measure.** A chart that compares,
219
+ ranks or colors a measure as good or bad (a variance against a target, a KPI rank, ahead or behind, a diverging color)
220
+ needs to know whether more of it is better, and the data can't say: a rising cost and a rising bookings figure move the
221
+ same way. So when the page has such a chart, the one question before the first `generate_chart` also asks, for each
222
+ measure it judges that the person hasn't already given a direction for, "is a higher <measure> better, or a lower
223
+ one?". Pass each answer in `generate_chart`'s `direction_of_good`, keyed by the measure's column name as sent:
224
+ `direction_of_good: { "Bookings": "higher", "Cost": "lower" }`. On a server whose `generate_chart` has no
225
+ `direction_of_good`, say it in that chart's `prompt` instead: `higher <measure> is better` or `lower <measure> is
226
+ better`. When you can't ask (a sub-agent, an unattended run) or the person said to use the
227
+ defaults, pass nothing: the engine infers a direction for a measure it can place (an actual against its own target:
228
+ higher is better; an expense, spend or opex: lower is better) and leaves the rest neutral; say in your final message which measures it was left to infer. A
229
+ measure the person already named a direction for is never asked again. `check_app` flags a chart generated with no
230
+ direction of good where it judges one.
231
+
218
232
  **Your final message keeps what the engine chose apart from what falls short.** Under what the engine chose, list its
219
233
  choices (the `settings.applied` rows it picked - `default`, `server-roll` - and `engineChoices`) and each chart's limits
220
234
  (a width it won't draw below, so the layout stacks it; a row cap; a type a count ruled out). Those are choices and
@@ -568,6 +582,21 @@ overwrites a file, and its reply ends with a worked example. Building notes on m
568
582
  data service is on (the notes recipe below, step a), run it again with `features: ["notes", "scenarios"]`: it writes
569
583
  those files finished.
570
584
 
585
+ **Every page works in light and dark, without being asked.** `setup_fabric_app` writes `src/lib/bic-theme.ts`, the
586
+ app's light/dark state: it follows Fabric's `data-appearance`, else the OS, until the reader picks, and the pick holds
587
+ for the session. It points the template's `src/main.tsx` at it in place of the template's own hook, which watches its own
588
+ class change and, under a host that sets `data-appearance`, flips a reader's pick straight back. It also writes
589
+ `src/components/bic-theme-toggle.tsx`: put `<BicThemeToggle />` in the app's header, on every page, whether or not the
590
+ person asked for one. Then build every page for both themes:
591
+ - Colors come from the template's tokens (`bg-card`, `bg-background`, `text-foreground`, `text-muted-foreground`,
592
+ `border-border`, `text-destructive`), never a literal (`#fff`, `bg-white`, `text-gray-700`, `style={{ color: "#333" }}`):
593
+ a literal stays the same color in both themes, so white text lands on a white card.
594
+ - Charts follow on their own: `useBicChartOptions()` passes the theme (the background as both `backgroundColor` and
595
+ `themeBg`), and the charts redraw when the toggle flips it. Pass it to every chart; never a color of your own.
596
+ - A dialog's scrim is `bg-black/40`, which dims the page in both themes; `bg-foreground/40` washes it white in dark mode.
597
+ - Look at every page in both themes before you call it done.
598
+ `check_app` flags a page with no toggle, the template's hook, a literal color and a scrim that flips with the theme.
599
+
571
600
  **The model's name is the `connection`.** Connect the semantic model the way the template's AGENTS.md says - before or
572
601
  after `setup_fabric_app`, whose reply names it either way. On the template's current layout the model is a Rayfin
573
602
  connector: `npx rayfin connector search "<model name>" --all-workspaces --type fabric-semanticmodel --json`, then the
@@ -601,6 +630,42 @@ is:
601
630
  blank), `check_app` flags them in the app's `.dax` files, and `check_chart` warns when one value sets a diverging color
602
631
  scale's span.
603
632
 
633
+ **A chart's categories come from the data.** Group by the category column and let the chart take every value the
634
+ model holds; never name the categories in the DAX one filter at a time (`CALCULATE([Sales], 'Channel'[Channel] =
635
+ "Online")`, then "Retail", then "Partner"). A value the model adds or renames would drop out silently, and a chart that
636
+ divides by their sum would re-scale every mark to the ones named. `check_app` flags a query that names two or more
637
+ values of one column that way.
638
+
639
+ **A shown total is the sum of the parts shown.** When a card shows a total and another panel shows its parts (a goal,
640
+ and each owner's goal in a table), compute the total from those parts, so the reader can add them up and get it:
641
+
642
+ ```dax
643
+ "Goal", SUMX ( VALUES ( 'Owner'[Owner] ), [Goal] ) -- the sum of the owners' goals the table shows
644
+ ```
645
+
646
+ A measure evaluated once at the total isn't the sum of its parts when it rounds (MROUND, ROUND), clamps or takes a
647
+ maximum each time it's evaluated, and a ratio over it (an attainment) then disagrees with the table's. Compute that ratio
648
+ from the summed total too. `check_app` flags a total panel whose measure rounds while another panel shows it by part
649
+ (read from the project's schema cache).
650
+
651
+ **A query reads the window its card names.** A card that says "2019 to 2025", "to December 2025" or "in 2025" gets a
652
+ query bounded to that window, with a filter on the model's date column inside SUMMARIZECOLUMNS:
653
+
654
+ ```dax
655
+ EVALUATE
656
+ SUMMARIZECOLUMNS (
657
+ 'Date'[Month],
658
+ DATESBETWEEN ( 'Date'[Date], DATE ( 2019, 1, 1 ), DATE ( 2025, 12, 31 ) ), -- "2019 to 2025"
659
+ "Bookings", [Bookings]
660
+ )
661
+ -- one year: TREATAS ( { 2025 }, 'Date'[Year] )
662
+ ```
663
+
664
+ Without it the chart reads whatever the model holds - a month after the stated end, a year before its start - while the
665
+ card's words stay as written. Write the card's words and the query's bound together, from the same dates; for a
666
+ projection, the window is the history it starts from, and its horizon is the chart's own control. `check_app` warns on a
667
+ card that names a window over a query with no bound on it.
668
+
604
669
  **2. Profile and generate from THE WHOLE RESULT of that query - every row, never a few pasted in.**
605
670
  Eligibility reads row, region and category counts. A bivariate country map needs at least 24
606
671
  countries, so the same 40-country query shown as 8 sample rows hides it, and nothing fails - you are
@@ -756,10 +821,17 @@ function CountryMap({ table, onPick }) { // table = the live query resu
756
821
 
757
822
  **Layout: a chart is never clipped or squashed.** Charts sit in cards, and a card is where they get cut off.
758
823
  - Render every chart inside `BicChartPanel`. It sizes the chart from its cell, never from the chart.
759
- - Give each chart its minimum: `minChartWidth` and `minChartHeight` (a world map about 560 x 300; a time series wants
824
+ - Give each chart its minimum: `minChartWidth` and `minChartHeight` (a world map wants about 2:1; a time series wants
760
825
  height). Below that the panel scrolls; it never squashes or clips the chart. That scroll is a safety net, not a layout:
761
826
  inside a page that already scrolls it's a second scrollbar, which reads as broken - size every chart's cell at or above
762
827
  its minimum so it never shows.
828
+ - **A phone needs no sideways scroll.** A phone is about 360px wide, and the page's and the card's padding leave a
829
+ chart's box 294 of it. `BicChartPanel` draws a chart at its box's width, so set no `minChartWidth` unless the chart
830
+ needs more, and never one above 294; make the chart fit that width instead: check it with `check_chart` (it checks at
831
+ 294 when no width is named), and if it doesn't fit, generate it again with `generate_chart` at `width: 294` (the
832
+ engine's small-screen rules then apply). A minimum wider than the phone's box draws the chart wider than its card,
833
+ so the card scrolls sideways and anything the chart keeps in place (a table's name column) scrolls away with it.
834
+ `check_app` warns on a `minChartWidth` above 294.
763
835
  - Take the actual size from the layout - percent, flex or grid `fr` - and never a fixed height smaller than the chart's
764
836
  minimum.
765
837
  - 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
@@ -843,6 +915,21 @@ What the library does, so the page doesn't:
843
915
  - `country.value`, `country.values`, `country.keys`, `country.row` (the clicked row), `country.active` and
844
916
  `country.text` ("Japan", or "Spain → Portugal" for a route) are what the page reads. For any OTHER key - a badge's,
845
917
  a lane's end - `country.textOf(key, rows)` names it from a table that carries the display column.
918
+ - A pick can be a group: a legend entry, a subtotal row, a lasso or a Ctrl-click picks several keys through the same
919
+ filter, and `country.value` is its FIRST key only. Anything that names or acts on the pick (a panel heading, an "Add
920
+ an item" button) reads `country.keys`: with one, name it (`country.text`); with more, say how many ("3 countries")
921
+ and list them, and offer a one-mark action only while `country.keys.length === 1`. When several filters share a key
922
+ (one per clicked chart), read every one of them. `check_app` flags a `.value` read.
923
+ - **A label picks its own field.** On a chart of region by stage with a filter keyed `["Region", "PipelineStep"]`, a
924
+ click on a region's axis label holds that region in ANY stage, not the stage tuples drawn at that moment: the key is
925
+ `["EMEA", ANY_VALUE]` (`isAnyValue` tells a free column; both from `@bicharts/chart-host/react` or `/page`), the chip
926
+ reads "EMEA", and `treatAs(filter, "Accounts[Region]", "Deals[Stage]")` filters by the region alone. Never build tuple
927
+ keys by hand or `set()` the drawn combinations: a stage that comes back when another filter clears is then left out.
928
+ - **Key a click filter at the grain of the marks that set it.** A chart with one mark per product in each region
929
+ sets `useBicFilter(["Product", "Region"])`, never `useBicFilter("Product")`: keyed by product alone, one picked mark
930
+ picks that product in every region, the chart lights more marks than the reader picked (a lasso's group comes back
931
+ wider than it was drawn), and the page filters a wider group. A column from the key's own table (a product's
932
+ category) is an attribute, not a finer grain. `check_app` flags a filter keyed coarser than its chart's marks.
846
933
  - Another chart whose table carries the key: `filterBy={{ filter: country, columns: [...] }}` on its component - any
847
934
  of several columns, for a route that starts or ends there. It's applied inside the component, so the chart redraws
848
935
  only when the filter or its data changes. A table WITHOUT the key column can't be filtered that way (it warns): filter
@@ -852,6 +939,21 @@ What the library does, so the page doesn't:
852
939
  It adds a filter value and never changes the query's structure, so one `.dax` file serves both. A route filter maps
853
940
  each end: `treatAs(route, "Routes[Origin]", "Routes[Destination]")`; `treatAs(route, "Geography[CountryIso3]")` takes
854
941
  the route's FIRST column (its origin). Two filters on one column intersect: pass the one that should win.
942
+ - **A pick narrows only the measures it can mean.** A filter passed through `filterQuery` narrows EVERY measure in that
943
+ query, so decide per measure what the pick means. A stage pick narrows the open measures (the value in a stage, the
944
+ deals in it), not the closed ones (won value, the goal, attainment, the close rate): a won deal's stage isn't the
945
+ reader's question. Keep those out of the pick's reach - their own query that doesn't take it, or
946
+ `CALCULATE([Won Value], REMOVEFILTERS('Deals'[Stage]))` in the query - and say on the card which figures follow the
947
+ pick. Pass a pick only to panels it can reach, too: a model's relationships filter one way, so a deal's stage can't
948
+ narrow a table that relates only to accounts, and a `scopedBy` naming it there tells the chart something false.
949
+ `check_app` reads the model's relationships from the project's schema cache (`.schema/models/<model>/model.sqlite`)
950
+ and flags a `treatAs` column that can't reach a panel's query, and a `scopedBy` that claims it; with no cache it says
951
+ the check was skipped. A pick reaches a measure through the tables its expression reads, not the table it's kept on:
952
+ a goal kept on an owners table but computed from the deals follows a region pick on accounts (Accounts filters the
953
+ deals), while one computed from the owners table alone stays the whole company's beside revenue that narrows, and the
954
+ attainment over the two reads low. Carry the pick to it - `CALCULATE ( [Goal], TREATAS ( VALUES ( 'Accounts'[Region]
955
+ ), 'Owners'[Region] ) )` - or say on the card that it ignores that pick. `check_app` flags a measure a pick can't
956
+ reach beside figures it narrows.
855
957
 
856
958
  A Microsoft `VegaVisual` or `DataGrid` on the same page joins the same filter:
857
959
  `onInteraction={e => fromVegaInteraction(country, e)}` (`fromVegaInteraction` from `@bicharts/chart-host/react`; a page without React takes all of these from `@bicharts/chart-host/page`). It matches
@@ -921,7 +1023,9 @@ never rewrites them:
921
1023
  position: `chart`, `markKey`, `markKeyTo` (a route's other end), `body`, `author`, `authorId`, `createdAt`. Every
922
1024
  entity it writes has an explicit permission decorator and a `max` on every `@text` (MSSQL gets `NVARCHAR(MAX)` without
923
1025
  one, which can break the deployed GraphQL schema). Its create permission carries a policy,
924
- `claims.sub.eq(item.authorId)`: the server takes a note only under the caller's own id.
1026
+ `claims.sub.eq(item.authorId).and(claims.email.eq(item.author))`: the server takes a note only under the caller's own
1027
+ id and email, and the `addMarkNote` function checks both against the caller's token first, so a note can't be filed
1028
+ under someone else's name.
925
1029
  - `src/lib/bic-notes.ts`: `useMarkNotes(author)` - every note (paged with `.executePaginated()`: one query returns at
926
1030
  most 100 and doesn't say there are more), an `error` when they can't load, and `add(chart, key, body)`, which saves
927
1031
  through the `addMarkNote` Rayfin function and reloads, and REJECTS when the save fails. The author is the signed-in
@@ -965,6 +1069,15 @@ Open a mark's notes from `onAnnotationClick` (`badgeKey(a)` is its key), and its
965
1069
  note" action on the selected mark - `country.keys[0]` is its key, `country.text` its name. Don't read `data-row-idx`
966
1070
  or bounding boxes to place anything: that breaks on the next redraw, and this does it for you.
967
1071
 
1072
+ **A chart that carries write markers draws every row a marker can belong to.** A badge lands only on a drawn mark, so
1073
+ a map whose query keeps only the accounts with open pipeline (`FILTER ( SUMMARIZECOLUMNS ( ... ), NOT ISBLANK ( [Open
1074
+ Pipeline] ) )`) never shows a flag on an account whose deals are all won or lost, though the flag form offered it. Drop
1075
+ the row filter: the chart draws a row with a blank size as a hollow ring (say on the card what a ring means). An
1076
+ account with no fact rows at all is dropped by `SUMMARIZECOLUMNS` too, which drops a row whose measures are all blank, so
1077
+ when the form offers every account, list them with `ADDCOLUMNS ( SUMMARIZE ( 'Accounts', 'Accounts'[Account Number],
1078
+ ... ), "Open Pipeline", [Revenue Open] )`. `check_app` flags a marker-hosting chart whose query drops rows the form can
1079
+ target.
1080
+
968
1081
  The files use the template's own tokens; restyle them as you like. The table and the permissions are created on
969
1082
  the next `npx rayfin up`, and every later `rayfin up` applies entity changes.
970
1083
 
@@ -974,6 +1087,10 @@ files do:
974
1087
  entity's create permission a policy that checks it - `@role<Item>('authenticated', 'create', { policy: (claims, item) =>
975
1088
  claims.sub.eq(item.createdById) })` - so the server refuses a row filed under anyone else. With no one signed in,
976
1089
  refuse the save and say why (the form keeps what was typed); never store "unknown".
1090
+ - **The name shown comes from the caller's token too**, never only from the browser: the name is the signed-in email,
1091
+ and the policy checks it with the id - `claims.sub.eq(item.createdById).and(claims.email.eq(item.createdBy))` - so a
1092
+ hand-made call with your own id and someone else's name is refused. (`claims.email` is the token's email claim; a
1093
+ Rayfin function sees the same token as `ctx.accessToken`.)
977
1094
  - **When** is the server's clock, never the browser's: every write that records a time (an item created, an item
978
1095
  marked done) goes through a Rayfin function that stamps it on the server. Rayfin's data layer (checked on 1.36.2) has
979
1096
  no server-time default for a field (a `default` is a fixed value), so the browser never sends the time at all. The
@@ -988,11 +1105,11 @@ files do:
988
1105
  // rayfin/data/TaskDone.ts
989
1106
  @entity()
990
1107
  @authenticated('read')
991
- @role<TaskDone>('authenticated', 'create', { policy: (claims, item) => claims.sub.eq(item.doneById) })
1108
+ @role<TaskDone>('authenticated', 'create', { policy: (claims, item) => claims.sub.eq(item.doneById).and(claims.email.eq(item.doneBy)) })
992
1109
  export class TaskDone {
993
1110
  @uuid() id!: string;
994
1111
  @uuid({ unique: true }) taskId!: string; // one per task: a second "done" fails, so the first completer stays
995
- @text({ max: 320 }) doneBy!: string; // the name shown (the session's email)
1112
+ @text({ max: 320 }) doneBy!: string; // the name shown: the session's email, checked against the token's
996
1113
  @text({ max: 128 }) doneById!: string; // session.user.id, checked against the caller by the policy
997
1114
  @date() doneAt!: Date; // the server's clock: set only in markTaskDone below
998
1115
  }
@@ -1012,15 +1129,84 @@ await getRayfinClient().functions.markTaskDone.invoke({ taskId: task.id, doneBy:
1012
1129
 
1013
1130
  **What this can't stop yet.** A Rayfin function writes with the same permissions as the browser, and Rayfin has no
1014
1131
  server-time default for a field and no permission that only a function holds. So the app's own saves always carry the
1015
- server's time, but a request someone writes by hand against the data API can still set its own. Say so if the person
1132
+ server's time, but a request someone writes by hand against the data API can still set its own. Who is not in that
1133
+ gap: the policy checks the name and the id against the caller's token on every write, however it arrives. Say so if the person
1016
1134
  asks for an audit trail; a database the app reaches only through functions would close it, and Rayfin doesn't offer
1017
1135
  that today.
1018
1136
 
1137
+ - **A write's button stays off until its reload lands.** The save resolves only once the list it reloads is on screen:
1138
+ make the reload a function that awaits the load itself, and end the save with `await reload()`. Bumping a counter that
1139
+ an effect reloads on returns before the rows arrive, so "Mark done" comes back on over a row that still looks open, and
1140
+ a quick second click sends the same write again (the unique key refuses it, and the row shows an error for an item that
1141
+ was closed). setup's notes and scenarios hooks do this.
1142
+
1143
+ ```ts
1144
+ const seq = useRef(0);
1145
+ const reload = useCallback(async () => {
1146
+ const mine = ++seq.current;
1147
+ const rows = await loadTasks();
1148
+ if (mine === seq.current) setTasks(rows); // a reload a later one overtook drops its rows
1149
+ }, []);
1150
+ const markDone = useCallback(async (taskId: string) => {
1151
+ await saveTaskDone(taskId); // the markTaskDone call above
1152
+ await reload(); // resolves once the row shows done: the button stays off until then
1153
+ }, [reload]);
1154
+ ```
1155
+
1156
+ - **The rest of the pattern, each measured on a real app:**
1157
+ - Apply only the latest reload's rows (the `seq` above): two quick saves start two reloads, and the older one can land
1158
+ last, so an item just closed shows open again.
1159
+ - Show "Loading..." until the first load lands, and the load's error when it fails - never an empty list's words
1160
+ ("Nothing open") before the rows are in.
1161
+ - A form's default that comes from data loading elsewhere (the item's owner) is adopted when it arrives, unless the
1162
+ reader has already chosen; a lookup behind a choice list that fails says so in the form.
1163
+ - A due date takes `min={today}`, and the server function refuses a past one.
1164
+ - Read what the page shows, not every row ever saved: bound the query with `.where(...)` and show the first page as it
1165
+ lands. Bound the DONE items, by when they were done; never bound the open ones, least of all by their due date: the
1166
+ most overdue open item is the one the reader most needs, and a due-date window drops it first.
1167
+ - A panel whose query failed offers Retry: setup's `BicChartPanel` does, from the query hook's `refetch` (pass the
1168
+ hook's whole result).
1169
+ - A disabled Save says why, beside it ("Pick a country from the list", "The due date can't be in the past"), tied to
1170
+ the button with `aria-describedby`: a grey button with no word leaves the reader guessing.
1171
+ - A moment (a done or created time) shows in the reader's own time zone; only a date stored as a calendar day (a due
1172
+ date) is formatted in UTC.
1173
+ - When a "done" is refused because someone else closed the item first, the list (the hook, not the row the reload
1174
+ removes) says "Already marked done by <name>".
1175
+ - A single pick across several filters on one key is one distinct key: a country held by both the map's and the
1176
+ table's filter is one pick, not none.
1177
+ - A list for a picked owner filters by the owner field the form saves (the follow-up's owner), never by the owner the
1178
+ model gives the item's account: a follow-up on Bob's account given to Alice belongs on Alice's list. Count an owner
1179
+ badge by that same field.
1180
+ - A "sign in" check asks what the save asks: when the save needs the user's email, `cannotSave` checks
1181
+ `user?.id && user.email`, not just `user`, or a reader signed in without an email claim fills in the form and is
1182
+ told to sign in.
1183
+ - A lookup whose rows reach the page shows its loading and its failure on the page, beside what it feeds, not only
1184
+ in the form: an owner-of-each-account lookup run for the flag form also places the table's badges and fills the
1185
+ follow-ups list, so while it loads, or after it fails, those read as truly empty unless the page says why. A blank
1186
+ model value is left out of text, never put in with `String()`: `String(null)` prints "null".
1187
+ - Other people's writes show without a page reload: the hook reloads when the page comes back into view
1188
+ (`visibilitychange` to visible, and window `focus`), never on a timer, and a write that happens once (a done)
1189
+ reloads whether it succeeds or is refused (`try { await markDone() } finally { await reload() }`), so a refusal
1190
+ shows the item as it now is, with who closed it. setup's notes and scenarios hooks do this.
1191
+ - A dialog's list is filtered from the hook's rows where it's shown (keep only its key in state), never a copy taken
1192
+ when it opened: a copy misses the flag just added in it.
1193
+ `check_app` flags each of these.
1194
+ - **Every write target is reachable by name, from the keyboard.** A write that goes to one mark (a flag on an account, a
1195
+ note on a product) is opened by a chart pick, and also chosen by name: setup writes
1196
+ `src/components/bic-target-picker.tsx`, a searchable list (type a few letters, arrows, Enter; accents and case don't
1197
+ matter). Put `<BicTargetPicker label="Account" options={every target as { key, text }} value={key} onChange={setKey} />`
1198
+ in the form, start it at the chart's pick when there is one, and keep the button that opens the form enabled. A
1199
+ chart's keyboard order is its drawing order (a map's bubbles by size), so a pick alone means arrowing through every
1200
+ mark. `check_app` flags a write button that only a chart pick enables.
1201
+
1019
1202
  - **A dialog of your own** (a form over the page) uses `useDialogFocus(ref, onClose)` from `src/lib/bic-dialog.ts`:
1020
- Tab stays inside it, Escape closes it, and focus goes back to what opened it.
1203
+ Tab stays inside it, Escape closes it (it asks first when a field holds typed text, and an open list inside it, such
1204
+ as the target picker's, takes its own Escape), and focus goes back to what opened it - or, when a save repainted that (a
1205
+ chart's badge), to its replacement.
1021
1206
 
1022
1207
  `check_app` flags a placeholder name in a write, a time written from the browser's clock, an entity that takes any
1023
- name on create or lets any row be updated again, and a dialog that doesn't hold the keyboard.
1208
+ name on create or lets any row be updated again, a name it stores that its policy doesn't check against the token, a write that resolves before its reload lands, and a dialog that
1209
+ doesn't hold the keyboard.
1024
1210
 
1025
1211
  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.
1026
1212