@cosla/sensemaking-report-builder 0.1.1 → 0.2.0

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/README.md CHANGED
@@ -55,7 +55,9 @@ Defaults: opinions `<inputDir>/opinions.csv`, summary `<inputDir>/summary.json`,
55
55
 
56
56
  | Arg / flag | Purpose | Default |
57
57
  |------------|---------|---------|
58
- | `inline` \| `static` | Build mode (**required**) | — |
58
+ | `inline` \| `static` | Build mode (**required** for builds) | — |
59
+ | `config-schema` | Print config JSON Schema to stdout (no inputs) | — |
60
+ | `--schema-version` | With `config-schema`: schema version to print | latest (`1`) |
59
61
  | `--inputDir` | Base dir for default input paths | `./input` |
60
62
  | `--opinions` / `--bridging_scores` | Opinions / bridging scores CSV | `<inputDir>/opinions.csv` |
61
63
  | `--summary` | Summary JSON (`report_data.json`) | `<inputDir>/summary.json` |
@@ -64,6 +66,21 @@ Defaults: opinions `<inputDir>/opinions.csv`, summary `<inputDir>/summary.json`,
64
66
  | `--output` | **inline only:** path to HTML file | `./output/report.html` |
65
67
  | `--outputDir` | **static only:** output directory | `./output` |
66
68
 
