diff --git a/CLAUDE.md b/CLAUDE.md
index bf00de3..499d3de 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -3,10 +3,11 @@ python-dashboard-template/
├── src/
│ ├── app.py # Main application file
│ ├── theme.py # Colours, type scale and the Plotly template
-│ ├── memory_log.py # Dev aid: prints RSS memory usage, see LOG_MEMORY
+│ ├── memory_log.py # Opt-in dev aid: prints RSS memory to the terminal, see LOG_MEMORY
│ ├── assets/ # Static files (CSS, images, sample data)
│ └── pages/ # One module per page, each with dash.register_page
-│ ├── home.py
+│ ├── introduction.py
+│ ├── data_table.py
│ └── analytics.py
├── tests/ # pytest suite
├── notebooks/ # ad-hoc exploration, outside the running app
@@ -50,9 +51,48 @@ python-dashboard-template/
- **Component Libraries**: Prioritize component libraries in this order: Dash Core Components combined with Dash HTML Components, then Dash Mantine Components, then Dash Bootstrap Components if required. Try to minimize the number of libraries required.
- **Data Tables**: Do not use `dash.datatable`; use `dash.AgGrid` instead.
- **AgGrid Configs**: When instantiating `dag.AgGrid`, always set the following properties:
- - `dashGridOptions={"theme": "themeBalham", "animateRows": True, "pagination": True, "paginationPageSize": 10}`
- `columnSize="responsiveSizeToFit"`
- `defaultColDef={"filter": True, "sortable": True}`
+ - `dashGridOptions={"theme": "themeBalham", "animateRows": True, ...}`, choosing pagination settings based on row count:
+ - 15 rows or fewer: `{"pagination": False, "domLayout": "autoHeight"}` — the grid sizes to its content instead of drawing a tall empty box with a pager underneath a handful of rows.
+ - more than 15 rows: `{"pagination": True, "paginationPageSize": 10}`
+
+## Fullscreen Toggle Pattern
+A reusable "expand to fullscreen" button for any chart inside a `.visual` box. No Dash callback is needed — it's pure CSS + one small JS file in `assets/`, so it automatically applies to any current or future chart that follows the markup pattern below.
+
+**Why it's built this way (read this before changing it):** the naive version — toggle a `position: fixed` class and call `Plotly.Plots.resize(gd)` — breaks in two ways that are easy to reintroduce by accident:
+1. Outside fullscreen, a plot's container normally has no explicit height (it's sized *by* the plot, not the other way round). Asking Plotly to "resize to fit its container" on exit is therefore circular — the container has no size to resize to, and the chart doesn't shrink back.
+2. `dcc.Loading` wraps the graph in one or two extra `
`s whose class names aren't part of the public Dash API. Trying to cascade a height down through them with CSS percentages (`height: 100%` chained through unknown wrapper divs) silently breaks and leaves the chart stuck at a stale pixel size — which, in a flex row with the default `align-items: stretch`, then drags the *other* column's box height along with it.
+
+The fix: give the chart's own wrapper (`.graph-wrap`, not the Plotly div itself) an explicit, always-defined height in both states (fixed px normally, flex-filled in fullscreen), then measure that wrapper directly in JS and set the chart's exact pixel size via `Plotly.relayout(gd, {width, height, autosize: false})`. This never depends on the unknown internal `dcc.Loading` DOM structure.
+
+**Markup** — wrap every chart to make fullscreen-able like this:
+```python
+html.Div(
+ [
+ html.Button(
+ "⛶",
+ className="fullscreen-toggle-btn",
+ title="Toggle full screen",
+ **{"aria-label": "Toggle full screen"},
+ ),
+ html.Div("Chart Title", className="visual-title"),
+ html.Div(
+ dcc.Loading(dcc.Graph(id="my-chart")),
+ className="graph-wrap",
+ ),
+ ],
+ className="visual",
+)
+```
+If several charts sit side by side in a flex row, add `"alignItems": "flex-start"` to that row's `style` dict — a safety net so one chart's sizing hiccup can never stretch its neighbor.
+
+The CSS lives in `assets/css/main.css` (the `.visual`, `.fullscreen-toggle-btn`, `.visual--fullscreen`, `.graph-wrap` and `body.fullscreen-active` rules) and the JS lives in `assets/fullscreen.js`. Both are already in the template and apply automatically — no per-page wiring needed beyond the markup above.
+
+**Rules when reusing this:**
+- Always wrap the chart in `.graph-wrap` — never put `fullscreen-toggle-btn` next to a bare `dcc.Graph`/`dcc.Loading` without it; the JS measures `.graph-wrap`, not the Plotly div, so skipping it breaks the resize.
+- Don't try to make the chart's height cascade through CSS percentages past `.graph-wrap` — that's the exact thing that broke before. Let the JS set the Plotly size explicitly.
+- Only one visual can be fullscreen at a time by design (`enterFullscreen` clears any other `.visual--fullscreen` first); don't remove that if adding more charts.
## Avoid Hallucinations
- Never use `app.run_server`; only use `app.run`
diff --git a/README.md b/README.md
index 2840f59..c2e2114 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,13 @@
# Python Dashboard Template
-A starting point for Plotly Dash dashboards: one theme, one working example
-page and one filterable-chart page, a mock-up of 4insight's header for local
-layout work, and the process scaffolding (CI, PR template, `CLAUDE.md`
+A starting point for Plotly Dash dashboards: one theme, a landing page with a
+revision log and notes box, a data-table example page and a
+filterable-chart page, a mock-up of 4insight's header for local layout
+work, and the process scaffolding (CI, PR template, `CLAUDE.md`
conventions) set up.
To start a new project from this template: clone or copy it, replace
-`src/assets/sample_data.csv` and the two pages in `src/pages/` with your own,
+`src/assets/sample_data.csv` and the pages in `src/pages/` with your own,
and update this README and the browser-tab title in `src/app.py`.
## Running it
@@ -50,8 +51,9 @@ src/
├── theme.py colours, type scale and the Plotly template
├── memory_log.py dev aid: prints RSS memory usage, see LOG_MEMORY
├── pages/
-│ ├── home.py example: an AgGrid over the sample data
-│ └── analytics.py example: a slicer driving a filtered Plotly chart
+│ ├── introduction.py landing page: revision log AgGrid and a free-text notes box
+│ ├── data_table.py example: an AgGrid over the sample data
+│ └── analytics.py example: a slicer driving a filtered Plotly chart
└── assets/
├── css/main.css page styling, mirrors theme.py as CSS variables
├── 4insight_logo.png
diff --git a/src/assets/css/main.css b/src/assets/css/main.css
index 7aedc2d..cdbe314 100644
--- a/src/assets/css/main.css
+++ b/src/assets/css/main.css
@@ -257,6 +257,12 @@ body,
-------------------------------------------------------------------------- */
.visual {
+ position: relative;
+ /* so .fullscreen-toggle-btn can be pinned to a corner. No overflow: hidden
+ here - .graph-wrap already clips chart overflow during resize (see
+ fullscreen.js), and hiding overflow on every .visual would also clip
+ unrelated content that legitimately grows past the box, such as a
+ resizable .notes-textarea. */
border: 1px solid var(--border-grey-1);
border-radius: var(--radius);
padding: 12px;
@@ -287,6 +293,18 @@ body,
margin-bottom: var(--gap);
}
+.notes-textarea {
+ width: 100%;
+ min-height: 120px;
+ padding: 0;
+ border: none;
+ font-family: var(--font-main);
+ font-size: var(--size-body);
+ color: var(--body-text);
+ box-sizing: border-box;
+ resize: vertical;
+}
+
/* --------------------------------------------------------------------------
Tables (AG Grid)
top border 2px dark grey · inner 1px grey · headers size 9 dark grey
@@ -391,4 +409,71 @@ input[disabled] {
border: 1px solid var(--border-grey-2);
background-color: var(--border-grey-1);
color: var(--grey);
+}
+
+/* --------------------------------------------------------------------------
+ Fullscreen toggle for chart visuals — see assets/fullscreen.js and the
+ "Fullscreen Toggle Pattern" section in CLAUDE.md for the markup and the
+ reasoning behind this approach.
+ -------------------------------------------------------------------------- */
+
+.fullscreen-toggle-btn {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ z-index: 10;
+ width: 28px;
+ height: 28px;
+ min-height: 28px;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+}
+
+.visual--fullscreen {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ width: 100vw;
+ height: 100vh;
+ z-index: 2000;
+ margin: 0;
+ border-radius: 0;
+ padding: 24px;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ overflow: auto;
+}
+
+.visual--fullscreen .fullscreen-toggle-btn {
+ top: 16px;
+ right: 16px;
+}
+
+.visual--fullscreen .visual-title {
+ flex: 0 0 auto;
+}
+
+/* .graph-wrap's own box size is always deterministic - a fixed height
+ normally, or flex-filled the remaining fullscreen space. We do NOT rely
+ on that cascading further down into dcc.Loading's wrapper divs; the JS
+ in fullscreen.js measures this box directly instead. */
+.graph-wrap {
+ height: 460px;
+ overflow: hidden;
+}
+
+.visual--fullscreen .graph-wrap {
+ flex: 1 1 auto;
+ min-height: 0;
+ height: auto;
+}
+
+body.fullscreen-active {
+ overflow: hidden;
}
\ No newline at end of file
diff --git a/src/assets/fullscreen.js b/src/assets/fullscreen.js
new file mode 100644
index 0000000..131b000
--- /dev/null
+++ b/src/assets/fullscreen.js
@@ -0,0 +1,61 @@
+// Fullscreen toggle for chart visuals. See the "Fullscreen Toggle Pattern"
+// section in CLAUDE.md for the required markup and the reasoning behind
+// this approach.
+//
+// Event delegation (rather than per-chart wiring) so this survives
+// dcc.Loading re-rendering the DOM, and applies automatically to any
+// current or future chart that follows the markup pattern.
+
+function resizeGraphsIn(container) {
+ if (!window.Plotly) return;
+ container.querySelectorAll(".graph-wrap").forEach(function (wrap) {
+ const gd = wrap.querySelector(".js-plotly-plot");
+ if (!gd) return;
+ const rect = wrap.getBoundingClientRect();
+ const width = Math.round(rect.width);
+ const height = Math.round(rect.height);
+ if (width > 0 && height > 0) {
+ window.Plotly.relayout(gd, { width: width, height: height, autosize: false });
+ }
+ });
+}
+
+function settleResize(visual) {
+ requestAnimationFrame(function () {
+ resizeGraphsIn(visual);
+ setTimeout(function () { resizeGraphsIn(visual); }, 100);
+ });
+}
+
+function exitFullscreen(visual) {
+ visual.classList.remove("visual--fullscreen");
+ document.body.classList.remove("fullscreen-active");
+ settleResize(visual);
+}
+
+function enterFullscreen(visual) {
+ document.querySelectorAll(".visual--fullscreen").forEach(function (el) {
+ el.classList.remove("visual--fullscreen");
+ });
+ visual.classList.add("visual--fullscreen");
+ document.body.classList.add("fullscreen-active");
+ settleResize(visual);
+}
+
+document.addEventListener("click", function (event) {
+ const btn = event.target.closest(".fullscreen-toggle-btn");
+ if (!btn) return;
+ const visual = btn.closest(".visual");
+ if (!visual) return;
+ if (visual.classList.contains("visual--fullscreen")) {
+ exitFullscreen(visual);
+ } else {
+ enterFullscreen(visual);
+ }
+});
+
+document.addEventListener("keydown", function (event) {
+ if (event.key !== "Escape") return;
+ const visual = document.querySelector(".visual--fullscreen");
+ if (visual) exitFullscreen(visual);
+});
diff --git a/src/pages/analytics.py b/src/pages/analytics.py
index 5e9b3b1..d1857fb 100644
--- a/src/pages/analytics.py
+++ b/src/pages/analytics.py
@@ -12,15 +12,15 @@
import plotly.express as px
from dash import Input, Output, callback, dcc, html
-dash.register_page(__name__, path="/analytics", name="Analytics", order=1)
+dash.register_page(__name__, path="/analytics", name="Analytics", order=2)
SAMPLE_DATA_PATH = pathlib.Path(__file__).resolve().parents[1] / "assets" / "sample_data.csv"
def load_sample_data():
"""Not shared with app.py: importing from app here would re-trigger Dash's
- own page auto-discovery when the app is run as a script. See home.py for
- the same function - duplicated rather than imported, on purpose."""
+ own page auto-discovery when the app is run as a script. See data_table.py
+ for the same function - duplicated rather than imported, on purpose."""
return pd.read_csv(SAMPLE_DATA_PATH)
diff --git a/src/pages/home.py b/src/pages/data_table.py
similarity index 79%
rename from src/pages/home.py
rename to src/pages/data_table.py
index 6ed07c8..d50119f 100644
--- a/src/pages/home.py
+++ b/src/pages/data_table.py
@@ -1,10 +1,10 @@
"""
-Landing page
-------------
+Data table page
+----------------
-This is the "Home" page, which is the first page users see when they open the app.
-It contains a table of sample data fetched from the "assets/sample_data.csv" file.
-The table is implemented using the Dash AG Grid component, which allows for filtering, sorting, and pagination.
+Example page showing a table of sample data fetched from the
+"assets/sample_data.csv" file. The table is implemented using the Dash AG
+Grid component, which allows for filtering, sorting, and pagination.
"""
@@ -15,7 +15,7 @@
import pandas as pd
from dash import dcc, html
-dash.register_page(__name__, path="/", name="Home", order=0)
+dash.register_page(__name__, path="/data-table", name="Data table", order=1)
SAMPLE_DATA_PATH = pathlib.Path(__file__).resolve().parents[1] / "assets" / "sample_data.csv"
@@ -30,7 +30,7 @@ def load_sample_data():
def layout():
df = load_sample_data()
grid = dag.AgGrid(
- id="home-sample-grid",
+ id="data-table-sample-grid",
rowData=df.to_dict("records"),
columnDefs=[{"field": col, "headerName": col} for col in df.columns],
defaultColDef={"filter": True, "sortable": True},
diff --git a/src/pages/introduction.py b/src/pages/introduction.py
new file mode 100644
index 0000000..b37d668
--- /dev/null
+++ b/src/pages/introduction.py
@@ -0,0 +1,86 @@
+"""Introduction page: revision log and a free-text notes box.
+
+Landing page. The revision log records who issued, checked and approved
+each version of the app's content - add a row to issue a new revision
+rather than editing the last one, since the point of the log is the
+history.
+"""
+
+import dash
+import dash_ag_grid as dag
+from dash import dcc, html
+
+dash.register_page(__name__, path="/", name="Introduction", order=0)
+
+# Empty Checked, Approved, etc. mean exactly that: not yet checked, not yet
+# approved. Fill them in when you fill in a real revision.
+
+REVISION_LOG = [
+ {
+ "Revision No.": "1.0",
+ "Date": "2026-01-01",
+ "Author": "ABC",
+ "Checked": "DEF",
+ "Approved": "GHI",
+ "Comment": " ",
+ },
+]
+
+
+def layout():
+ grid = dag.AgGrid(
+ id="introduction-revision-log-grid",
+ rowData=REVISION_LOG,
+ # headerName explicitly, or AG Grid title-cases the field: "Comment"
+ # would still be fine, but "Revision No." would render as "Revision No .".
+ # flex: the first 5 columns are 20% narrower than an even split, and
+ # Comment absorbs that freed width (0.8 * 5 = 4, so Comment's flex of
+ # 2 keeps the same total of 6 that six equal columns would have had).
+ columnDefs=(
+ [{"field": col, "headerName": col, "flex": 0.8} for col in list(REVISION_LOG[0])[:5]]
+ + [{"field": "Comment", "headerName": "Comment", "flex": 2}]
+ ),
+ defaultColDef={"filter": True, "sortable": True},
+ # No columnSize: AG Grid's sizeColumnsToFit (what "responsiveSizeToFit"
+ # calls) recalculates widths on its own and overrides colDef.flex in
+ # the process - the two are alternative sizing mechanisms, not
+ # composable. Flex-sized columns already resize responsively without it.
+ dashGridOptions={
+ "theme": "themeBalham",
+ "animateRows": True,
+ # No pagination: the log only ever holds a handful of rows, and
+ # autoHeight sizes the grid to its content instead of drawing a
+ # tall empty box with a pager underneath it.
+ "pagination": False,
+ "domLayout": "autoHeight",
+ # A dot in a field name is a nested-property path to AG Grid, so
+ # "Revision No." would look up row["Revision No"][""] and render
+ # blank. The field names here are human labels, never paths.
+ "suppressFieldDotNotation": True,
+ },
+ )
+ return html.Div(
+ [
+ html.Div(
+ [
+ html.Div("Project info", className="visual-title"),
+ dcc.Textarea(
+ id="introduction-notes-textarea",
+ value="Here you can write free text about the project, the dashboard or other relevant information.",
+ className="notes-textarea",
+ ),
+ ],
+ className="visual",
+ ),
+ html.Div(
+ [
+ html.Div("Revision log", className="visual-title"),
+ dcc.Loading(grid),
+ ],
+ className="visual row-gap",
+ ),
+ ],
+ # A small table and a text box read as mostly whitespace spread
+ # across the full 1800px content width.
+ className="narrow-page",
+ )
diff --git a/tests/test_app.py b/tests/test_app.py
index 8dd30a8..9e9d505 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -10,7 +10,8 @@
# sharing an `order` - this dict is the spec these tests check reality
# against, so add your new page's path/name here too.
EXPECTED_PAGES = {
- "/": "Home",
+ "/": "Introduction",
+ "/data-table": "Data table",
"/analytics": "Analytics",
}
@@ -62,7 +63,8 @@ def component_ids(node, found=None):
# Same deal as EXPECTED_PAGES: add your new page's callback-bound component
# ids here, or they simply aren't checked (not a failure, just a silent gap).
CALLBACK_IDS = {
- "/": {"home-sample-grid"},
+ "/": {"introduction-revision-log-grid"},
+ "/data-table": {"data-table-sample-grid"},
"/analytics": {"analytics-category-filter", "analytics-chart"},
}