From d6e8ef0dff993019247937e0d4c848343ea1d0ec Mon Sep 17 00:00:00 2001 From: Paddy Mullen Date: Tue, 9 Jun 2026 15:48:13 -0400 Subject: [PATCH 1/2] feat(app): add read-only catalog customizations page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit List the project-wide Buckaroo customizations — summary stats, styling classes, and post-processing functions — for a catalog, each expandable to inspect its source. Adds GET /{project}/api/customizations, which returns the three registries via the existing tallyman_core list_stats / list_display_klasses / list_post_processings helpers (the same ones the catalog_list_* MCP tools use), including _disabled/ items flagged accordingly. The React SPA gets a CustomizationsPage with a nav link; it refetches on SSE version bumps so edits made via the MCP tools show up live. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/app/src/App.tsx | 2 + packages/app/src/api.ts | 4 + packages/app/src/components/Header.tsx | 1 + packages/app/src/pages/CustomizationsPage.tsx | 86 +++++++++++++++++++ packages/app/src/styles.css | 16 ++++ packages/app/src/types.ts | 14 +++ src/tallyman_companion/app.py | 17 ++++ 7 files changed, 140 insertions(+) create mode 100644 packages/app/src/pages/CustomizationsPage.tsx diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 13998cb..4aa5da2 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -9,6 +9,7 @@ import { LineagePage } from "./pages/LineagePage"; import { LineageEntryPage } from "./pages/LineageEntryPage"; import { DiffPage } from "./pages/DiffPage"; import { CachePage } from "./pages/CachePage"; +import { CustomizationsPage } from "./pages/CustomizationsPage"; import { EmptyStatePage } from "./pages/EmptyStatePage"; import { ProjectsPage } from "./pages/ProjectsPage"; import { api } from "./api"; @@ -55,6 +56,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/packages/app/src/api.ts b/packages/app/src/api.ts index 9ee30f4..1676c5e 100644 --- a/packages/app/src/api.ts +++ b/packages/app/src/api.ts @@ -7,6 +7,7 @@ import type { LineageLayout, DiffData, ResultCache, + Customizations, Projects, } from "./types"; @@ -132,6 +133,9 @@ export const api = { resultCache: (project: string): Promise => get(`/${project}/api/result_cache`), + customizations: (project: string): Promise => + get(`/${project}/api/customizations`), + deleteResultCache: (project: string, hash: string): Promise<{ ok: boolean; hash: string }> => fetch(`/${project}/api/result_cache/${hash}`, { method: "DELETE" }).then((r) => { if (!r.ok) throw new Error(`delete failed: HTTP ${r.status}`); diff --git a/packages/app/src/components/Header.tsx b/packages/app/src/components/Header.tsx index 746c39e..4d9ccdb 100644 --- a/packages/app/src/components/Header.tsx +++ b/packages/app/src/components/Header.tsx @@ -81,6 +81,7 @@ export function Header() { notebook lineage cache + customizations projects )} diff --git a/packages/app/src/pages/CustomizationsPage.tsx b/packages/app/src/pages/CustomizationsPage.tsx new file mode 100644 index 0000000..f838351 --- /dev/null +++ b/packages/app/src/pages/CustomizationsPage.tsx @@ -0,0 +1,86 @@ +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { api } from "../api"; +import { useSSE } from "../SSEContext"; +import type { CustomizationItem, Customizations } from "../types"; + +const SECTIONS: { key: keyof Omit; title: string; blurb: string }[] = [ + { + key: "summary_stats", + title: "Summary stats", + blurb: "Per-column statistics computed by Buckaroo. def compute(col) -> ibis.Expr", + }, + { + key: "display_klasses", + title: "Styling classes", + blurb: "ColAnalysis subclasses that control table rendering and pinned rows.", + }, + { + key: "post_processings", + title: "Post-processing", + blurb: "Expression transformers offered in Buckaroo's post-processing dropdown. def process(expr) -> ibis.Expr | DataFrame", + }, +]; + +function Section({ title, blurb, items }: { title: string; blurb: string; items: CustomizationItem[] }) { + const active = items.filter((i) => !i.disabled).length; + return ( +
+
+ {title} + + {items.length === 0 + ? "none" + : `${active} active${items.length - active ? ` · ${items.length - active} disabled` : ""}`} + +
+ {blurb} + + {items.length === 0 ? ( +
none defined
+ ) : ( + items.map((item) => ( +
+ + {item.name} + {item.disabled && disabled} + + {item.path} + + +
{item.source}
+
+ )) + )} +
+ ); +} + +export function CustomizationsPage() { + const { project } = useParams<{ project: string }>(); + const { version } = useSSE(); + const [data, setData] = useState(null); + + useEffect(() => { + if (!project) return; + api.customizations(project).then(setData).catch(() => {}); + }, [project, version]); + + return ( +
+
+
+ Catalog customizations + + Project-wide Buckaroo customizations. They apply to every entry in the catalog and are loaded + into each session. Read-only here — edit via the MCP tools. + +
+ + {SECTIONS.map((s) => ( +
+ ))} +
+
+ ); +} diff --git a/packages/app/src/styles.css b/packages/app/src/styles.css index 5702d4a..6fb526f 100644 --- a/packages/app/src/styles.css +++ b/packages/app/src/styles.css @@ -514,6 +514,22 @@ table.cache-table .muted { color: var(--muted); } .del-btn:hover { background: #2a1b1b; color: #ffb4b4; border-color: #7a4a4a; } .del-btn:disabled { opacity: 0.5; cursor: default; } +/* catalog customizations page */ +.cust-section { margin-bottom: 22px; } +.cust-section-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 2px; } +.cust-section-head strong { font-size: 14px; } +.cust-section .cache-note { display: block; margin-bottom: 8px; } +.cust-item { border: 1px solid var(--border); border-radius: 5px; margin-bottom: 6px; background: var(--panel, transparent); } +.cust-item > summary { display: flex; align-items: baseline; gap: 10px; padding: 6px 10px; cursor: pointer; list-style: none; } +.cust-item > summary::-webkit-details-marker { display: none; } +.cust-item > summary::before { content: "▸"; color: var(--muted); font-size: 11px; } +.cust-item[open] > summary::before { content: "▾"; } +.cust-item .cust-name { font-weight: 600; font-family: ui-monospace, monospace; } +.cust-item .cust-name.muted { color: var(--muted); font-weight: 400; text-decoration: line-through; } +.cust-item .cust-path { margin-left: auto; font-family: ui-monospace, monospace; font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 50%; } +.cust-item .vchip { background: var(--accent-soft); color: var(--accent); padding: 1px 6px; border-radius: 10px; font-size: 11px; } +.cust-item > pre.code { margin: 0; border-top: 1px solid var(--border); border-radius: 0 0 5px 5px; } + /* projects management page */ .projects-page { max-width: 600px; margin: 32px auto; padding: 16px; } .projects-page h2 { margin-top: 0; margin-bottom: 16px; } diff --git a/packages/app/src/types.ts b/packages/app/src/types.ts index 3be96ba..6834781 100644 --- a/packages/app/src/types.ts +++ b/packages/app/src/types.ts @@ -180,6 +180,20 @@ export interface ResultCache { total_formatted: string; } +export interface CustomizationItem { + name: string; + path: string; + source: string; + disabled: boolean; +} + +export interface Customizations { + project: string; + summary_stats: CustomizationItem[]; + display_klasses: CustomizationItem[]; + post_processings: CustomizationItem[]; +} + export interface Projects { active: string | null; available: string[]; diff --git a/src/tallyman_companion/app.py b/src/tallyman_companion/app.py index 9a7cefb..b8361e4 100644 --- a/src/tallyman_companion/app.py +++ b/src/tallyman_companion/app.py @@ -1007,6 +1007,23 @@ def api_result_cache(project: str): "total_formatted": _fmt_bytes(total), } + @app.get("/{project}/api/customizations") + def api_customizations(project: str): + """Project-wide buckaroo customizations: summary stats, display + klasses and post-processing functions, each with its source. These + are global to the catalog (one set per project), not per-entry.""" + project = _validate_project(project) + from tallyman_core.display_klasses import list_display_klasses # noqa: PLC0415 + from tallyman_core.post_processing import list_post_processings # noqa: PLC0415 + from tallyman_core.summary_stats import list_stats # noqa: PLC0415 + + return { + "project": project, + "summary_stats": list_stats(project), + "display_klasses": list_display_klasses(project), + "post_processings": list_post_processings(project), + } + @app.delete("/{project}/api/result_cache/{content_hash}") async def api_delete_result_cache(project: str, content_hash: str): project = _validate_project(project) From 8bb077343555f2f93a3fd3aebf538aa0c54a70b9 Mon Sep 17 00:00:00 2001 From: Paddy Mullen Date: Tue, 9 Jun 2026 20:58:56 -0400 Subject: [PATCH 2/2] fix(customizations-ui): distinguish loading/error from empty and mute disabled badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously a fetch 500/404 and the pre-load state both rendered identically to a genuinely empty catalog (three "none defined" sections). Track explicit loading/error state: show "loading…" on first load and the server error in an .error-banner, while keeping current data on screen during SSE-driven refetches so a version bump no longer flashes the page. Swap the disabled badge off the blue accent .vchip (used elsewhere for informational version tags) to a muted outlined .cust-flag, consistent with the strikethrough/muted name. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/app/src/pages/CustomizationsPage.tsx | 23 +++++++++++++++---- packages/app/src/styles.css | 2 +- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/app/src/pages/CustomizationsPage.tsx b/packages/app/src/pages/CustomizationsPage.tsx index f838351..1b6534f 100644 --- a/packages/app/src/pages/CustomizationsPage.tsx +++ b/packages/app/src/pages/CustomizationsPage.tsx @@ -43,7 +43,7 @@ function Section({ title, blurb, items }: { title: string; blurb: string; items:
{item.name} - {item.disabled && disabled} + {item.disabled && disabled} {item.path} @@ -60,10 +60,17 @@ export function CustomizationsPage() { const { project } = useParams<{ project: string }>(); const { version } = useSSE(); const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); useEffect(() => { if (!project) return; - api.customizations(project).then(setData).catch(() => {}); + // Refetch on `version` bumps but keep the current data on screen — don't + // flip back to the loading state, so an SSE event doesn't flash the page. + api + .customizations(project) + .then((d) => { setData(d); setError(""); setLoading(false); }) + .catch((e) => { setError(String(e)); setLoading(false); }); }, [project, version]); return ( @@ -77,9 +84,15 @@ export function CustomizationsPage() { - {SECTIONS.map((s) => ( -
- ))} + {loading ? ( +
loading…
+ ) : error && !data ? ( +
{error}
+ ) : ( + SECTIONS.map((s) => ( +
+ )) + )} ); diff --git a/packages/app/src/styles.css b/packages/app/src/styles.css index 6fb526f..5c1ae3f 100644 --- a/packages/app/src/styles.css +++ b/packages/app/src/styles.css @@ -527,7 +527,7 @@ table.cache-table .muted { color: var(--muted); } .cust-item .cust-name { font-weight: 600; font-family: ui-monospace, monospace; } .cust-item .cust-name.muted { color: var(--muted); font-weight: 400; text-decoration: line-through; } .cust-item .cust-path { margin-left: auto; font-family: ui-monospace, monospace; font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 50%; } -.cust-item .vchip { background: var(--accent-soft); color: var(--accent); padding: 1px 6px; border-radius: 10px; font-size: 11px; } +.cust-item .cust-flag { color: var(--muted); border: 1px solid var(--border); padding: 0 6px; border-radius: 10px; font-size: 11px; } .cust-item > pre.code { margin: 0; border-top: 1px solid var(--border); border-radius: 0 0 5px 5px; } /* projects management page */