69
+ ### Config schema
70
+
71
+ ```bash
72
+ npx @cosla/sensemaking-report-builder config-schema
73
+ npx @cosla/sensemaking-report-builder config-schema --schema-version 1
74
+ ```
75
+
76
+ Prints a [JSON Schema draft 2020-12](https://json-schema.org/draft/2020-12/schema) document for supported `config.json` keys (`schemas/config.v1.json`), including application field `config_schema_version`. Unknown `--schema-version` values fail with a clear error.
77
+
78
+ ```bash
79
+ npm test # smoke (config-schema) + unit tests
80
+ npm run smoke # config-schema only
81
+ npm run unit # node:test suite only
82
+ ```
83
+
67
84
  Logo and translations are configured in `config.json` (not CLI flags). Place `logo.svg` / `logo.png` next to that config (under `--inputDir` when using dir mode).
68
85
 
69
86
  ## Input contracts
@@ -72,7 +89,7 @@ Logo and translations are configured in `config.json` (not CLI flags). Place `lo
72
89
 
73
90
  Required columns: `topic`, `opinion`, `quote`, `participant_id`.
74
91
 
75
- Optional: `AVERAGE_OF_2_BRIDGING` or `AVERAGE_OF_3_BRIDGING` (used to sort quotes). Demographic columns: prefix with `demo:` (e.g. `demo:Age`).
92
+ Optional: `AVERAGE_OF_2_BRIDGING` or `AVERAGE_OF_3_BRIDGING` (used to sort quotes; OF_2 preferred when both are present). When neither column is present, a warning is printed and quotes keep input order. Demographic columns: prefix with `demo:` (e.g. `demo:Age`). When no demographic columns have values, the participant overview and drawer demographics tab are omitted.
76
93
 
77
94
  ### Summary JSON
78
95
 
@@ -88,9 +105,9 @@ Same shape as Python `sensemaking-report-text` / `report_data.json`: `text`, `su
88
105
  | `number_of_top_opinions` | Opinions overview chart size |
89
106
  | `number_of_sample_quotes` | Quote previews per opinion |
90
107
  | `low_sample_warning_threshold` | Low-sample warning threshold |
91
- | `topic_colors` / `chart_colors` | Overview chart colours |
92
- | `demographic_colors` | Participant chart colours |
93
- | `excluded_topics` / `excluded_opinions` | Hide named topics/opinions (exact match to CSV columns) |
108
+ | `chart_colors` | Overview chart colours (array of hex strings). Default: `#DA3D2E`, `#F2A50C`, `#80CD57`, `#00885F`, `#11C2CE`, `#1233A6`, `#8A2FE4`, `#F388D2`. Blank entries are ignored; an empty list falls back to the default. |
109
+ | `demographic_colors` | Participant chart colours: a single hex string (a 6-step light-to-dark palette is generated) or an explicit array of colours. Default base: `#4886f7`. Generated ramps use CSS relative colour syntax (`oklch(from …)`; Chrome 119+, Firefox 128+, Safari 18+); use an explicit hex array for older browsers. |
110
+ | `excluded_topics` / `excluded_opinions` | Hide named topics/opinions (exact match to CSV columns). CamelCase aliases `excludedTopics` / `excludedOpinions` are also accepted at runtime. |
94
111
 
95
112
  ### Predicted agreement (optional)
96
113
 
package/bin/cli.js CHANGED
@@ -3,6 +3,7 @@ import { runBuild } from "../build.js";
3
3
 
4
4
  runBuild(process.argv, process.cwd())
5
5
  .then((result) => {
6
+ if (result.command === "config-schema") return;
6
7
  const out = result.output || result.outputDir;
7
8
  console.log(`Standalone report generated: ${out}`);
8
9
  })
package/build.js CHANGED
@@ -10,7 +10,11 @@ import { execSync } from "node:child_process";
10
10
  import csv from "csvtojson";
11
11
  import mustache from "mustache";
12
12
  import { inlineSource } from "inline-source";
13
- import { processReportData, resolveBuildOptions } from "./data.js";
13
+ import {
14
+ loadConfigSchema,
15
+ processReportData,
16
+ resolveBuildOptions,
17
+ } from "./data.js";
14
18
 
15
19
  const WORK_DIR_NAME = ".py-report-ui-work";
16
20
 
@@ -173,6 +177,13 @@ function inlineFontDataUris(html, fontsDir) {
173
177
  export async function runBuild(argv = process.argv, cwd = process.cwd()) {
174
178
  const options = resolveBuildOptions(argv, cwd);
175
179
  const packageRoot = path.dirname(fileURLToPath(import.meta.url));
180
+
181
+ if (options.command === "config-schema") {
182
+ const schema = loadConfigSchema(options.schemaVersion, packageRoot);
183
+ process.stdout.write(`${JSON.stringify(schema, null, 2)}\n`);
184
+ return { command: "config-schema" };
185
+ }
186
+
176
187
  const artefactRoot =
177
188
  options.command === "inline"
178
189
  ? path.dirname(options.output)
@@ -296,6 +307,7 @@ if (isMain) {
296
307
  } else {
297
308
  runBuild(process.argv, process.cwd())
298
309
  .then((result) => {
310
+ if (result.command === "config-schema") return;
299
311
  console.log(
300
312
  `Standalone report generated: ${result.output || result.outputDir}`,
301
313
  );
package/data.js CHANGED
@@ -10,8 +10,35 @@
10
10
  import fs from "node:fs";
11
11
  import path from "node:path";
12
12
 
13
+ import { resolveDemographicColors } from "./palette.js";
14
+
13
15
  const demographics_prefix = "demo:";
14
16
 
17
+ /**
18
+ * Bridging average columns, in preference order. Matches columns written by
19
+ * get_bridging_scores.py (OF_3) and older pipeline outputs (OF_2).
20
+ * @type {string[]}
21
+ */
22
+ export const BRIDGING_COLUMNS = [
23
+ "AVERAGE_OF_2_BRIDGING",
24
+ "AVERAGE_OF_3_BRIDGING",
25
+ ];
26
+
27
+ /**
28
+ * Default topic / overview chart colours when config.chart_colors is absent.
29
+ * @type {string[]}
30
+ */
31
+ export const DEFAULT_CHART_COLORS = [
32
+ "#DA3D2E",
33
+ "#F2A50C",
34
+ "#80CD57",
35
+ "#00885F",
36
+ "#11C2CE",
37
+ "#1233A6",
38
+ "#8A2FE4",
39
+ "#F388D2",
40
+ ];
41
+
15
42
  /**
16
43
  * @typedef {Object} RawOpinion
17
44
  * @property {string} topic - The high-level topic category.
@@ -22,19 +49,84 @@ const demographics_prefix = "demo:";
22
49
  * @property {string|number} [AVERAGE_OF_3_BRIDGING] - Alternate bridging column.
23
50
  */
24
51
 
52
+ /**
53
+ * Returns whether any row has a known bridging-score column.
54
+ * @param {Object[]} rows
55
+ * @returns {boolean}
56
+ */
57
+ export function hasBridgingColumn(rows) {
58
+ return rows.some((row) => BRIDGING_COLUMNS.some((col) => col in row));
59
+ }
60
+
61
+ /**
62
+ * Reads bridging score from AVERAGE_OF_2_BRIDGING or AVERAGE_OF_3_BRIDGING.
63
+ * Prefers OF_2 when both are present (legacy Cosla behaviour).
64
+ * @param {RawOpinion|Object} row
65
+ * @returns {number}
66
+ */
67
+ export function getBridgingScore(row) {
68
+ const num = Number(row.AVERAGE_OF_2_BRIDGING ?? row.AVERAGE_OF_3_BRIDGING);
69
+ return Number.isFinite(num) ? num : 0;
70
+ }
71
+
72
+ /**
73
+ * Drops blank/non-string entries from a colour list. Non-arrays pass through.
74
+ * Empty or all-blank arrays become undefined so callers can fall back to
75
+ * defaults (guards against Consul string-list rows that submit `[""]`).
76
+ * @param {unknown} value
77
+ * @returns {unknown}
78
+ */
79
+ export function cleanColorList(value) {
80
+ if (!Array.isArray(value)) return value;
81
+ const cleaned = value.filter((c) => typeof c === "string" && c.trim() !== "");
82
+ return cleaned.length > 0 ? cleaned : undefined;
83
+ }
84
+
25
85
  /**
26
86
  * @typedef {Object} BuildOptions
27
- * @property {"inline"|"static"} command
28
- * @property {string} inputDir
29
- * @property {string|null} output
30
- * @property {string|null} outputDir
31
- * @property {string} opinionsPath
32
- * @property {string} summaryPath
33
- * @property {string|null} predictedPath
34
- * @property {string|null} configPath
35
- * @property {boolean} predictedExplicit
36
- * @property {boolean} configExplicit
87
+ * @property {"inline"|"static"|"config-schema"} command
88
+ * @property {string|null} [schemaVersion]
89
+ * @property {string} [inputDir]
90
+ * @property {string|null} [output]
91
+ * @property {string|null} [outputDir]
92
+ * @property {string} [opinionsPath]
93
+ * @property {string} [summaryPath]
94
+ * @property {string|null} [predictedPath]
95
+ * @property {string|null} [configPath]
96
+ * @property {boolean} [predictedExplicit]
97
+ * @property {boolean} [configExplicit]
98
+ */
99
+
100
+ /** Latest shipped config schema version (omit `--schema-version` → this). */
101
+ export const LATEST_CONFIG_SCHEMA_VERSION = 1;
102
+
103
+ const CONFIG_SCHEMA_FILES = {
104
+ 1: "config.v1.json",
105
+ };
106
+
107
+ /**
108
+ * Loads a config JSON Schema document for the given version.
109
+ * @param {string|number|null|undefined} schemaVersion
110
+ * @param {string} packageRoot Absolute path to the package root (directory of build.js).
111
+ * @returns {object}
37
112
  */
113
+ export function loadConfigSchema(schemaVersion, packageRoot) {
114
+ const version =
115
+ schemaVersion === undefined || schemaVersion === null || schemaVersion === ""
116
+ ? LATEST_CONFIG_SCHEMA_VERSION
117
+ : Number(schemaVersion);
118
+ if (!Number.isInteger(version) || !CONFIG_SCHEMA_FILES[version]) {
119
+ const supported = Object.keys(CONFIG_SCHEMA_FILES).join(", ");
120
+ throw new Error(
121
+ `Unknown config schema version "${schemaVersion}". Supported: ${supported}.`,
122
+ );
123
+ }
124
+ const schemaPath = path.join(packageRoot, "schemas", CONFIG_SCHEMA_FILES[version]);
125
+ if (!fs.existsSync(schemaPath)) {
126
+ throw new Error(`Config schema file not found: ${schemaPath}`);
127
+ }
128
+ return JSON.parse(fs.readFileSync(schemaPath, "utf-8"));
129
+ }
38
130
 
39
131
  /**
40
132
  * Parses argv flags into a Map of key -> value.
@@ -69,16 +161,26 @@ export function resolveBuildOptions(argv = process.argv, cwd = process.cwd()) {
69
161
  const commandToken = args[0] && !args[0].startsWith("--") ? args[0] : null;
70
162
  if (!commandToken) {
71
163
  throw new Error(
72
- 'Build mode is required. Use "inline" or "static" as the first argument.',
164
+ 'Command is required. Use "inline", "static", or "config-schema" as the first argument.',
73
165
  );
74
166
  }
167
+
168
+ const flags = parseFlags(args);
169
+
170
+ if (commandToken === "config-schema") {
171
+ return {
172
+ command: commandToken,
173
+ schemaVersion: flags.has("schema-version")
174
+ ? flags.get("schema-version")
175
+ : null,
176
+ };
177
+ }
178
+
75
179
  if (commandToken !== "inline" && commandToken !== "static") {
76
180
  throw new Error(
77
- `Unsupported command "${commandToken}". Use "inline" or "static".`,
181
+ `Unsupported command "${commandToken}". Use "inline", "static", or "config-schema".`,
78
182
  );
79
183
  }
80
-
81
- const flags = parseFlags(args);
82
184
  const hasOutput = flags.has("output");
83
185
  const hasOutputDir = flags.has("outputDir");
84
186
 
@@ -273,16 +375,6 @@ function generateId(str, useFirstWords = false) {
273
375
  );
274
376
  }
275
377
 
276
- /**
277
- * Reads bridging score from AVERAGE_OF_2_BRIDGING or AVERAGE_OF_3_BRIDGING.
278
- * @param {RawOpinion} row
279
- * @returns {number}
280
- */
281
- function bridgingScore(row) {
282
- const raw = row.AVERAGE_OF_2_BRIDGING ?? row.AVERAGE_OF_3_BRIDGING;
283
- return raw ? +raw : 0;
284
- }
285
-
286
378
  /**
287
379
  * Resolves optional user i18n file under the staged input directory.
288
380
  * @param {Object} config
@@ -327,6 +419,13 @@ export function processReportData({
327
419
  index,
328
420
  }));
329
421
 
422
+ if (opinions.length > 0 && !hasBridgingColumn(opinions)) {
423
+ console.warn(
424
+ `Warning: no ${BRIDGING_COLUMNS.join(" or ")} column found in the ` +
425
+ "opinions data; quotes will not be ordered by bridging score.",
426
+ );
427
+ }
428
+
330
429
  const config = configPath
331
430
  ? JSON.parse(fs.readFileSync(configPath, "utf-8"))
332
431
  : {};
@@ -380,8 +479,12 @@ export function processReportData({
380
479
  }
381
480
 
382
481
  const overviewChart = config.overview_chart || "toggle";
383
- const excludedTopics = new Set(config.excluded_topics || []);
384
- const excludedOpinions = new Set(config.excluded_opinions || []);
482
+ const excludedTopics = new Set(
483
+ config.excluded_topics ?? config.excludedTopics ?? [],
484
+ );
485
+ const excludedOpinions = new Set(
486
+ config.excluded_opinions ?? config.excludedOpinions ?? [],
487
+ );
385
488
  const reportOptions = {
386
489
  logo: config.logo || "",
387
490
  overviewChart,
@@ -395,23 +498,11 @@ export function processReportData({
395
498
  Math.max(config.number_of_top_opinions || 10, 2),
396
499
  20,
397
500
  ),
398
- topicColors: config.chart_colors || [
399
- "#AFB42B",
400
- "#F4511E",
401
- "#3949AB",
402
- "#E52592",
403
- "#00897B",
404
- "#EFB22F",
405
- "#aaa",
406
- ],
407
- demographicColors: config.demographic_colors || [
408
- "#4886f7",
409
- "#4071d5",
410
- "#385db3",
411
- "#2f4a93",
412
- "#273874",
413
- "#1e2656",
414
- ],
501
+ topicColors:
502
+ cleanColorList(config.chart_colors) ?? DEFAULT_CHART_COLORS,
503
+ demographicColors: resolveDemographicColors(
504
+ cleanColorList(config.demographic_colors),
505
+ ),
415
506
  };
416
507
 
417
508
  /**
@@ -430,7 +521,7 @@ export function processReportData({
430
521
  index: v.index,
431
522
  text: v.quote,
432
523
  participant_id: v.participant_id,
433
- avg_bridging: bridgingScore(v),
524
+ avg_bridging: getBridgingScore(v),
434
525
  ...demos,
435
526
  };
436
527
  })
@@ -668,6 +759,7 @@ export function processReportData({
668
759
  });
669
760
 
670
761
  demographics.sort((a, b) => a.label.localeCompare(b.label, i18n.locale));
762
+ const hasDemographics = demographics.some((d) => d.values.length > 0);
671
763
 
672
764
  const executiveSummary = parseSummary(cleanMarkdown(summary.text || ""));
673
765
  const title = stripMarkdownHeader(summary.title);
@@ -698,6 +790,7 @@ export function processReportData({
698
790
  propositionsGenerated,
699
791
  topics,
700
792
  demographics,
793
+ hasDemographics,
701
794
  predicted,
702
795
  hasPredicted: predicted.topics.length > 0,
703
796
  i18n,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosla/sensemaking-report-builder",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Interactive HTML report builder for Cosla Python sensemaking pipeline outputs.",
5
5
  "type": "module",
6
6
  "main": "build.js",
@@ -11,8 +11,11 @@
11
11
  "bin/",
12
12
  "build.js",
13
13
  "data.js",
14
+ "palette.js",
14
15
  "src/**/*",
15
- "README.md"
16
+ "schemas/**/*",
17
+ "README.md",
18
+ "smoke.js"
16
19
  ],
17
20
  "scripts": {
18
21
  "predev": "node bin/cli.js static --outputDir ./output",
@@ -21,7 +24,10 @@
21
24
  "inline": "node bin/cli.js inline",
22
25
  "build": "node bin/cli.js inline",
23
26
  "preview": "node build.js preview",
24
- "github": "node build.js github"
27
+ "github": "node build.js github",
28
+ "smoke": "node smoke.js",
29
+ "unit": "node --test",
30
+ "test": "npm run smoke && npm run unit"
25
31
  },
26
32
  "repository": {
27
33
  "type": "git",
package/palette.js ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * @fileoverview Sequential color palette generation for the report UI.
3
+ *
4
+ * Builds a light-to-dark palette from a single base hex color using CSS
5
+ * Relative Color Syntax (`oklch(from <color> l c h)`), delegating color-space
6
+ * conversion and gamut mapping to the browser's CSS engine.
7
+ */
8
+
9
+ /**
10
+ * Number of colors generated for the demographic chart. The chart shows at
11
+ * most five values plus "Other" (see data.js), so six colors are enough.
12
+ * @type {number}
13
+ */
14
+ export const DEMOGRAPHIC_PALETTE_STEPS = 6;
15
+
16
+ /**
17
+ * Base color for the default demographic palette.
18
+ * @type {string}
19
+ */
20
+ export const DEFAULT_DEMOGRAPHIC_BASE_COLOR = "#4886f7";
21
+
22
+ /**
23
+ * Lightest OKLCH lightness used in generated palettes.
24
+ *
25
+ * Palettes deliberately use a wide lightness range: lightness differences are
26
+ * what keep segments distinguishable for color-blind readers. The lightest
27
+ * segment is therefore a tint with less than 3:1 contrast against the chart
28
+ * background; segments are separated by gaps and labelled in the legend, so
29
+ * color is not the only way to tell values apart.
30
+ * @type {number}
31
+ */
32
+ export const PALETTE_MAX_LIGHTNESS = 0.85;
33
+
34
+ /**
35
+ * Darkest OKLCH lightness used in generated palettes. Dark enough for a strong
36
+ * end to the ramp while staying visibly tinted rather than black.
37
+ * @type {number}
38
+ */
39
+ export const PALETTE_MIN_LIGHTNESS = 0.22;
40
+
41
+ /**
42
+ * Fraction of the base color's chroma kept at PALETTE_MAX_LIGHTNESS. Steps
43
+ * lighter than the base taper towards this, so pale steps are soft tints
44
+ * rather than fluorescent.
45
+ * @type {number}
46
+ */
47
+ export const LIGHTEST_CHROMA_FRACTION = 0.5;
48
+
49
+ const HEX_COLOR_RE = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
50
+
51
+ /**
52
+ * Returns whether `value` is a `#rgb` or `#rrggbb` hex color string.
53
+ * @param {unknown} value
54
+ * @returns {boolean}
55
+ */
56
+ export function isHexColor(value) {
57
+ return typeof value === "string" && HEX_COLOR_RE.test(value.trim());
58
+ }
59
+
60
+ /**
61
+ * Generates a light-to-dark palette of CSS `oklch(from ...)` strings from a
62
+ * single base hex color.
63
+ *
64
+ * Every step keeps the base hue (`h`), spaces lightness (`targetL`) evenly
65
+ * from PALETTE_MAX_LIGHTNESS down to PALETTE_MIN_LIGHTNESS, and scales the
66
+ * base chroma (`c`) in CSS:
67
+ * - Steps darker than the base scale chroma proportionally (`targetL / l`).
68
+ * - Steps lighter than the base taper chroma linearly toward
69
+ * `LIGHTEST_CHROMA_FRACTION * c` at `PALETTE_MAX_LIGHTNESS`.
70
+ *
71
+ * @param {string} [baseHex=DEFAULT_DEMOGRAPHIC_BASE_COLOR] - `#rgb` or `#rrggbb`.
72
+ * @param {number} [steps=DEMOGRAPHIC_PALETTE_STEPS] - Number of steps (>= 2).
73
+ * @returns {string[]} CSS `oklch(from ...)` color strings, lightest to darkest.
74
+ * @throws {Error} If `baseHex` is not a valid hex color or `steps` < 2.
75
+ */
76
+ export function generateSequentialPalette(
77
+ baseHex = DEFAULT_DEMOGRAPHIC_BASE_COLOR,
78
+ steps = DEMOGRAPHIC_PALETTE_STEPS,
79
+ ) {
80
+ if (!isHexColor(baseHex)) {
81
+ throw new Error(`Invalid hex color: ${baseHex}`);
82
+ }
83
+ if (!Number.isInteger(steps) || steps < 2) {
84
+ throw new Error(`steps must be an integer >= 2, got ${steps}`);
85
+ }
86
+
87
+ const base = baseHex.trim().toLowerCase();
88
+ const lightTaper = +(1 - LIGHTEST_CHROMA_FRACTION).toFixed(4);
89
+
90
+ return Array.from({ length: steps }, (_, i) => {
91
+ const t = i / (steps - 1);
92
+ const targetL = +(
93
+ PALETTE_MAX_LIGHTNESS -
94
+ t * (PALETTE_MAX_LIGHTNESS - PALETTE_MIN_LIGHTNESS)
95
+ ).toFixed(4);
96
+
97
+ const darkScale = `${targetL} / max(l, 0.001)`;
98
+ const lightProgress = `clamp(0, (${targetL} - l) / max(0.001, ${PALETTE_MAX_LIGHTNESS} - l), 1)`;
99
+ const lightScale = `1 - ${lightTaper} * ${lightProgress}`;
100
+
101
+ return `oklch(from ${base} ${targetL} calc(c * min(${darkScale}, ${lightScale})) h)`;
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Resolves the `demographic_colors` config value into a palette.
107
+ *
108
+ * Accepts an explicit array of colors (used as-is), a single hex color (a
109
+ * palette is generated from it), or nothing (a palette is generated from
110
+ * DEFAULT_DEMOGRAPHIC_BASE_COLOR).
111
+ *
112
+ * @param {string|string[]|undefined} configValue
113
+ * @returns {string[]}
114
+ */
115
+ export function resolveDemographicColors(configValue) {
116
+ if (Array.isArray(configValue)) return configValue;
117
+ if (isHexColor(configValue)) return generateSequentialPalette(configValue);
118
+ if (configValue != null && configValue !== "") {
119
+ console.warn(
120
+ `Warning: demographic_colors must be an array or a hex color such as ` +
121
+ `"${DEFAULT_DEMOGRAPHIC_BASE_COLOR}"; got ` +
122
+ `${JSON.stringify(configValue)}. Using default colors.`,
123
+ );
124
+ }
125
+ return generateSequentialPalette(DEFAULT_DEMOGRAPHIC_BASE_COLOR);
126
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "config_schema_version": 1,
4
+ "title": "PythonReportConfig",
5
+ "type": "object",
6
+ "additionalProperties": true,
7
+ "properties": {
8
+ "title": {
9
+ "type": "string",
10
+ "title": "Report title"
11
+ },
12
+ "logo": {
13
+ "type": "string",
14
+ "title": "Logo image filename"
15
+ },
16
+ "excluded_topics": {
17
+ "type": "array",
18
+ "title": "Excluded topics",
19
+ "description": "Topic names to omit (exact match)",
20
+ "items": {
21
+ "type": "string"
22
+ }
23
+ },
24
+ "excluded_opinions": {
25
+ "type": "array",
26
+ "title": "Excluded opinions",
27
+ "description": "Opinion names to omit (exact match)",
28
+ "items": {
29
+ "type": "string"
30
+ }
31
+ }
32
+ }
33
+ }
package/smoke.js ADDED
@@ -0,0 +1,62 @@
1
+ import { loadConfigSchema, LATEST_CONFIG_SCHEMA_VERSION } from "./data.js";
2
+ import { runBuild } from "./build.js";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const packageRoot = path.dirname(fileURLToPath(import.meta.url));
7
+
8
+ function assert(condition, message) {
9
+ if (!condition) {
10
+ throw new Error(message);
11
+ }
12
+ }
13
+
14
+ async function main() {
15
+ const schema = loadConfigSchema(null, packageRoot);
16
+ assert(
17
+ schema.config_schema_version === LATEST_CONFIG_SCHEMA_VERSION,
18
+ `Expected config_schema_version ${LATEST_CONFIG_SCHEMA_VERSION}`,
19
+ );
20
+ for (const key of ["title", "logo", "excluded_topics", "excluded_opinions"]) {
21
+ assert(schema.properties?.[key], `Config schema missing property "${key}"`);
22
+ }
23
+
24
+ const explicit = loadConfigSchema(1, packageRoot);
25
+ assert(explicit.config_schema_version === 1, "Expected schema version 1");
26
+
27
+ let failed = false;
28
+ try {
29
+ loadConfigSchema(99, packageRoot);
30
+ } catch (error) {
31
+ failed = true;
32
+ assert(
33
+ String(error.message).includes("Unknown config schema version"),
34
+ `Expected clear unknown-version error, got: ${error.message}`,
35
+ );
36
+ }
37
+ assert(failed, "Expected unknown schema version to fail.");
38
+
39
+ const previousWrite = process.stdout.write.bind(process.stdout);
40
+ let captured = "";
41
+ process.stdout.write = (chunk) => {
42
+ captured += String(chunk);
43
+ return true;
44
+ };
45
+ try {
46
+ await runBuild(["node", "build.js", "config-schema"], packageRoot);
47
+ } finally {
48
+ process.stdout.write = previousWrite;
49
+ }
50
+ const printed = JSON.parse(captured);
51
+ assert(
52
+ printed.config_schema_version === 1,
53
+ "config-schema command did not print v1 schema",
54
+ );
55
+
56
+ console.log("Smoke tests passed.");
57
+ }
58
+
59
+ main().catch((error) => {
60
+ console.error(error.message);
61
+ process.exit(1);
62
+ });
@@ -95,6 +95,7 @@
95
95
  {{/executiveSummary}}
96
96
  </section>
97
97
  </article>
98
+ {{#hasDemographics}}
98
99
  <article>
99
100
  <section id="participant-overview">
100
101
  <header>
@@ -105,6 +106,7 @@
105
106
  <div id="participant-overview-chart" class="demographic-chart"></div>
106
107
  </section>
107
108
  </article>
109
+ {{/hasDemographics}}
108
110
  <article>
109
111
  <section id="conversation-overview">
110
112
  <header>
@@ -245,9 +247,11 @@
245
247
  <button class="is-active" data-target="drawer-quotes">
246
248
  {{i18n.drawer.participantQuotesTitle}} (<span class="drawer-quote-count"></span>)
247
249
  </button>
250
+ {{#hasDemographics}}
248
251
  <button data-target="drawer-demographics">
249
252
  {{i18n.drawer.participantDemographics}}
250
253
  </button>
254
+ {{/hasDemographics}}
251
255
  </div>
252
256
  </div>
253
257
  <div id="drawer-quotes" class="tab is-visible">
@@ -265,9 +269,11 @@
265
269
  <ul class="drawer-quotes"></ul>
266
270
  </div>
267
271
 
272
+ {{#hasDemographics}}
268
273
  <div id="drawer-demographics" class="tab">
269
274
  <div id="drawer-demographics-chart" class="demographic-chart"></div>
270
275
  </div>
276
+ {{/hasDemographics}}
271
277
  <button class="button-close"><img inline src="svg/x.svg" alt="{{i18n.meta.closeAlt}}"></button>
272
278
  </div>
273
279
  </div>
package/src/script.js CHANGED
@@ -246,7 +246,7 @@ function createDemographicChart({ id, data }) {
246
246
  return `calc(${ratio * 100}% - ${shrinkPixels}px)`;
247
247
  })
248
248
  .attr("rx", borderRadius)
249
- .attr(
249
+ .style(
250
250
  "fill",
251
251
  (d, i) => DEMOGRAPHIC_PALETTE[i % DEMOGRAPHIC_PALETTE.length],
252
252
  );
@@ -532,6 +532,8 @@ function createParticipantChart() {
532
532
  /** @type {Demographic[]} */
533
533
  const data = window.PAYLOAD.demographics;
534
534
  const id = "participant-overview-chart";
535
+ // The section is omitted from the template when there is no demographic data.
536
+ if (!document.getElementById(id)) return;
535
537
  createDemographicChart({ id, data });
536
538
  }
537
539
 
@@ -870,6 +872,8 @@ function updateQuotesDrawer() {
870
872
 
871
873
  function createQuotesDrawerParticipantChart() {
872
874
  const id = "drawer-demographics-chart";
875
+ // The tab is omitted from the template when there is no demographic data.
876
+ if (!document.getElementById(id)) return null;
873
877
  const unknownOpinionText = i18n.drawer?.unknownOpinion || "Unknown Opinion";
874
878
  const match = flatOpinions.find((o) => o.fullID === currentDrawerId);
875
879
  const text = match?.text || unknownOpinionText;