/* =====================================================================
   Career Futures — Exposure Lab
   Design system: "Career Futures" site asset package (tokens, SVG, renders)
   Live data: U.S. Bureau of Labor Statistics Public Data API v1 (no key)
   ===================================================================== */

import React, {
  useState, useEffect, useMemo, useRef, useCallback, createContext, useContext
} from 'react';
import { createRoot } from 'react-dom/client';
import {
  motion, animate, useMotionValue, useMotionValueEvent, useReducedMotion
} from 'framer-motion';

/* =====================================================================
   1. ICONS — paths taken from assets/svg/icons/*.svg (currentColor)
   ===================================================================== */
const mk = (d, extra) => (p) => (
  <svg viewBox="0 0 64 64" aria-hidden="true" focusable="false" {...p}>
    <g fill="none" stroke="currentColor" strokeWidth="4.5" strokeLinecap="round" strokeLinejoin="round">
      {d.map((x, i) => <path key={i} d={x} />)}
      {extra}
    </g>
  </svg>
);
const I = {
  home: mk(['M8 30L32 10l24 20v25H39V40H25v15H8Z']),
  careers: mk(['M41 20l-7 15-15 7 7-15Z'], <circle cx="32" cy="32" r="23" />),
  compare: mk(['M12 52V36h8v16M28 52V20h8v32M44 52V10h8v42']),
  resources: mk(['M15 10h34v46L32 46 15 56Z']),
  saved: mk(['M32 7l7 15 17 2-12 12 3 17-15-8-15 8 3-17L8 24l17-2Z']),
  search: mk(['M42 42l14 14'], <circle cx="29" cy="29" r="17" />),
  user: mk(['M12 56c1-15 9-23 20-23s19 8 20 23'], <circle cx="32" cy="22" r="11" />),
  robot: mk(['M32 16V8M25 29h1M38 29h1M24 40c5 5 11 5 16 0'],
    <rect x="12" y="16" width="40" height="34" rx="8" />),
  head: mk(['M19 50c2-9 8-15 16-16 8-1 15-7 15-16 0-10-8-17-18-17-12 0-20 9-20 22 0 7 2 13 7 17v10Z', 'M32 13c5 4 7 9 5 15']),
  bars: mk(['M10 54V38h10v16M27 54V28h10v26M44 54V12h10v42']),
  eye: mk(['M6 32s10-17 26-17 26 17 26 17-10 17-26 17S6 32 6 32Z'], <circle cx="32" cy="32" r="8" />),
  bulb: mk(['M20 28c0-9 5-15 12-15s12 6 12 15c0 6-4 9-7 13v5H27v-5c-3-4-7-7-7-13ZM27 52h10']),
  pencil: mk(['M11 49l4-14L42 8l14 14-27 27ZM39 11l14 14']),
  arrow: mk(['M10 32h44M38 16l16 16-16 16']),
  arrowUp: mk(['M16 48L48 16M24 16h24v24']),
  book: mk(['M8 14c12-4 20-3 24 3v38c-5-7-13-8-24-4ZM56 14c-12-4-20-3-24 3v38c5-7 13-8 24-4Z']),
  scales: mk(['M32 8v46M13 17h38M32 11L13 17M32 11l19 6M13 17L5 38h16ZM51 17l-8 21h16ZM20 54h24']),
  notebook: mk(['M23 8v48M10 16h10M10 27h10M10 38h10M10 49h10'],
    <rect x="15" y="8" width="36" height="48" rx="5" />),
  medal: mk(['M22 39l-5 18 15-8 15 8-5-18M32 18l3 5 6 1-4 4 1 6-6-3-6 3 1-6-4-4 6-1Z'],
    <circle cx="32" cy="26" r="15" />),
  route: mk(['M7 50c13-16 14-28 29-25 12 2 12-13 21-14M48 7h10v10M12 54l-6-6m0 6 6-6']),
  cap: mk(['M5 25l27-14 27 14-27 14ZM16 31v15c11 8 21 8 32 0V31M56 27v18']),
  calc: mk(['M17 15h30v10H17Z', 'M18 33h6m9 0h6m9 0h2M18 44h6m9 0h6m9 0h2'],
    <rect x="10" y="6" width="44" height="52" rx="6" />),
  laptop: mk(['M6 48h52l-5 8H11ZM25 26h1m12 0h1M24 33c5 4 11 4 16 0'],
    <rect x="13" y="11" width="38" height="29" rx="4" />),
  heart: mk(['M32 54 9 32C-2 20 7 6 20 12l12 9 12-9c13-6 22 8 11 20Z']),
  speech: mk(['M8 12h48v34H28L16 55v-9H8Z', 'M20 28h1m11 0h1m11 0h1']),
  chev: mk(['M14 24l18 18 18-18']),
};

/* =====================================================================
   2. BLS DATA
      OEWS  = Occupational Employment & Wage Statistics (national, May 2025)
      CES   = Current Employment Statistics (monthly industry employment)
      Snapshot below was fetched from api.bls.gov on 2026-07-27 and is used
      when the live call fails or the shared daily quota is exhausted.
   ===================================================================== */

const OEWS_PREFIX = 'OEUN' + '0'.repeat(13);   // national / all areas / all industries
const DT_EMP = '01';                            // employment level
const DT_MED = '13';                            // annual median wage

const CES_MONTH0 = [2019, 1];                  // snapshot starts 2019-01
const CES_SNAPSHOT = {
  "CES0000000001": [150060,150067,150295,150591,150618,150838,150937,151169,151365,151460,151667,151794,152031,152293,150895,130426,133040,137671,139255,140821,141770,142460,142733,142548,142863,143380,144232,144587,145065,145820,146762,147314,147771,148572,149230,149816,150006,150825,151315,151623,151924,152358,153072,153362,153582,153939,154242,154342,154776,155066,155134,155375,155655,155880,156043,156261,156417,156576,156703,156857,157032,157238,157466,157530,157608,157695,157748,157757,157912,157945,158079,158316,158268,158310,158377,158485,158498,158478,158542,158472,158548,158408,158449,158432,158592,158436,158650,158798,158927,158984],
  "CES5000000001": [2842,2843,2849,2846,2858,2875,2866,2865,2871,2874,2885,2891,2901,2910,2902,2643,2609,2622,2622,2640,2695,2684,2696,2712,2747,2764,2770,2796,2816,2824,2881,2903,2917,2938,2957,2967,2984,2990,3018,3034,3057,3076,3094,3097,3095,3100,3115,3092,3061,3048,3049,3043,3034,3021,3001,2981,2975,2951,2962,2963,2963,2958,2951,2940,2930,2930,2917,2909,2903,2887,2885,2895,2877,2876,2867,2865,2864,2863,2861,2860,2859,2853,2848,2842,2815,2792,2793,2787,2783,2774],
  "CES6054110001": [1143.7,1144.2,1147.1,1146.8,1149.3,1150.3,1150.5,1152.6,1154.2,1154.5,1157.3,1157.8,1162.8,1164.2,1164.5,1095.9,1103.1,1109,1115.1,1120.2,1123.1,1126.9,1130.6,1133.2,1135.7,1143.7,1147.2,1148.7,1149.3,1147.6,1151.5,1155.7,1159.8,1164.3,1166.7,1169.5,1169.5,1170.3,1173,1176.2,1179.4,1180.7,1187.3,1180.7,1176.7,1180.2,1180.4,1180.7,1181.7,1182.6,1180,1179.3,1180.6,1185.5,1187.4,1187.8,1190.1,1190.3,1192.9,1200.3,1193.4,1200.9,1194.6,1196.8,1199,1196.2,1196.1,1197.9,1199.7,1202.3,1204,1207.3,1209.8,1211.5,1215.7,1216.3,1218.2,1220.9,1223.5,1224.7,1226.2,1228.4,1229.9,1231.2,1233.9,1234.9,1234.1,1237.2,1238.4,1243.5],
  "CES6054120001": [1013.8,1017.1,1018.8,1017.1,1018.8,1023.9,1022.3,1019.6,1020.4,1020,1019.3,1025.2,1028,1037.5,1037.7,968.1,969.7,976.3,978,980.8,983,984.2,986.4,994.3,1027.4,1028.8,1033.3,1041.1,1047.6,1045.1,1056.5,1062.8,1064.4,1068.3,1074,1081.7,1077.2,1085.2,1094.7,1098.7,1104.6,1113,1116.2,1117.3,1124.7,1130.8,1134,1132.7,1136,1139.5,1142.4,1147.7,1148.5,1145.4,1149,1145.2,1142.5,1139.9,1140.7,1155.3,1160.5,1156.4,1153.9,1153.8,1156.1,1152.6,1145.2,1143,1138.3,1132.6,1127.9,1125,1125.1,1125.1,1126,1124.7,1127.5,1127.6,1126.7,1127.1,1128.5,1126.4,1134,1129.8,1129.2,1130.5,1120.9,1124.9,1121.9,1124.6],
  "CES6054140001": [143,143.9,144.2,144,143.1,143.1,142.4,142.2,142.7,145.1,143.1,142.2,142.5,142.2,141.6,124.4,123.3,129.4,129.1,130.6,131.8,134.2,133.8,134.1,136.3,136.3,136.6,136.9,139.2,140.9,143.6,144,144.4,144.6,147,146.7,145.9,146.5,148.2,149.8,151.6,151.8,152.3,151.1,151.5,149.6,150.6,151.5,152.1,152.4,152.9,152.4,152,152.8,152.6,153.1,152.8,152.9,151.8,152.1,154,152.6,151.5,151.3,150,149.3,149.4,149.2,149.3,150.7,151.7,150,148.8,147.8,148.3,146.6,147.7,147.9,148.5,148.7,147.8,147.3,148.8,148.1,148.8,148.9,150.3,149.4,149.4,150.2],
  "CES6054150001": [2153.6,2156.9,2168.7,2180.7,2189.4,2199.7,2208.2,2216.4,2214.1,2213.8,2218.9,2213.6,2228.5,2234.1,2219.8,2165.8,2166.1,2167.7,2167.2,2180.6,2191.3,2202.5,2208.3,2222.1,2232.6,2249,2260.5,2272.3,2279.1,2296.9,2319.6,2332,2348.9,2361.2,2377.8,2389.5,2406.9,2413.6,2436.5,2441.6,2453.3,2458.3,2457.1,2462,2469.3,2479,2482.9,2483,2478.6,2481.3,2483.5,2482.4,2478.2,2472.9,2470.7,2467,2456.5,2454,2451.1,2449.8,2446.9,2444.6,2439.8,2441,2440.6,2444.7,2442.6,2437.2,2437.6,2433.4,2429.2,2431.4,2426.3,2416.7,2408.9,2411.3,2410.3,2399.9,2395.7,2394,2387.6,2379.7,2381.5,2380.6,2374.2,2379.1,2369.6,2369.5,2370.3,2374.6],
  "CES6054160001": [1503.6,1510.2,1515.6,1519.1,1523.2,1528.3,1535.9,1546.4,1551.9,1557.2,1560.4,1562.9,1562.3,1569,1567.9,1474.1,1488.1,1502.7,1506,1514.3,1523.4,1532.8,1538.1,1553.7,1571.8,1582,1595.3,1609.2,1619.4,1625.5,1643.7,1656.6,1672.9,1696.9,1714.1,1723.8,1735.6,1746.2,1759.3,1766.3,1770.2,1774.6,1787.7,1802.4,1803.9,1812.4,1820.8,1827.3,1836.8,1845.9,1846.8,1850.3,1855.6,1859.2,1861.5,1862.1,1865.8,1861.9,1860,1864.3,1860.8,1856.7,1856,1853.5,1859.3,1861.7,1862.2,1858.2,1857.3,1858.5,1862.3,1880,1859.2,1861.6,1864.3,1866.6,1864.3,1858.7,1859,1858.3,1860,1860,1868.3,1871.6,1871.1,1875.7,1875.2,1879.5,1877.2,1884.5],
  "CES6056100001": [8851.2,8848.5,8840.6,8878,8891.4,8898.9,8892.8,8902.4,8899.3,8891.6,8872.1,8840.8,8825.8,8798.2,8729.5,7200,7342,7626,7793.2,7935.8,7982.2,8125.5,8240.2,8333.4,8429.2,8451.3,8503.6,8444.2,8465.3,8520.8,8584.3,8636.2,8662.9,8818.6,8885.6,8940.8,8952,9067.9,9105.8,9066.3,9049.4,9053.2,9081.2,9085.6,9100,9088.3,9061.8,9017.8,9016.1,9019.1,8996.8,8977.5,8967.1,8941.6,8890.4,8863.9,8841.1,8819.9,8767.2,8727.1,8741.8,8723.3,8718.9,8701.4,8693.1,8660,8651.6,8624.3,8601.9,8571.4,8577.1,8579.7,8550.1,8556.8,8535.1,8538.4,8519.7,8503.6,8483.2,8462,8449.3,8435,8454,8443.1,8475.3,8459.8,8503.6,8511.5,8517.4,8538.6],
  "CES6562000001": [20164.7,20200.7,20263.2,20320.2,20352.5,20392.7,20428.1,20489.9,20532.4,20578.3,20639.6,20675.5,20724.7,20771,20655.7,18485,18864.3,19344.5,19525.4,19653.2,19766.3,19853.3,19909.2,19966.8,19903.6,19942.8,20023.5,20066,20075.6,20069.8,20077.8,20076.5,20083.8,20119.1,20149.5,20183.4,20160.2,20256.3,20316.5,20380.8,20434.5,20513.3,20616.7,20684.8,20766.3,20855,20931.1,21017.7,21103.6,21170.1,21240.9,21309.4,21382.4,21464.2,21576.6,21676.7,21761.7,21839.2,21945.4,22018.7,22095.6,22191.2,22276.6,22359.2,22424.6,22493.7,22541.9,22586.7,22673.6,22754.8,22831.6,22901.4,22960.4,23008.3,23076,23152,23217,23277.5,23360.1,23382.9,23438.2,23485.8,23545.7,23587.5,23694.4,23664,23756.7,23813.8,23859.7,23906.3],
  "CES2023800001": [4703.1,4683.9,4704.2,4739.7,4748.8,4762.6,4781.5,4771.7,4780.4,4783.4,4773.5,4761.8,4794.6,4819.4,4774.9,4075.4,4421.1,4552.7,4579.4,4601,4621.5,4653.8,4668,4693.4,4696.6,4639.8,4716.1,4717.1,4705.5,4706.9,4728,4735.9,4760.6,4786.3,4811.6,4829.6,4828,4868.1,4892.5,4887,4913.7,4933.8,4956.4,4965.9,4989,4998.6,5001.9,5012.1,5036.4,5041.5,5027.7,5058.4,5069.1,5086.3,5094.4,5107.1,5107.7,5124,5129.3,5135.9,5151.8,5153.8,5173.4,5175.5,5184.1,5191.4,5209.3,5221.4,5233.5,5235.4,5238.5,5239.2,5230.4,5234.5,5232.5,5233.4,5229.1,5232.3,5224.4,5211.4,5224,5215.7,5234.5,5222.4,5251,5235.2,5244.3,5245,5253.2,5261.6],
  "CES9093161101": [7986.4,7991,8015.5,8032.3,8009.6,7983,7964,7980.6,7997.9,8011.3,8028.9,8033.4,8039.6,8055.6,8049.7,7627,7328,7342,7385.3,7527.1,7419.3,7396.3,7379.9,7377.4,7419.7,7422.4,7458.1,7487.8,7526,7634.5,7661.4,7763,7717,7665.4,7686.8,7706,7725.9,7730.2,7745.4,7756.5,7767.2,7778.4,7892.4,7862.1,7845.7,7829.6,7869.9,7875.4,7906.9,7927.9,7934.6,7939.8,7958.2,7969.8,7994.3,8021.3,8030.4,8038,8059.8,8079,8082.1,8098.6,8130.2,8123,8126.7,8106.2,8140.4,8146,8148.4,8160.2,8165.2,8173.5,8183.5,8191.4,8198.8,8203.9,8207.9,8219.8,8224.4,8227,8234,8239.6,8237.6,8231.8,8234.7,8229.5,8240.5,8229.5,8234.7,8233.6],
};

const OEWS_SNAPSHOT = {
  '232011': { emp: 392880, med: 62890 },
  '252021': { emp: 1388390, med: 63970 },
  '433031': { emp: 1373680, med: 50670 },
  '271024': { emp: 197830, med: 62960 },
  '291141': { emp: 3379720, med: 97550 },
  '151252': { emp: 1687890, med: 135980 },
  '434051': { emp: 2595750, med: 44770 },
  '131161': { emp: 899580, med: 78760 },
  '273043': { emp: 47940, med: 76910 },
  '132011': { emp: 1449500, med: 83680 },
  '472111': { emp: 757220, med: 63190 },
  '151232': { emp: 717190, med: 61860 },
};
const SNAPSHOT_TAKEN = 'July 27, 2026';
const OEWS_PERIOD = 'May 2025';

/* ---------------------------------------------------------------------
   Second BLS pull, for the Pressure Test screen. Same API, same index
   base (month 0 = January 2019) except productivity, which is quarterly.
     LNS = Current Population Survey, seasonally adjusted unemployment rates
     JTS = Job Openings and Labor Turnover Survey
     PRS = Major Sector Productivity
   --------------------------------------------------------------------- */
const CPS_ALL = 'LNS14000000';        // unemployment rate, 16 years and over
const CPS_YOUNG = 'LNS14000036';      // unemployment rate, 20–24 years
const CPS_PRIME = 'LNS14000060';      // unemployment rate, 25 years and over
const JOLTS_OPEN = 'JTS000000000000000JOL';   // job openings, level (thousands)
const JOLTS_HIRE = 'JTS000000000000000HIR';   // hires rate
const JOLTS_QUIT = 'JTS000000000000000QUR';   // quits rate
const PROD_IDX = 'PRS85006093';       // nonfarm business labour productivity index, 2017 = 100

const EXTRA_SERIES = [CPS_ALL, CPS_YOUNG, CPS_PRIME, JOLTS_OPEN, JOLTS_HIRE, JOLTS_QUIT];
const QUARTERLY = [PROD_IDX];

const EXTRA_SNAPSHOT = {
  "LNS14000000": [4,3.8,3.8,3.7,3.6,3.6,3.7,3.6,3.5,3.6,3.6,3.6,3.6,3.5,4.4,14.8,13.2,11,10.2,8.4,7.8,6.9,6.7,6.7,6.4,6.2,6.1,6.1,5.8,5.9,5.4,5.1,4.7,4.5,4.1,3.9,4,3.9,3.7,3.7,3.6,3.6,3.5,3.6,3.5,3.6,3.6,3.5,3.5,3.6,3.5,3.4,3.6,3.6,3.5,3.7,3.7,3.9,3.7,3.8,3.7,3.9,3.9,3.9,3.9,4.1,4.2,4.2,4.1,4.1,4.2,4.1,4,4.2,4.2,4.2,4.3,4.1,4.3,4.3,4.4,4.4,4.5,4.4,4.3,4.4,4.3,4.3,4.3,4.2],
  "LNS14000036": [7.8,7.3,7,6.5,6.9,6.1,6.7,6.7,6.2,6.3,6.5,6.7,7,6.4,8.5,25.5,23,19.6,18,13.9,12.5,11,10.7,11.3,9.9,9.6,10.3,10.7,10.2,9.1,9.1,8.9,7.7,7.1,7.3,7.2,7.3,7.4,7.6,7.2,6.7,6.9,6.4,6.8,6.9,6.8,6.8,7.4,7.1,6.8,6.6,5.5,6.2,6.1,6.8,7,7.1,6.9,6.6,6.4,6,7.2,7.2,6.7,7.9,7.5,7.7,7.8,7,7.8,7.9,7.5,8,8.3,7.6,8.2,8.2,8.2,7.9,9.2,9.2,9.2,8.3,8.2,7,7.4,6.4,7.6,7.2,7.1],
  "LNS14000060": [3.3,3.2,3.2,3.1,2.9,3,3.1,3.1,3,3,3,3.1,3,3,3.6,12.8,11.5,9.7,9.1,7.5,7.1,6.4,6.1,5.8,5.8,5.6,5.5,5.5,5.2,5.5,4.9,4.7,4.4,4.1,3.7,3.5,3.5,3.3,3,3.1,3.1,3.1,3,3.1,2.9,3.2,3.1,2.9,3,3,3,3.1,3.2,3.1,3,3.1,3.2,3.2,3.1,3.3,3.3,3.3,3.2,3.2,3.3,3.5,3.6,3.6,3.4,3.5,3.6,3.5,3.4,3.5,3.5,3.5,3.6,3.3,3.6,3.6,3.7,3.7,3.9,3.7,3.8,3.9,3.7,3.7,3.8,3.7],
  "JTS000000000000000JOL": [7502,7066,7317,7272,7275,7194,7042,7178,7124,7289,6888,6699,7124,6966,5894,4606,5612,6129,6530,6429,6511,6798,6820,6773,7162,7827,8499,9346,9954,10319,10983,10872,10834,11238,11199,11520,11210,11698,12301,11861,11535,11283,11576,10092,10800,10447,10632,10902,10318,9857,9655,9966,9368,9180,8657,9254,9259,8580,8602,8520,8378,8440,8206,7529,7782,7416,7426,7520,6943,7379,7566,7295,7431,7242,6952,7098,7310,7204,7089,6919,7169,7170,6846,6550,7240,6922,6887,7585,7594],
  "JTS000000000000000HIR": [3.9,3.8,3.8,4,3.8,3.8,3.9,3.9,3.9,3.8,3.9,3.9,3.9,3.9,3.4,3.1,6.1,5.4,4.5,4.3,4.2,4.3,4.1,3.9,3.9,4.1,4.3,4.2,4.1,4.5,4.6,4.3,4.5,4.5,4.6,4.5,4.3,4.5,4.4,4.3,4.2,4.3,4.2,4.2,4,4,4,4,4.1,3.9,3.8,3.8,3.9,3.8,3.6,3.7,3.7,3.7,3.5,3.6,3.6,3.6,3.5,3.5,3.5,3.3,3.4,3.3,3.4,3.3,3.3,3.3,3.3,3.3,3.4,3.4,3.4,3.4,3.3,3.2,3.3,3.3,3.2,3.3,3.4,3.1,3.5,3.3,3.3],
  "JTS000000000000000QUR": [2.3,2.4,2.3,2.3,2.3,2.3,2.4,2.3,2.3,2.3,2.3,2.3,2.3,2.3,1.9,1.5,1.7,1.9,2.2,2.1,2.2,2.3,2.2,2.4,2.3,2.4,2.5,2.7,2.6,2.8,2.8,2.8,2.9,2.8,3,2.9,2.9,2.8,2.9,3,2.8,2.7,2.6,2.8,2.7,2.6,2.6,2.7,2.5,2.5,2.4,2.3,2.6,2.4,2.3,2.3,2.3,2.3,2.3,2.2,2.1,2.2,2.1,2.2,2.1,2.1,2.1,2.1,2,2,1.9,1.9,2,2,2.2,2,2.1,2.1,2,2,1.9,1.9,2,2,2,1.9,2,1.9,1.9],
  "PRS85006093": [102.34,102.971,104.129,105.103,104.679,109.772,111.491,110.599,111.394,111.626,110.948,111.578,110.176,109.36,109.423,110.047,110.298,111.404,112.786,113.881,113.947,114.99,116.035,116.445,116.187,117.385,118.884,119.35,119.437],
};

/* Wikipedia pageviews — the Wikimedia REST API is open, keyless and CORS-enabled.
   Used only to contrast public attention with measured employment. */
const WIKI_API = 'https://wikimedia.org/api/rest_v1/metrics/pageviews/per-article/en.wikipedia/all-access/user/';
const WIKI_SNAPSHOT = {
  "Artificial_intelligence": [281671,269847,291528,334138,279083,257127,272012,248309,287954,294123,290238,239396,276137,277123,233114,242444,244422,207483,206641,201959,228918,240979,229255,219651,219520,204483,223346,184313,180279,171709,302008,358259,350333,328145,305126,302930,354089,386514,482516,443032,434343,421482,395263,479483,550500,511869,501863,461950,347639,425620,566455,553829,655257,696242,715551,631020,505577,533675,537255,522816,626726,524445,519588,455129,415549,307168,283842,293080,312003,340363,363899,307977,402159,365248,348376,294960,295206,272443,288589,296561,334185,349106,323056,325445,344166,345208,370062,306610,340860,304218],
  "ChatGPT": [null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,1882964,5349371,5947457,6916888,8009498,7126714,5723544,2926034,1906589,2120546,2136737,2247191,2048877,2448529,2408484,1914228,1478087,1378272,1674856,787459,647852,1057844,1532236,1787879,1669915,2568792,2601557,2756392,4441034,2421622,5242637,3256479,4170760,4354370,4224435,3639485,3155462,3163419,2455248,2314878,2265270,2278052,2724827],
};

/* =====================================================================
   3. CAREERS
      SOC codes and industry series are real BLS identifiers.
      Task statements are written for this lab in the style of O*NET work
      activities; they are teaching material, not an O*NET extract.
   ===================================================================== */

const CAREERS = [
  {
    id: 'paralegal', soc: '232011', title: 'Paralegal', full: 'Paralegals and Legal Assistants',
    cluster: 'Legal', icon: 'scales', ind: 'CES6054110001', indName: 'Legal services',
    band: [55, 75], bandLabel: 'Higher',
    bandWhy: 'Document review, drafting from templates and summarisation are among the most directly automatable knowledge tasks — and they are a large share of the entry-level workload.',
    named: false,
    tasks: [
      'Retrieve and organise case documents for review',
      'Draft standard contracts and pleadings from templates',
      'Summarise long deposition transcripts',
      'Run citation checks and verify legal references',
      'Research case law on a question nobody has asked before',
      'Interview a client about a painful, contested dispute',
      'Decide which facts actually matter to the case strategy',
      'Sign off that a filing is accurate and complete',
    ],
    edge: [
      ['Judgment', 'Deciding what matters in a record that is mostly noise.'],
      ['Trust', 'Clients disclose difficult facts to people, not to systems.'],
      ['Accountability', 'Someone has to be answerable for what gets filed.'],
    ],
    next: ['Go deep on one area of law', 'Practise verifying AI-drafted documents', 'Learn e-discovery and research tools properly'],
    aiNote: 'AI can produce a competent first draft quickly. It cannot be sanctioned by a judge — you can.',
  },
  {
    id: 'teacher', soc: '252021', title: 'Elementary Teacher', full: 'Elementary School Teachers (except special education)',
    cluster: 'Education', icon: 'cap', ind: 'CES9093161101', indName: 'Local government educational services',
    band: [15, 35], bandLabel: 'Lower',
    bandWhy: 'Planning and grading are exposed, but the core of the job is real-time relational work with children, which current systems do not perform.',
    named: false,
    tasks: [
      'Take attendance and maintain daily records',
      'Generate practice worksheets and quiz items',
      'Grade multiple-choice assessments',
      'Draft parent newsletters and progress comments',
      'Change a lesson mid-class because the room is lost',
      'De-escalate a conflict between two eight-year-olds',
      'Decide whether a child needs a referral or just a bad-day pass',
      'Earn the trust of a student who will not speak',
    ],
    edge: [
      ['Presence', 'Reading a room of thirty children in real time.'],
      ['Care', 'Noticing the child whose behaviour changed this week.'],
      ['Authority', 'Being the adult who is responsible for them.'],
    ],
    next: ['Build facilitation and classroom-management craft', 'Use AI for planning, not for judgement about children', 'Document what your presence changes'],
    aiNote: 'AI can take back the paperwork evening. It cannot be the adult in the room.',
  },
  {
    id: 'bookkeeper', soc: '433031', title: 'Bookkeeper', full: 'Bookkeeping, Accounting, and Auditing Clerks',
    cluster: 'Business', icon: 'calc', ind: 'CES6054120001', indName: 'Accounting, tax prep, bookkeeping & payroll services',
    band: [65, 85], bandLabel: 'Higher',
    bandWhy: 'Categorisation, matching and reconciliation are exactly what software has been eating for two decades; generative AI extends that to the exception cases.',
    named: false,
    tasks: [
      'Categorise routine transactions',
      'Reconcile bank and credit-card statements',
      'Generate the monthly financial reports',
      'Chase down and match missing receipts',
      'Investigate an anomaly that will not reconcile',
      'Explain a cash-flow risk to an anxious business owner',
      'Decide how to treat a genuinely ambiguous expense',
      'Flag a transaction that looks like fraud',
    ],
    edge: [
      ['Exception handling', 'The 5% of entries that break the rule.'],
      ['Advice', 'Telling an owner what the numbers mean for next month.'],
      ['Suspicion', 'Noticing the pattern nobody asked you to look for.'],
    ],
    next: ['Move toward analysis and advising', 'Learn the automation tools well enough to audit them', 'Build fraud and controls knowledge'],
    aiNote: 'The data-entry half of this job is shrinking. The judgement half is being paid more.',
  },
  {
    id: 'designer', soc: '271024', title: 'Graphic Designer', full: 'Graphic Designers',
    cluster: 'Arts & Media', icon: 'pencil', ind: 'CES6054140001', indName: 'Specialised design services',
    band: [50, 75], bandLabel: 'Higher',
    bandWhy: 'Generative image tools hit production and concepting hard. Direction, rights and client judgement are less exposed than execution.',
    named: false,
    tasks: [
      'Resize and reformat assets for a dozen channels',
      'Generate first-pass concept variations',
      'Clean up and retouch supplied images',
      'Build and maintain the brand style guide',
      'Turn a vague brief into an actual direction',
      'Defend a design decision to a sceptical client',
      'Judge whether an image is licensed, appropriate, honest',
      'Art-direct a photo shoot or an illustration commission',
    ],
    edge: [
      ['Taste', 'Knowing which of forty options is the right one.'],
      ['Context', 'Understanding a client and an audience over time.'],
      ['Responsibility', 'Owning what the image claims and where it came from.'],
    ],
    next: ['Strengthen art direction over execution', 'Practise provenance and rights checks', 'Build domain knowledge a generalist model lacks'],
    aiNote: 'Making an image got cheap. Deciding which image is right did not.',
  },
  {
    id: 'nurse', soc: '291141', title: 'Registered Nurse', full: 'Registered Nurses',
    cluster: 'Health', icon: 'heart', ind: 'CES6562000001', indName: 'Health care and social assistance',
    band: [10, 30], bandLabel: 'Lower',
    bandWhy: 'Charting and documentation are exposed. Physical care, real-time assessment and clinical accountability are not.',
    named: false,
    tasks: [
      'Chart vitals and update the patient record',
      'Draft the shift-handoff summary',
      'Check drug-interaction references',
      'Triage the queue of incoming messages',
      'Notice a patient "looks wrong" before the numbers change',
      'Place an IV and perform hands-on care',
      'Tell a family news they did not want',
      'Override a protocol because this patient does not fit it',
    ],
    edge: [
      ['Assessment', 'Seeing deterioration before a monitor does.'],
      ['Touch', 'Care that has to happen with hands.'],
      ['Advocacy', 'Speaking for a patient who cannot.'],
    ],
    next: ['Specialise clinically', 'Learn to audit clinical decision-support tools', 'Build communication craft for hard conversations'],
    aiNote: 'The documentation burden is the part AI is actually reducing. That is not nothing.',
  },
  {
    id: 'software', soc: '151252', title: 'Software Developer', full: 'Software Developers',
    cluster: 'Technology', icon: 'laptop', ind: 'CES6054150001', indName: 'Computer systems design and related services',
    band: [45, 70], bandLabel: 'Mixed',
    bandWhy: 'Very high task exposure and very high augmentation at the same time. The Stanford payroll study finds the entry level shrinking while experienced employment holds.',
    named: 'Named directly in the Stanford "Canaries in the Coal Mine" payroll analysis as an occupation where 22–25 year-old employment declined.',
    tasks: [
      'Write boilerplate and project scaffolding',
      'Generate unit tests for existing functions',
      'Translate code between languages or frameworks',
      'Write documentation and commit messages',
      'Debug a failure that spans three systems',
      'Decide what to build and what to cut',
      'Review a teammate\'s design for a security flaw',
      'Own an outage at 2am',
    ],
    edge: [
      ['System sense', 'Holding a whole architecture in your head.'],
      ['Prioritisation', 'Knowing which problem is worth solving.'],
      ['Accountability', 'Being on call for what you shipped.'],
    ],
    next: ['Get good at review and verification, fast', 'Learn systems, not just syntax', 'Find the domain where your judgement compounds'],
    aiNote: 'This is the clearest live test case: enormous productivity gains, and the sharpest measured decline in junior hiring.',
  },
  {
    id: 'csr', soc: '434051', title: 'Customer Service Rep', full: 'Customer Service Representatives',
    cluster: 'Service', icon: 'speech', ind: 'CES6056100001', indName: 'Administrative and support services',
    band: [65, 90], bandLabel: 'Higher',
    bandWhy: 'Scripted, high-volume, text-and-voice work with clear success criteria — the archetype of an automatable role, and a heavily deployed one.',
    named: 'Named directly in the Stanford "Canaries in the Coal Mine" payroll analysis as an occupation where young-worker employment declined.',
    tasks: [
      'Answer routine "where is my order" questions',
      'Look up account details and order history',
      'Route tickets to the right team',
      'Draft standard apology and resolution replies',
      'Calm a furious customer down',
      'Decide when to break policy for a fair outcome',
      'Spot the pattern that means the product is broken',
      'Handle a call where someone is in crisis',
    ],
    edge: [
      ['De-escalation', 'Absorbing anger without escalating it.'],
      ['Discretion', 'Bending a rule for the right reason.'],
      ['Signal', 'Being the company\'s early-warning system.'],
    ],
    next: ['Move toward escalation and complex cases', 'Learn to supervise and correct AI agents', 'Build product knowledge deep enough to diagnose'],
    aiNote: 'The easy 70% of contacts is going. What remains is harder, and worth more.',
  },
  {
    id: 'analyst', soc: '131161', title: 'Market Research Analyst', full: 'Market Research Analysts and Marketing Specialists',
    cluster: 'Business', icon: 'bars', ind: 'CES6054160001', indName: 'Management, scientific & technical consulting services',
    band: [45, 70], bandLabel: 'Mixed',
    bandWhy: 'Data wrangling and reporting are highly exposed; framing the question and defending an unwelcome finding are not.',
    named: false,
    tasks: [
      'Clean and merge survey datasets',
      'Produce the standard charts and dashboards',
      'Summarise competitor reports',
      'Draft survey questions',
      'Decide which question is actually worth asking',
      'Judge whether a sample is biased',
      'Tell a client their favourite idea is failing',
      'Turn a finding into a decision someone acts on',
    ],
    edge: [
      ['Question design', 'The analysis is only as good as the question.'],
      ['Scepticism', 'Knowing when the data is lying to you.'],
      ['Persuasion', 'Making a finding land with people who dislike it.'],
    ],
    next: ['Learn research design, not just tooling', 'Practise interrogating model outputs', 'Build the storytelling half of the job'],
    aiNote: 'Producing the chart is now trivial. Knowing that the chart is wrong is the job.',
  },
  {
    id: 'writer', soc: '273043', title: 'Writer / Author', full: 'Writers and Authors',
    cluster: 'Arts & Media', icon: 'notebook', ind: 'CES5000000001', indName: 'Information',
    band: [55, 85], bandLabel: 'Higher',
    bandWhy: 'Commodity text production collapsed in price first and fastest. Reported, verified and voice-driven writing is a different market.',
    named: false,
    tasks: [
      'Produce SEO product descriptions and listings',
      'Draft routine summaries and recaps',
      'Generate headline and subject-line variants',
      'Format and copy-fit to length',
      'Report an original story from human sources',
      'Verify a claim against primary evidence',
      'Develop a voice that takes years to build',
      'Decide what is true enough to publish',
    ],
    edge: [
      ['Reporting', 'Getting information that is not on the internet yet.'],
      ['Verification', 'Standing behind a claim.'],
      ['Voice', 'Being worth reading specifically.'],
    ],
    next: ['Report; do not just write', 'Build a beat nobody else has', 'Learn verification as a craft'],
    aiNote: 'The floor of the market fell out. The top of it is more distinguishable than ever.',
  },
  {
    id: 'accountant', soc: '132011', title: 'Accountant / Auditor', full: 'Accountants and Auditors',
    cluster: 'Business', icon: 'book', ind: 'CES6054120001', indName: 'Accounting, tax prep, bookkeeping & payroll services',
    band: [40, 65], bandLabel: 'Mixed',
    bandWhy: 'Preparation and testing are exposed; professional judgement, materiality and legal liability are structurally protected.',
    named: false,
    tasks: [
      'Pull and reconcile ledger data',
      'Prepare standard tax filings from records',
      'Test transaction samples against rules',
      'Draft audit workpaper documentation',
      'Judge whether an estimate is reasonable',
      'Question a client\'s explanation you do not believe',
      'Decide materiality on a genuinely grey item',
      'Sign an opinion and carry the liability',
    ],
    edge: [
      ['Professional scepticism', 'Not believing the client.'],
      ['Materiality', 'Judgement calls no rule fully specifies.'],
      ['Liability', 'A licence and a signature that mean something.'],
    ],
    next: ['Get licensed', 'Learn to audit automated systems', 'Specialise where judgement is contested'],
    aiNote: 'Regulation is a moat. It is also a reason this role changes more slowly than the tech allows.',
  },
  {
    id: 'electrician', soc: '472111', title: 'Electrician', full: 'Electricians',
    cluster: 'Skilled Trades', icon: 'bulb', ind: 'CES2023800001', indName: 'Building equipment contractors',
    band: [10, 25], bandLabel: 'Lower',
    bandWhy: 'Estimating and scheduling are exposed. Physical work in unpredictable, unsafe, non-standard environments is not.',
    named: false,
    tasks: [
      'Estimate materials and generate bids',
      'Look up code requirements',
      'Schedule jobs and order parts',
      'Produce as-built documentation',
      'Diagnose an intermittent fault on site',
      'Pull wire through an existing wall',
      'Decide a job is unsafe and stop work',
      'Train an apprentice by watching their hands',
    ],
    edge: [
      ['Embodiment', 'Work that happens in a crawlspace.'],
      ['Diagnosis', 'Faults that do not reproduce on demand.'],
      ['Liability', 'Signing that a building will not burn.'],
    ],
    next: ['Get licensed and keep the licence', 'Specialise (solar, controls, industrial)', 'Use AI for the paperwork half of the business'],
    aiNote: 'The strongest hedge in this list is not a skill. It is a body in a physical space.',
  },
  {
    id: 'support', soc: '151232', title: 'Computer Support Specialist', full: 'Computer User Support Specialists',
    cluster: 'Technology', icon: 'robot', ind: 'CES6054150001', indName: 'Computer systems design and related services',
    band: [50, 75], bandLabel: 'Mixed',
    bandWhy: 'Tier-1 tickets are highly automatable; the escalation, security-judgement and human-teaching layers are not.',
    named: false,
    tasks: [
      'Reset passwords and provision accounts',
      'Answer common how-do-I questions',
      'Triage and tag incoming tickets',
      'Write help-desk documentation',
      'Diagnose a problem the user described wrong',
      'Decide a machine must be pulled offline now',
      'Teach a frustrated colleague patiently',
      'Judge whether a request is a social-engineering attempt',
    ],
    edge: [
      ['Translation', 'Turning "it\'s broken" into a diagnosis.'],
      ['Security judgement', 'Being the human who says no.'],
      ['Patience', 'Teaching people who are embarrassed.'],
    ],
    next: ['Move toward security and systems administration', 'Learn to supervise automated triage', 'Build the teaching half of the role'],
    aiNote: 'Tier 1 is the most automated help desk layer in the economy. Tier 3 is hiring.',
  },
];
const byId = (id) => CAREERS.find((c) => c.id === id) || CAREERS[0];

/* =====================================================================
   4. EVIDENCE DESK — real published work, with its limits stated
   ===================================================================== */

const EVIDENCE = [
  {
    id: 'stanford',
    org: 'Stanford Digital Economy Lab',
    kind: 'Payroll microdata study',
    icon: 'bars', tone: '#6E69F8',
    title: 'Canaries in the Coal Mine?',
    who: 'Brynjolfsson, Chandar & Chen — ADP payroll records, millions of workers, 730+ occupations',
    claim: 'Since late 2022, employment for 22–25 year-olds in the most AI-exposed occupations fell roughly 13% relative to older workers in the same occupations. The decline concentrates where AI automates work; where it augments, employment grew at every age.',
    shows: 'Movement inside specific occupations, by age, from actual payroll records rather than surveys or job ads.',
    limits: 'It cannot prove AI is the cause. The authors themselves test interest rates, the post-pandemic correction and firm-level hiring freezes as rival explanations. It covers ADP\'s client firms, not the whole economy.',
    url: 'https://digitaleconomy.stanford.edu/publication/canaries-in-the-coal-mine-six-facts-about-the-recent-employment-effects-of-artificial-intelligence/',
  },
  {
    id: 'yale',
    org: 'The Budget Lab at Yale',
    kind: 'National survey tracker',
    icon: 'eye', tone: '#BFD7CC',
    title: 'Evaluating the Impact of AI on the Labor Market',
    who: 'Rolling analysis of Current Population Survey data, updated monthly',
    claim: 'At the level of the whole economy, the mix of occupations is shifting only slightly faster than historical norms — and that shift started before generative AI. Measures of exposure, automation and augmentation show no clear relationship to changes in employment or unemployment.',
    shows: 'That there is no broad, economy-wide disruption visible yet in the main national labour survey.',
    limits: 'Averages hide subgroups. This is the same period in which the Stanford study finds a sharp young-worker effect. The authors note that three years is very short for a technology transition.',
    url: 'https://budgetlab.yale.edu/research/evaluating-impact-ai-labor-market-novemberdecember-cps-update',
  },
  {
    id: 'aei',
    org: 'Anthropic',
    kind: 'Platform usage index',
    icon: 'robot', tone: '#FF6658',
    title: 'Anthropic Economic Index',
    who: 'Privacy-preserving analysis of millions of Claude conversations, published periodically',
    claim: 'Roughly half of conversations look like augmentation — learning, iterating, asking for feedback — and slightly under half look like automation, where a task is handed over. Automation\'s share has drifted upward over the longer run.',
    shows: 'How people actually use one AI system at scale, rather than what they say in a survey.',
    limits: 'Usage is not employment. This is one company\'s traffic, skewed toward technical and English-speaking users, and a conversation labelled "automation" may be automating a task nobody was paid to do.',
    url: 'https://www.anthropic.com/research/economic-index-june-2026-report',
  },
  {
    id: 'bls',
    org: 'U.S. Bureau of Labor Statistics',
    kind: 'Official projections',
    icon: 'notebook', tone: '#F6C94C',
    title: 'Employment Projections & Occupational Outlook',
    who: 'The federal statistical agency whose live data powers the numbers in this lab',
    claim: 'BLS publishes ten-year employment projections for every occupation, and states plainly that projections incorporate expected technology adoption but are not predictions about any individual job.',
    shows: 'The official U.S. baseline — the number a careers counsellor will quote you.',
    limits: 'Projections are revised annually, assume a full-employment economy, and are built to describe slow structural change. They are not designed to catch a fast shock.',
    url: 'https://www.bls.gov/emp/',
  },
  {
    id: 'gs',
    org: 'Goldman Sachs Research',
    kind: 'Macroeconomic forecast',
    icon: 'scales', tone: '#8C86FF',
    title: 'How Will AI Affect the US Labor Market?',
    who: 'Investment-bank economists modelling adoption, displacement and productivity',
    claim: 'A substantial share of current work tasks is exposed to automation; the model projects a modest, temporary rise in unemployment during the transition, followed by productivity gains.',
    shows: 'A clearly stated scenario with its assumptions on the table — useful precisely because you can argue with the assumptions.',
    limits: 'It is a forecast, not an observation. Nothing in it has happened yet, the authors revise it, and the firm has a commercial interest in the AI story.',
    url: 'https://www.goldmansachs.com/insights/articles/how-will-ai-affect-the-us-labor-market',
  },
  {
    id: 'press',
    org: 'Business press coverage',
    kind: 'Journalism',
    icon: 'speech', tone: '#DDEAE4',
    title: 'The entry-level jobs story',
    who: 'Fortune, Time and others, reporting on graduate hiring and job-postings data',
    claim: 'Entry-level postings have fallen sharply and unemployment among recent graduates has risen. Headlines widely attribute this to AI.',
    shows: 'Real movement in the hiring market that young people are experiencing right now.',
    limits: 'Postings are not employment — a posting can vanish because a job was filled, frozen, or never real. The same window contains interest-rate rises and a tech-sector correction. Headlines compress "correlated with" into "caused by".',
    url: 'https://fortune.com/2026/06/27/what-is-ai-impact-entry-level-jobs-stanford-adp-canaries-brynjolfsson-richardson/',
  },
];

const VERDICTS = [
  { k: 'convincing', label: 'Convincing', cls: 'chip' },
  { k: 'partly', label: 'Partly', cls: 'chip mustard' },
  { k: 'notyet', label: 'Not yet', cls: 'chip coral' },
  { k: 'more', label: 'Need more', cls: 'chip violet' },
];

const REFLECT_Q = [
  { id: 'human-core', icon: 'user', tone: '#F6C94C', q: 'What parts of this job should stay human, and why?' },
  { id: 'responsible-ai', icon: 'robot', tone: '#FF6658', q: 'What AI tools could help here responsibly, without removing necessary judgement?' },
  { id: 'skills-next', icon: 'bars', tone: '#BFD7CC', q: 'If the task mix changed the way you sorted it, what skill would you build next?' },
  { id: 'evidence-check', icon: 'eye', tone: '#8C86FF', q: 'What evidence would change your mind about this career?' },
];

const SCENARIOS = [
  'If AI writes the first draft, what should the human still verify?',
  'If software can finish a task faster, what responsibility still belongs to the worker?',
  'What would make AI assistance unsafe, unfair, or misleading in this role?',
  'Which task here would you be comfortable delegating? Which would you keep human-led? Why?',
  'Who benefits when this task is automated — and who carries the cost?',
];

const CONF_LABELS = { 1: 'Not informed yet', 2: 'A little informed', 3: 'Somewhat informed', 4: 'Well informed', 5: 'Very informed' };

/* =====================================================================
   PRESSURE TEST — four headline claims, checked against official data
   the student can see. Each says plainly what the number does and does
   not settle, because the honest answer to most of these is "not proven".
   ===================================================================== */

const CLAIMS = [
  {
    id: 'young',
    claim: 'AI is hitting young workers hardest.',
    heard: 'Headlines, and the Stanford payroll study',
    check: 'Unemployment rate, 20–24 year-olds vs everyone 25 and over',
    source: 'BLS Current Population Survey, seasonally adjusted, monthly',
    tone: '#6E69F8',
  },
  {
    id: 'openings',
    claim: 'Entry-level jobs are disappearing.',
    heard: 'Job-board reports and business press',
    check: 'Job openings across the whole economy, and the rate at which people are actually hired',
    source: 'BLS Job Openings and Labor Turnover Survey, monthly',
    tone: '#FF6658',
  },
  {
    id: 'productivity',
    claim: 'AI is delivering a productivity boom.',
    heard: 'Vendors, and macro forecasts',
    check: 'Output per hour worked in the nonfarm business sector',
    source: 'BLS Major Sector Productivity, quarterly, index 2017 = 100',
    tone: '#F6C94C',
  },
  {
    id: 'attention',
    claim: 'Everything changed in 2023.',
    heard: 'More or less everywhere',
    check: 'How many people read the Wikipedia articles for ChatGPT and Artificial intelligence, against total US employment',
    source: 'Wikimedia REST API pageviews, and BLS total nonfarm employment',
    tone: '#BFD7CC',
  },
];

/* =====================================================================
   THE DECISION — a short scenario about how AI actually enters a workplace.
   Every option is defensible; none is free. The point is that the outcome
   is chosen by people, not delivered by the technology.
   ===================================================================== */

const METERS = [
  { k: 'output', label: 'Work done', note: 'How much the team gets through' },
  { k: 'jobs', label: 'Jobs & entry level', note: 'Headcount, and the way in for beginners' },
  { k: 'quality', label: 'Quality & risk', note: 'Errors, liability, who is accountable' },
  { k: 'worker', label: 'Working life', note: 'Autonomy, stress, room to grow' },
];

const ROUNDS = [
  {
    id: 'pilot',
    title: 'The offer',
    setup: (t) =>
      `A vendor demonstrates a tool that does roughly 40% of the routine work your ${t.toLowerCase()}s do. It is impressive. It is also wrong about one thing in the demo, and nobody in the room notices but you.`,
    options: [
      {
        k: 'team', label: 'Pilot it, and let the team decide which tasks it takes',
        d: { output: 1, jobs: 0, quality: 1, worker: 2 },
        why: 'Slower to show a return, and the people who know where the tool breaks are the ones choosing where to point it. Adoption tends to stick.',
      },
      {
        k: 'mandate', label: 'Roll it out to everyone with a target for time saved',
        d: { output: 2, jobs: 0, quality: -1, worker: -2 },
        why: 'The fastest measured gain. Targets also teach people to hide the cases where the tool fails, which is where your risk lives.',
      },
      {
        k: 'decline', label: 'Decline for now and revisit in a year',
        d: { output: 0, jobs: 0, quality: 1, worker: 0 },
        why: 'Nothing breaks. But your competitors pilot it, and in a year you are learning on their timetable instead of your own.',
      },
    ],
  },
  {
    id: 'dividend',
    title: 'The dividend',
    setup: () =>
      'The tool works. The same work now takes about 60% of the time it used to. That saved time is real, and it belongs to somebody. You decide who.',
    options: [
      {
        k: 'cut', label: 'Cut two roles and bank the saving',
        d: { output: 0, jobs: -3, quality: -1, worker: -2 },
        why: 'The saving is immediate and shows up in this year\'s numbers. The remaining team now carries the same volume with less slack — and they watched what happened to the two who left.',
      },
      {
        k: 'grow', label: 'Keep everyone and take on more work',
        d: { output: 3, jobs: 1, quality: 0, worker: -1 },
        why: 'This is what happened with bank tellers after the ATM: cheaper branches meant more branches, and more tellers, for two decades. It only works if there is more demand to capture.',
      },
      {
        k: 'time', label: 'Keep everyone and give the time back',
        d: { output: 1, jobs: 1, quality: 2, worker: 3 },
        why: 'Retention and quality improve; the productivity gain mostly does not reach the accounts. Defensible, and the hardest of the three to defend upward.',
      },
    ],
  },
  {
    id: 'entry',
    title: 'The way in',
    setup: (t) =>
      `Your junior ${t.toLowerCase()} role exists to do exactly the work the tool now does. That is how people used to learn this job — badly at first, on the easy cases, until the hard ones made sense.`,
    options: [
      {
        k: 'stop', label: 'Stop hiring juniors',
        d: { output: 1, jobs: -3, quality: -1, worker: -1 },
        why: 'Rational this year. In five years you need senior people and nobody has been trained, because the ladder you climbed no longer has a bottom rung.',
      },
      {
        k: 'redesign', label: 'Redesign the junior role around checking and judgement',
        d: { output: 1, jobs: 1, quality: 3, worker: 2 },
        why: 'Harder to hire for and harder to supervise — you are asking beginners to audit confident output. It is also the only option that still produces seniors.',
      },
      {
        k: 'toolday1', label: 'Hire juniors and give them the tool on day one',
        d: { output: 2, jobs: 1, quality: -1, worker: 0 },
        why: 'They are productive immediately and fluent with the tool. Some never build the underlying judgement, because they never had to do it the slow way.',
      },
    ],
  },
  {
    id: 'error',
    title: 'The error',
    setup: () =>
      'The tool produced something confident and wrong. A person signed off on it without checking, because ninety-nine times out of a hundred it is right. It reached a client.',
    options: [
      {
        k: 'blame', label: 'Discipline the person who signed off',
        d: { output: 0, jobs: 0, quality: -2, worker: -3 },
        why: 'Accountability lands on the least powerful person in the chain. The next error gets hidden rather than reported, and you stop hearing about failures.',
      },
      {
        k: 'gate', label: 'Add a review step and accept it will be slower',
        d: { output: -2, jobs: 1, quality: 3, worker: 1 },
        why: 'You give back some of the speed you bought. In a regulated or safety-critical setting this is not optional, and the review step is now a real job.',
      },
      {
        k: 'vendor', label: 'Report it to the vendor and carry on',
        d: { output: 1, jobs: 0, quality: -2, worker: -1 },
        why: 'You keep the speed and move the problem to someone with no liability for your client. When it happens again, you still own the outcome.',
      },
    ],
  },
];

const DECISION_VERDICT = (m) => {
  if (m.jobs <= -3) return ['You bought the gain with jobs.', 'The productivity was real and so was the cost, and it landed almost entirely on people rather than on the organisation. That is a choice, and it is the most common one.'];
  if (m.quality >= 4) return ['You bought safety with speed.', 'You gave back a good deal of the productivity to keep a human accountable. In a role where being wrong is expensive, that is the defensible trade — and it will keep needing defending.'];
  if (m.worker >= 4) return ['You gave the gain to the people doing the work.', 'The least common answer in practice. It holds up on retention, quality and trust, and it is the hardest to justify to anyone counting only the money.'];
  if (m.output >= 5) return ['You optimised hard for throughput.', 'The numbers look excellent this year. Check what you traded: quality, the entry level, or the people. All three are slower to show up than output is.'];
  return ['You spread the cost around.', 'No single meter took the whole hit. That is often what careful management looks like — and it also means no one outcome is dramatic enough to point at when someone asks what AI did here.'];
};

/* =====================================================================
   5. UTILITIES
   ===================================================================== */

const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const fmtInt = (n) => n == null ? '—' : n.toLocaleString('en-US');
const fmtUSD = (n) => n == null ? '—' : '$' + Math.round(n).toLocaleString('en-US');
const pct = (n, d = 1) => (n >= 0 ? '+' : '') + n.toFixed(d) + '%';
const monthLabel = (i) => {
  const m = CES_MONTH0[1] - 1 + i;
  const y = CES_MONTH0[0] + Math.floor(m / 12);
  return ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][m % 12] + ' ' + y;
};
const idxOfMonth = (y, m) => (y - CES_MONTH0[0]) * 12 + (m - CES_MONTH0[1]);

const store = {
  get(k, f) { try { const v = localStorage.getItem('cf.' + k); return v ? JSON.parse(v) : f; } catch (e) { return f; } },
  set(k, v) { try { localStorage.setItem('cf.' + k, JSON.stringify(v)); } catch (e) { /* private mode */ } },
};

function usePersist(key, initial) {
  const [v, setV] = useState(() => store.get(key, initial));
  useEffect(() => { store.set(key, v); }, [key, v]);
  return [v, setV];
}

/* exposure score from a student's sort: automated=1, ai-assisted=0.5, human=0 */
const WEIGHT = { automated: 1, aiAssisted: 0.5, humanLed: 0 };
function scoreOf(sort, tasks) {
  const vals = tasks.map((_, i) => sort[i]).filter(Boolean);
  if (!vals.length) return null;
  const s = vals.reduce((a, k) => a + WEIGHT[k], 0);
  return Math.round((s / vals.length) * 100);
}
function mixOf(sort, tasks) {
  const c = { automated: 0, aiAssisted: 0, humanLed: 0 };
  tasks.forEach((_, i) => { if (sort[i]) c[sort[i]]++; });
  const t = c.automated + c.aiAssisted + c.humanLed || 1;
  const p = { automated: Math.round((c.automated / t) * 100), aiAssisted: Math.round((c.aiAssisted / t) * 100) };
  p.humanLed = 100 - p.automated - p.aiAssisted;    // absorb rounding so the three always total 100
  return { counts: c, ...p, done: c.automated + c.aiAssisted + c.humanLed === tasks.length };
}
const levelOf = (s) => s == null ? null : s < 34 ? 'low' : s < 67 ? 'moderate' : 'high';
const LEVEL_TEXT = { low: 'Low exposure', moderate: 'Moderate exposure', high: 'High exposure' };

/* =====================================================================
   6. LIVE BLS FETCH
      One POST for the 24 OEWS series, one for the 11 CES series.
      Cached for 7 days. The public v1 API allows 25 requests per IP per
      day, which a whole classroom shares — hence the bundled snapshot.
   ===================================================================== */

const API = 'https://api.bls.gov/publicAPI/v1/timeseries/data/';
const CACHE_KEY = 'blsCache.v2';
const CACHE_MS = 7 * 24 * 3600 * 1000;

async function postSeries(seriesid, startyear, endyear) {
  const r = await fetch(API, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ seriesid, startyear, endyear }),
  });
  if (!r.ok) throw new Error('BLS returned ' + r.status);
  const j = await r.json();
  if (j.status !== 'REQUEST_SUCCEEDED') throw new Error(j.message?.[0] || 'BLS request not succeeded');
  return j.Results.series;
}

/* Turn a BLS series response into an array indexed from January 2019.
   Quarterly series (productivity) index four to the year instead of twelve. */
function toIndexed(series, quarterly) {
  const pts = series.data
    .filter((d) => (quarterly ? d.period?.[0] === 'Q' && d.period !== 'Q05'
      : d.period?.[0] === 'M' && d.period !== 'M13'))
    .map((d) => ({
      i: (Number(d.year) - CES_MONTH0[0]) * (quarterly ? 4 : 12) + Number(d.period.slice(1)) - 1,
      v: Number(d.value),
    }))
    .filter((d) => d.i >= 0 && Number.isFinite(d.v))
    .sort((a, b) => a.i - b.i);
  if (!pts.length) return null;
  const arr = new Array(pts[pts.length - 1].i + 1).fill(null);
  pts.forEach((p) => { arr[p.i] = p.v; });
  for (let i = 1; i < arr.length; i++) if (arr[i] == null) arr[i] = arr[i - 1];
  for (let i = arr.length - 1; i >= 0; i--) if (arr[i] == null) arr[i] = arr[i + 1];
  return arr.some((x) => x == null) ? null : arr;
}

async function fetchLive() {
  const socs = CAREERS.map((c) => c.soc);
  const oewsIds = socs.flatMap((s) => [OEWS_PREFIX + s + DT_EMP, OEWS_PREFIX + s + DT_MED]);
  const cesIds = Array.from(new Set(CAREERS.map((c) => c.ind))).concat(['CES0000000001']);
  const yr = new Date().getFullYear();

  const [oewsRes, cesRes, extraRes] = await Promise.all([
    postSeries(oewsIds, String(yr - 1), String(yr)),
    postSeries(Array.from(new Set(cesIds)), String(CES_MONTH0[0]), String(yr)),
    postSeries(EXTRA_SERIES.concat(QUARTERLY), String(CES_MONTH0[0]), String(yr)),
  ]);

  const extra = {};
  extraRes.forEach((s) => {
    const arr = toIndexed(s, QUARTERLY.includes(s.seriesID));
    if (arr) extra[s.seriesID] = arr;
  });

  const oews = {};
  oewsRes.forEach((s) => {
    if (!s.data?.length) return;
    const soc = s.seriesID.slice(17, 23), dt = s.seriesID.slice(23);
    const latest = s.data.slice().sort((a, b) => Number(b.year) - Number(a.year))[0];
    const v = Number(latest.value);
    oews[soc] = oews[soc] || {};
    oews[soc][dt === DT_EMP ? 'emp' : 'med'] = v;
    oews[soc].year = latest.year;
  });

  const ces = {};
  let last = null;
  cesRes.forEach((s) => {
    const pts = s.data
      .filter((d) => d.period?.[0] === 'M' && d.period !== 'M13')
      .map((d) => ({ i: idxOfMonth(Number(d.year), Number(d.period.slice(1))), v: Number(d.value) }))
      .filter((d) => d.i >= 0)
      .sort((a, b) => a.i - b.i);
    if (!pts.length) return;
    const arr = new Array(pts[pts.length - 1].i + 1).fill(null);
    pts.forEach((p) => { arr[p.i] = p.v; });
    for (let i = 1; i < arr.length; i++) if (arr[i] == null) arr[i] = arr[i - 1];
    for (let i = arr.length - 1; i >= 0; i--) if (arr[i] == null) arr[i] = arr[i + 1];
    if (arr.some((x) => x == null)) return;      // month index is anchored at 2019-01; never re-base it
    ces[s.seriesID] = arr;
    last = Math.max(last ?? 0, pts[pts.length - 1].i);
  });

  if (!Object.keys(oews).length || !Object.keys(ces).length) throw new Error('BLS returned no usable rows');
  /* The extra series power one screen; if only they fail, fall back for those
     alone rather than throwing the whole (expensive) fetch away. */
  return {
    oews, ces, lastIdx: last, at: Date.now(),
    extra: Object.keys(extra).length ? extra : EXTRA_SNAPSHOT,
    extraLive: Object.keys(extra).length > 0,
  };
}

/* ---------------------------------------------------------------------
   Wikipedia pageviews. Separate host, separate quota, separate failure:
   if this is unavailable the rest of the lab is unaffected.
   --------------------------------------------------------------------- */
const WIKI_CACHE = 'wikiCache.v1';

async function fetchWiki() {
  const now = new Date();
  /* The API range is inclusive, and the current month is always incomplete —
     ask for the previous month as the end, or the last data point is a
     partial count that reads as a collapse in attention. */
  const end = new Date(now.getFullYear(), now.getMonth() - 1, 1);
  const stamp = (d) => `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}0100`;
  const out = {};
  /* Settled, not all-or-nothing: the API 404s an individual article whenever
     its aggregate is briefly unavailable, and one missing article should not
     cost us the one that did load. Anything that fails keeps its snapshot. */
  await Promise.all(Object.keys(WIKI_SNAPSHOT).map(async (article) => {
    const url = `${WIKI_API}${article}/monthly/${CES_MONTH0[0]}010100/${stamp(end)}`;
    let j;
    try {
      const r = await fetch(url, { headers: { Accept: 'application/json' } });
      if (!r.ok) return;
      j = await r.json();
    } catch (e) { return; }
    /* Wikimedia sometimes serves a month that is still being aggregated, which
       arrives as a fraction of the real count. Left in, it draws a cliff that
       looks like attention collapsing. Drop trailing months that come in far
       below the recent norm. */
    const items = (j.items || []).slice();
    while (items.length > 7) {
      const tail = items[items.length - 1].views;
      const prior = items.slice(-7, -1).map((x) => x.views).sort((a, b) => a - b);
      const median = prior[Math.floor(prior.length / 2)];
      if (median && tail < median * 0.35) items.pop(); else break;
    }
    const arr = new Array(120).fill(null);
    items.forEach((it) => {
      const y = Number(it.timestamp.slice(0, 4)), m = Number(it.timestamp.slice(4, 6));
      const i = (y - CES_MONTH0[0]) * 12 + (m - 1);
      if (i >= 0 && i < arr.length) arr[i] = it.views;
    });
    if (arr.some((v) => v != null)) out[article] = arr;
  }));
  if (!Object.keys(out).length) throw new Error('Wikimedia returned no usable rows');
  /* Articles that failed keep their bundled series, so a partial result still
     draws a complete chart rather than a missing line. */
  return { views: { ...WIKI_SNAPSHOT, ...out }, live: Object.keys(out), at: Date.now() };
}

function useWiki() {
  const [state, setState] = useState(() => {
    const c = store.get(WIKI_CACHE, null);
    if (c && Date.now() - c.at < CACHE_MS) return { status: 'live', views: c.views, cached: true };
    return { status: 'loading', views: WIKI_SNAPSHOT };
  });
  useEffect(() => {
    if (state.status !== 'loading') return;
    let alive = true;
    fetchWiki().then((d) => {
      if (!alive) return;
      store.set(WIKI_CACHE, d);
      setState({ status: 'live', views: d.views, cached: false });
    }).catch(() => { if (alive) setState({ status: 'snapshot', views: WIKI_SNAPSHOT }); });
    return () => { alive = false; };
  }, [state.status]);
  return state;
}

const DataCtx = createContext(null);
const useData = () => useContext(DataCtx);

function useBLS() {
  const [state, setState] = useState(() => {
    const c = store.get(CACHE_KEY, null);
    if (c && Date.now() - c.at < CACHE_MS && c.ces && c.oews) {
      return { status: 'live', ...c, extra: c.extra || EXTRA_SNAPSHOT, cached: true };
    }
    return {
      status: 'loading',
      oews: Object.fromEntries(Object.entries(OEWS_SNAPSHOT).map(([k, v]) => [k, { ...v, year: '2025' }])),
      ces: CES_SNAPSHOT,
      extra: EXTRA_SNAPSHOT,
      lastIdx: CES_SNAPSHOT.CES0000000001.length - 1,
      at: null,
    };
  });

  useEffect(() => {
    if (state.status !== 'loading') return;
    let alive = true;
    fetchLive().then((d) => {
      if (!alive) return;
      store.set(CACHE_KEY, d);
      setState({ status: 'live', ...d, cached: false });
    }).catch((err) => {
      if (!alive) return;
      setState((s) => ({ ...s, status: 'snapshot', error: String(err.message || err) }));
    });
    return () => { alive = false; };
  }, [state.status]);

  return state;
}

/* =====================================================================
   7. THE DRUM
      DOM segments arranged on a cylinder with CSS 3D transforms.
      One Framer Motion spring drives BOTH the cylinder rotation and the
      canvas-painted ball — they read the same value on the same tick.
   ===================================================================== */

const SEG_H = 62;
const N = CAREERS.length;
const STEP = 360 / N;
const RADIUS = Math.round((SEG_H / 2) / Math.tan(Math.PI / N));

/* The whole widget is driven by ONE motion value, `rot` (degrees).
   While a finger is down it tracks the finger; on release a Framer Motion
   spring carries the momentum and settles it on a segment. The drum
   transform and the canvas ball both read this same value on the same tick. */
const SPRING = { type: 'spring', stiffness: 58, damping: 15.5, mass: 1.15, restDelta: 0.004 };

function Drum({ selected, onSelect }) {
  const reduce = useReducedMotion();
  const stageRef = useRef(null);
  const cylRef = useRef(null);
  const segRefs = useRef([]);
  const canvasRef = useRef(null);
  const trail = useRef([]);
  const prev = useRef({ r: 0, t: 0, speed: 0 });
  const drag = useRef(null);
  const [dragging, setDragging] = useState(false);

  const start = CAREERS.findIndex((c) => c.id === selected) * STEP;
  const rot = useMotionValue(start);
  const anim = useRef(null);
  const aim = useRef(start);          // where the spring is *headed*, not where it is

  const renderRef = useRef(null);      // assigned once `render` exists, below
  const watchdog = useRef(null);

  const settle = useCallback((to) => {
    anim.current?.stop();
    clearTimeout(watchdog.current);
    aim.current = to;
    const land = () => { anim.current?.stop(); rot.set(to); renderRef.current?.(to, 0); };
    if (reduce) { land(); return; }

    anim.current = animate(rot, to, SPRING);

    /* The spring runs on requestAnimationFrame, which browsers suspend for
       hidden tabs. Without this the drum would be stranded between two
       segments whenever a student switched away mid-spin. Poll on a timer
       (not rAF): if the value stops moving before it arrives, land it. */
    let last = NaN;
    const tick = () => {
      if (drag.current || aim.current !== to) return;   // superseded by a newer gesture
      const cur = rot.get();
      if (Math.abs(cur - to) < 0.5 || cur === last) { land(); return; }
      last = cur;
      watchdog.current = setTimeout(tick, 500);
    };
    watchdog.current = setTimeout(tick, 900);
  }, [reduce]); // eslint-disable-line

  useEffect(() => () => { anim.current?.stop(); clearTimeout(watchdog.current); }, []);

  /* Keep the drum honest when the career changes elsewhere (the select, a
     restored session). Compared against `aim`, never against the live value —
     re-targeting mid-flight would stop the spring the drum is riding. */
  useEffect(() => {
    const i = CAREERS.findIndex((c) => c.id === selected);
    const aimed = ((Math.round(aim.current / STEP) % N) + N) % N;
    if (aimed === i || drag.current) return;
    settle(i * STEP + Math.round((aim.current - i * STEP) / 360) * 360);
  }, [selected, settle]); // eslint-disable-line

  /* ---- canvas sizing ---- */
  const dims = useRef({ w: 0, h: 0, dpr: 1 });
  useEffect(() => {
    const cv = canvasRef.current, stage = stageRef.current;
    if (!cv || !stage) return;
    const fit = () => {
      const r = stage.getBoundingClientRect();
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      cv.width = Math.round(r.width * dpr);
      cv.height = Math.round(r.height * dpr);
      dims.current = { w: r.width, h: r.height, dpr };
      paint(rot.get(), 0);
    };
    fit();
    const ro = new ResizeObserver(fit);
    ro.observe(stage);
    return () => ro.disconnect();
  }, []); // eslint-disable-line

  /* ---- the single render pass, driven by the sprung value ---- */
  const paint = useCallback((r, speed) => {
    const cv = canvasRef.current;
    if (!cv) return;
    const { w, h, dpr } = dims.current;
    if (!w) return;
    const ctx = cv.getContext('2d');
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, w, h);

    const cx = w / 2, cy = h / 2;
    const outer = Math.min(w, h) * 0.44;
    const inner = Math.min(w, h) * 0.30;

    const sN = clamp(speed / 9, 0, 1);            // 0 = at rest, 1 = flying
    const orbit = inner + (outer - inner) * sN;   // radius follows the same value
    const lead = r * -2.35 * (Math.PI / 180);     // ball laps the drum
    const settle = (r * Math.PI / 180) * 0.05;    // drifts to rest on the selection line
    const phi = lead * sN + settle * (1 - sN);    // blends to the marker as it stops

    const bx = cx + Math.cos(phi) * orbit;
    const by = cy + Math.sin(phi) * orbit * 0.62;
    const br = Math.max(9, Math.min(w, h) * 0.045);

    /* --- painted rim: conic sheen + inner shadow --- */
    ctx.save();
    ctx.translate(cx, cy);
    ctx.scale(1, 0.62);
    ctx.lineWidth = br * 1.9;
    if (ctx.createConicGradient) {
      const cg = ctx.createConicGradient(phi, 0, 0);
      cg.addColorStop(0.00, 'rgba(255,247,231,0.30)');
      cg.addColorStop(0.14, 'rgba(191,215,204,0.12)');
      cg.addColorStop(0.45, 'rgba(255,247,231,0.03)');
      cg.addColorStop(0.72, 'rgba(110,105,248,0.16)');
      cg.addColorStop(1.00, 'rgba(255,247,231,0.30)');
      ctx.strokeStyle = cg;
    } else {
      const lg = ctx.createLinearGradient(-outer, 0, outer, 0);
      lg.addColorStop(0, 'rgba(255,247,231,0.05)');
      lg.addColorStop(0.5, 'rgba(191,215,204,0.18)');
      lg.addColorStop(1, 'rgba(110,105,248,0.12)');
      ctx.strokeStyle = lg;
    }
    ctx.beginPath();
    ctx.arc(0, 0, (inner + outer) / 2, 0, Math.PI * 2);
    ctx.stroke();
    ctx.restore();

    /* --- motion trail, same value, one frame behind --- */
    trail.current.unshift({ x: bx, y: by, s: sN });
    if (trail.current.length > 14) trail.current.pop();
    trail.current.forEach((p, i) => {
      if (i === 0) return;
      const a = (1 - i / trail.current.length) * 0.30 * sN;
      if (a <= 0.01) return;
      const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, br * (1 - i * 0.03));
      g.addColorStop(0, `rgba(246,201,76,${a})`);
      g.addColorStop(1, 'rgba(246,201,76,0)');
      ctx.fillStyle = g;
      ctx.beginPath();
      ctx.arc(p.x, p.y, br * (1 - i * 0.03), 0, Math.PI * 2);
      ctx.fill();
    });

    /* --- contact shadow --- */
    const sh = ctx.createRadialGradient(bx, by + br * 1.05, 0, bx, by + br * 1.05, br * 1.6);
    sh.addColorStop(0, 'rgba(0,0,0,0.55)');
    sh.addColorStop(1, 'rgba(0,0,0,0)');
    ctx.fillStyle = sh;
    ctx.beginPath();
    ctx.ellipse(bx, by + br * 1.05, br * 1.6, br * 0.6, 0, 0, Math.PI * 2);
    ctx.fill();

    /* --- the ball: painted gradients only --- */
    const body = ctx.createRadialGradient(
      bx - br * 0.38, by - br * 0.44, br * 0.06,
      bx, by, br * 1.12
    );
    body.addColorStop(0.00, '#FFFDF6');
    body.addColorStop(0.18, '#FFE9A8');
    body.addColorStop(0.46, '#F6C94C');
    body.addColorStop(0.80, '#D9932C');
    body.addColorStop(1.00, '#7A4A12');
    ctx.fillStyle = body;
    ctx.beginPath();
    ctx.arc(bx, by, br, 0, Math.PI * 2);
    ctx.fill();

    const rim = ctx.createRadialGradient(bx, by, br * 0.7, bx, by, br);
    rim.addColorStop(0, 'rgba(255,102,88,0)');
    rim.addColorStop(1, 'rgba(255,102,88,0.55)');
    ctx.fillStyle = rim;
    ctx.beginPath();
    ctx.arc(bx, by, br, 0, Math.PI * 2);
    ctx.fill();

    const spec = ctx.createRadialGradient(
      bx - br * 0.42, by - br * 0.5, 0,
      bx - br * 0.42, by - br * 0.5, br * 0.55
    );
    spec.addColorStop(0, 'rgba(255,255,255,0.95)');
    spec.addColorStop(1, 'rgba(255,255,255,0)');
    ctx.fillStyle = spec;
    ctx.beginPath();
    ctx.arc(bx - br * 0.42, by - br * 0.5, br * 0.55, 0, Math.PI * 2);
    ctx.fill();

    ctx.strokeStyle = 'rgba(22,22,22,0.55)';
    ctx.lineWidth = 1.4;
    ctx.beginPath();
    ctx.arc(bx, by, br - 0.7, 0, Math.PI * 2);
    ctx.stroke();
  }, []);

  /* one render pass for the whole widget, from one value */
  const render = useCallback((r, speed) => {
    if (cylRef.current) cylRef.current.style.transform = `translateZ(${-RADIUS}px) rotateX(${-r}deg)`;

    const active = ((Math.round(r / STEP) % N) + N) % N;
    segRefs.current.forEach((el, i) => {
      if (!el) return;
      const d = ((i * STEP - r) % 360 + 540) % 360 - 180;
      el.style.opacity = String(clamp(1 - Math.abs(d) / 108, 0.06, 1));
      el.classList.toggle('on', i === active && speed < 1.2);
    });

    paint(r, speed);
  }, [paint]);
  renderRef.current = render;

  /* the one subscription: everything visual reads this value */
  useMotionValueEvent(rot, 'change', (r) => {
    const now = performance.now();
    const dt = Math.max(1, now - prev.current.t);
    const speed = Math.abs(r - prev.current.r) * (16.7 / dt);
    prev.current = { r, t: now, speed };
    render(r, speed);
  });

  /* initial paint / position, plus a slow tick so the ball settles visually */
  useEffect(() => {
    render(rot.get(), 0);
    const id = setInterval(() => {
      const r = rot.get();
      // once the value stops changing the ball is at rest, whatever the last
      // per-frame speed happened to be
      prev.current.speed = Math.abs(r - prev.current.r) > 0.001 ? prev.current.speed * 0.85 : 0;
      prev.current.r = r;
      render(r, prev.current.speed);
    }, 260);
    return () => clearInterval(id);
  }, [render]); // eslint-disable-line

  /* ---- drag ---- */
  const commit = (v) => {
    const snapped = Math.round(v / STEP) * STEP;
    settle(snapped);
    onSelect(CAREERS[((Math.round(snapped / STEP) % N) + N) % N].id);
  };
  const onDown = (e) => {
    e.currentTarget.setPointerCapture?.(e.pointerId);
    anim.current?.stop();
    clearTimeout(watchdog.current);
    aim.current = rot.get();
    drag.current = { y: e.clientY, base: rot.get(), vy: 0, lastDy: 0, t: performance.now() };
    setDragging(true);
  };
  const onMove = (e) => {
    const d = drag.current;
    if (!d) return;
    const dy = e.clientY - d.y;
    const now = performance.now();
    d.vy = ((dy - d.lastDy) / Math.max(1, now - d.t)) * 16.7;
    d.lastDy = dy; d.t = now;
    rot.set(d.base - dy * 0.42);
  };
  const onUp = () => {
    const d = drag.current;
    if (!d) return;
    drag.current = null;
    setDragging(false);
    commit(rot.get() - clamp(d.vy, -40, 40) * 6.5);   // release velocity becomes throw distance
  };

  const spin = () => {
    const turns = 2 + Math.floor(Math.random() * 3);
    commit(rot.get() + turns * 360 + Math.floor(Math.random() * N) * STEP);
  };

  return (
    <div className="drumwrap">
      <div className="dots" aria-hidden="true" />
      <div
        ref={stageRef}
        className={'drumstage' + (dragging ? ' dragging' : '')}
        onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp}
        role="group" aria-label="Career drum. Drag to spin, or use the list below."
      >
        <canvas ref={canvasRef} className="ballcanvas" aria-hidden="true" />
        <motion.div ref={cylRef} className="cylinder" style={{ '--seg-h': SEG_H + 'px', height: SEG_H }}>
          {CAREERS.map((c, i) => (
            <div
              key={c.id}
              ref={(el) => (segRefs.current[i] = el)}
              className="seg"
              style={{ transform: `rotateX(${i * STEP}deg) translateZ(${RADIUS}px)` }}
              aria-hidden="true"
            >
              <span className="num">{String(i + 1).padStart(2, '0')}</span>
              <span className="txt">
                <span className="lbl">{c.title}</span>
                <span className="sub">{c.cluster}</span>
              </span>
            </div>
          ))}
        </motion.div>
        <div className="drum-glass" />
        <div className="drum-line" style={{ top: '50%' }} />
      </div>

      <div className="drumbar">
        <button className="btn" onClick={spin}>
          Spin the drum <span className="knob"><I.arrow /></span>
        </button>
        <p className="hint">Drag the drum, or spin it. The ball and the drum are driven by the same spring.</p>
      </div>

      <div className="pickerlist">
        <label className="tiny" htmlFor="career-select">Or choose from the list</label>
        <select id="career-select" value={selected} onChange={(e) => {
          const i = CAREERS.findIndex((c) => c.id === e.target.value);
          const cur = rot.get();
          settle(i * STEP + Math.round((cur - i * STEP) / 360) * 360);
          onSelect(e.target.value);
        }}>
          {CAREERS.map((c) => <option key={c.id} value={c.id}>{c.title} — {c.cluster}</option>)}
        </select>
      </div>
    </div>
  );
}

/* =====================================================================
   8. CHART PIECES
   ===================================================================== */

function Gauge({ score, size = 300 }) {
  const s = score == null ? null : clamp(score, 0, 100);
  const a = ((s ?? 50) / 100) * Math.PI - Math.PI;     // -π .. 0
  const cx = 256, cy = 347, R = 166, L = 132;
  const nx = cx + Math.cos(a) * L, ny = cy + Math.sin(a) * L;
  const arc = (from, to) => {
    const p = (t) => [cx + Math.cos(t * Math.PI - Math.PI) * R, cy + Math.sin(t * Math.PI - Math.PI) * R];
    const [x1, y1] = p(from), [x2, y2] = p(to);
    return `M${x1} ${y1}A${R} ${R} 0 0 1 ${x2} ${y2}`;
  };
  const segs = [[0, 0.333, '#BFD7CC'], [0.333, 0.666, '#F6C94C'], [0.666, 1, '#FF6658']];
  return (
    <svg viewBox="0 0 512 452" width={size} role="img"
      aria-label={s == null ? 'Exposure gauge, not yet set' : `Exposure gauge at ${s} of 100`}>
      {/* white casing first, so every band reads on any card colour */}
      <g fill="none" strokeLinecap="round">
        {segs.map(([a1, a2], i) => <path key={i} d={arc(a1, a2)} stroke="#FFF7E7" strokeWidth="66" />)}
        {segs.map(([a1, a2, col], i) => <path key={'c' + i} d={arc(a1, a2)} stroke={col} strokeWidth="52" />)}
      </g>
      {s != null && (
        <g stroke="#161616" strokeWidth="10" strokeLinecap="round">
          <path d={`M${cx} ${cy}L${nx} ${ny}`} />
          <circle cx={cx} cy={cy} r="22" fill="#161616" />
          <circle cx={cx} cy={cy} r="8" fill="#FFF7E7" stroke="none" />
        </g>
      )}
      <g>
        <rect x={cx - 108} y="384" width="216" height="56" rx="28" fill="#161616" />
        <text x={cx} y="424" textAnchor="middle" fontFamily='"Arial Narrow",Impact,sans-serif'
          fontSize="36" fontWeight="900" fill="#FFF7E7" letterSpacing="1">
          {s == null ? 'NOT SCORED' : s + ' / 100'}
        </text>
      </g>
    </svg>
  );
}

function Donut({ mix, size = 220 }) {
  const parts = [
    ['automated', mix.automated, '#FF6658'],
    ['aiAssisted', mix.aiAssisted, '#F6C94C'],
    ['humanLed', mix.humanLed, '#BFD7CC'],
  ];
  const R = 76, C = 2 * Math.PI * R;
  let off = 0;
  return (
    <svg viewBox="0 0 200 200" width={size} role="img" aria-label="Task mix donut">
      <g transform="translate(100,100) rotate(-90)">
        {parts.map(([k, v, col]) => {
          const len = (v / 100) * C;
          const el = (
            <circle key={k} r={R} fill="none" stroke={col} strokeWidth="40"
              strokeDasharray={`${len} ${C - len}`} strokeDashoffset={-off} />
          );
          off += len;
          return el;
        })}
      </g>
      <circle cx="100" cy="100" r="52" fill="#161616" />
      <text x="100" y="94" textAnchor="middle" fill="#FFF7E7" fontFamily="var(--head)" fontSize="30" fontWeight="900">
        {mix.humanLed}%
      </text>
      <text x="100" y="118" textAnchor="middle" fill="#90A89E" fontSize="12" letterSpacing="1.6">HUMAN-LED</text>
    </svg>
  );
}

function Spark({ series, lastIdx, color = '#BFD7CC', h = 120, marks = true }) {
  if (!series || series.length < 4) return null;
  const n = series.length;
  const min = Math.min(...series), max = Math.max(...series);
  const pad = (max - min) * 0.12 || 1;
  const lo = min - pad, hi = max + pad;
  const W = 640;
  const X = (i) => (i / (n - 1)) * W;
  const Y = (v) => h - ((v - lo) / (hi - lo)) * h;
  const d = series.map((v, i) => `${i ? 'L' : 'M'}${X(i).toFixed(1)} ${Y(v).toFixed(1)}`).join('');
  const area = d + `L${W} ${h}L0 ${h}Z`;
  const gptIdx = idxOfMonth(2022, 11);           // ChatGPT public release
  const gx = gptIdx >= 0 && gptIdx < n ? X(gptIdx) : null;
  return (
    <svg viewBox={`0 0 ${W} ${h + 24}`} preserveAspectRatio="none" role="img"
      aria-label="Monthly employment trend for this industry">
      <defs>
        <linearGradient id={'g' + color.slice(1)} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor={color} stopOpacity="0.30" />
          <stop offset="100%" stopColor={color} stopOpacity="0" />
        </linearGradient>
      </defs>
      <path d={area} fill={`url(#g${color.slice(1)})`} />
      <path d={d} fill="none" stroke={color} strokeWidth="2.6" strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
      {marks && gx != null && (
        <g>
          <line x1={gx} y1="0" x2={gx} y2={h} stroke="#FF6658" strokeWidth="1.5" strokeDasharray="4 4" vectorEffect="non-scaling-stroke" />
          <text x={gx + 6} y="13" fill="#FF6658" fontSize="11">Nov 2022</text>
        </g>
      )}
      <text x="0" y={h + 18} fill="#90A89E" fontSize="11">{monthLabel(0)}</text>
      <text x={W} y={h + 18} fill="#90A89E" fontSize="11" textAnchor="end">{monthLabel(lastIdx)}</text>
    </svg>
  );
}

/* Multi-series chart. Each line carries its own scale unless `share` is set,
   so a rate and a level can sit on the same time axis without one flattening
   the other — the axis labels say which is which. */
function MultiSpark({ lines, h = 170, quarterly = false, marker = true, share = false }) {
  const good = lines.filter((l) => l.data && l.data.length > 3);
  if (!good.length) return null;
  const W = 680;
  const n = Math.max(...good.map((l) => l.data.length));
  const X = (i) => (i / (n - 1)) * W;
  const bounds = (vals) => {
    const mn = Math.min(...vals), mx = Math.max(...vals);
    const pad = (mx - mn) * 0.14 || 1;
    return [mn - pad, mx + pad];
  };
  const shared = share ? bounds(good.flatMap((l) => l.data.filter((v) => v != null))) : null;
  const gpt = quarterly ? (2022 - CES_MONTH0[0]) * 4 + 3 : idxOfMonth(2022, 11);

  return (
    <svg viewBox={`0 0 ${W} ${h + 26}`} role="img"
      aria-label={good.map((l) => l.label).join(' and ') + ' over time'}>
      {marker && gpt >= 0 && gpt < n && (
        <g>
          <line x1={X(gpt)} y1="0" x2={X(gpt)} y2={h} stroke="#FF6658" strokeWidth="1.5" strokeDasharray="5 4" />
          <text x={X(gpt) + 7} y="13" fill="#FF6658" fontSize="12">ChatGPT, Nov 2022</text>
        </g>
      )}
      {good.map((l, li) => {
        const vals = l.data.filter((v) => v != null);
        const [lo, hi] = shared || bounds(vals);
        const Y = (v) => h - ((v - lo) / (hi - lo)) * h;
        let d = '', open = false;
        l.data.forEach((v, i) => {
          if (v == null) { open = false; return; }
          d += `${open ? 'L' : 'M'}${X(i).toFixed(1)} ${Y(v).toFixed(1)}`;
          open = true;
        });
        return (
          <g key={l.label}>
            <path d={d} fill="none" stroke={l.color} strokeWidth={l.weight || 2.6}
              strokeLinejoin="round" strokeDasharray={l.dash || undefined} vectorEffect="non-scaling-stroke" />
            {!share && (
              <text x={li % 2 ? W : 0} y={li % 2 ? h + 22 : h + 22} textAnchor={li % 2 ? 'end' : 'start'}
                fill={l.color} fontSize="11" opacity="0">{l.label}</text>
            )}
          </g>
        );
      })}
    </svg>
  );
}

function ChartLegend({ lines, note }) {
  return (
    <>
      <div className="legend">
        {lines.map((l) => (
          <span key={l.label}>
            <i style={{ background: l.color, ...(l.dash ? { height: 0, borderTop: `3px dashed ${l.color}` } : {}) }} />
            {l.label}{l.scale ? ` (${l.scale})` : ''}
          </span>
        ))}
      </div>
      {note && <p className="tiny" style={{ marginTop: 8 }}>{note}</p>}
    </>
  );
}

/* =====================================================================
   9. SCREEN: HOME
   ===================================================================== */

function Home({ selected, setSelected, go }) {
  const data = useData();
  const c = byId(selected);
  const o = data.oews[c.soc] || {};
  return (
    <div className="stack">
      <section className="hero">
        <div className="stack">
          <div>
            <p className="eyebrow">Explore · Understand · Decide</p>
            <h1 className="h-xl">Career<br />Futures</h1>
            <p className="lede" style={{ marginTop: 12, maxWidth: '46ch' }}>
              A lab for testing how exposed a career is to AI and automation — using live
              U.S. Bureau of Labor Statistics data, published research that disagrees with
              itself, and your own judgement about the work.
            </p>
          </div>
          <div className="speech">
            <p style={{ fontSize: '.94rem' }}>
              <b style={{ color: 'var(--mint)' }}>Exposure is not elimination.</b> A task being
              automatable does not mean a job disappears — it usually means the job changes shape,
              and the argument is about who absorbs the change.
            </p>
          </div>
          <div className="chipset">
            <button className="btn" onClick={() => go('career')}>
              Open the career file <span className="knob"><I.arrow /></span>
            </button>
            <button className="btn ghost" onClick={() => go('evidence')}>What the research says</button>
          </div>
        </div>
        <div className="hero-art">
          <img src="assets/art/hero-explore.png" alt="A student with a magnifying glass and a robot holding a Future Careers report." />
        </div>
      </section>

      <section className="stack">
        <div style={{ display: 'flex', alignItems: 'flex-end', gap: 16, flexWrap: 'wrap' }}>
          <div style={{ flex: 1, minWidth: 200 }}>
            <p className="eyebrow">Step one</p>
            <h2 className="h-lg">Pick a career</h2>
          </div>
          <a className="btn sm dark" href="drum.html">
            Open the big drum <span className="knob"><I.arrowUp /></span>
          </a>
        </div>
        <Drum selected={selected} onSelect={setSelected} />
      </section>

      <section className="panel stack">
        <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
          <span style={{ width: 44, height: 44, borderRadius: '50%', background: 'var(--mustard)', color: 'var(--ink)', display: 'grid', placeItems: 'center', flex: 'none' }}>
            {React.createElement(I[c.icon], { style: { width: 22, height: 22 } })}
          </span>
          <div style={{ flex: 1, minWidth: 180 }}>
            <p className="eyebrow">Selected career</p>
            <h3 className="h-md">{c.title}</h3>
          </div>
          <button className="btn sm" onClick={() => go('career')}>Open file <span className="knob"><I.arrow /></span></button>
        </div>
        <div className="statgrid">
          <div className="stat">
            <div className="k">People employed</div>
            <div className="v">{fmtInt(o.emp)}</div>
            <div className="n">BLS OEWS, {OEWS_PERIOD}</div>
          </div>
          <div className="stat">
            <div className="k">Median annual pay</div>
            <div className="v">{fmtUSD(o.med)}</div>
            <div className="n">BLS OEWS, {OEWS_PERIOD}</div>
          </div>
          <div className="stat">
            <div className="k">SOC code</div>
            <div className="v" style={{ fontSize: '1.4rem' }}>{c.soc.slice(0, 2)}-{c.soc.slice(2)}</div>
            <div className="n">{c.full}</div>
          </div>
        </div>
      </section>

      <section className="stack">
        <div>
          <p className="eyebrow">What you will do</p>
          <h2 className="h-lg">Seven steps</h2>
        </div>
        <div className="evgrid">
          {[
            ['Pick a career', 'Spin the drum, or take the big one.', 'careers', 'var(--mustard)'],
            ['Predict, then look', 'Commit to a guess before the real employment data appears.', 'eye', 'var(--violet)'],
            ['Build your own estimate', 'Sort the job\'s actual tasks. Your sort is the score.', 'bars', 'var(--mint)'],
            ['Read the research', 'Six real sources that disagree with each other.', 'resources', 'var(--coral)'],
            ['Pressure-test the claims', 'Check four headlines against numbers the government publishes.', 'scales', 'var(--violet-2)'],
            ['Make the decision', 'Run a workplace through automation. Nothing is free.', 'robot', 'var(--mustard)'],
            ['Write it up and export', 'One card, with its sources attached.', 'pencil', 'var(--mint)'],
          ].map(([t, d, ic, col], i) => (
            <div key={t} className="panel" style={{ display: 'flex', gap: 14, alignItems: 'flex-start' }}>
              <span style={{ width: 42, height: 42, borderRadius: '50%', background: col, color: 'var(--ink)', display: 'grid', placeItems: 'center', flex: 'none' }}>
                {React.createElement(I[ic], { style: { width: 21, height: 21 } })}
              </span>
              <div>
                <p className="eyebrow" style={{ color: 'var(--muted)' }}>Step {i + 1}</p>
                <b style={{ fontFamily: 'var(--head)', textTransform: 'uppercase', letterSpacing: '.04em', fontSize: '1.05rem' }}>{t}</b>
                <p className="tiny" style={{ color: '#c6c0b3', marginTop: 4 }}>{d}</p>
              </div>
            </div>
          ))}
        </div>
      </section>

      <section className="notice">
        <b>How this lab handles numbers.</b> Employment, pay, unemployment, job openings and productivity
        all come from the BLS API and are labelled with their survey and date. Exposure is <i>not</i> a BLS
        statistic — nobody publishes an official one. You build it yourself by sorting the actual tasks,
        then compare your result against what published research suggests.
      </section>
    </div>
  );
}

/* =====================================================================
   10. SCREEN: CAREER FILE (live data + task sort)
   ===================================================================== */

const GUESSES = [
  { k: 'downLot', label: 'Fell a lot', hint: 'more than 5% down', lo: -Infinity, hi: -5 },
  { k: 'down', label: 'Fell a little', hint: '1–5% down', lo: -5, hi: -1 },
  { k: 'flat', label: 'Barely moved', hint: 'within 1%', lo: -1, hi: 1 },
  { k: 'up', label: 'Grew a little', hint: '1–5% up', lo: 1, hi: 5 },
  { k: 'upLot', label: 'Grew a lot', hint: 'more than 5% up', lo: 5, hi: Infinity },
];
const guessOf = (v) => GUESSES.find((g) => v >= g.lo && v < g.hi) || GUESSES[2];

function TrendPanel({ career, guess, setGuess }) {
  const data = useData();
  const s = data.ces[career.ind];
  const total = data.ces.CES0000000001;
  if (!s) return null;
  const last = s.length - 1;
  const gpt = idxOfMonth(2022, 11);
  const chg = (arr, from) => arr[from] ? ((arr[arr.length - 1] - arr[from]) / arr[from]) * 100 : 0;
  const since19 = chg(s, 0), sinceGpt = chg(s, gpt);
  const tSince19 = chg(total, 0), tSinceGpt = chg(total, gpt);
  const rel = sinceGpt - tSinceGpt;
  const truth = guessOf(sinceGpt);
  const right = guess === truth.k;

  /* Commit to an answer before the chart appears. Guessing first is what
     makes the real number stick — and being wrong is the useful outcome. */
  if (!guess) {
    return (
      <div className="sparkbox stack">
        <div>
          <p className="eyebrow">Before you look</p>
          <h3 className="h-md" style={{ marginTop: 4 }}>Make a prediction</h3>
        </div>
        <p style={{ fontSize: '.98rem' }}>
          Generative AI reached the public in November 2022. Since then, what do you think
          happened to employment in <b>{career.indName.toLowerCase()}</b> — the industry where
          many {career.title.toLowerCase()}s work?
        </p>
        <div className="chipset">
          {GUESSES.map((g) => (
            <button key={g.k} className="chip" onClick={() => setGuess(g.k)}>
              {g.label} <span style={{ opacity: .65, fontWeight: 500 }}>· {g.hint}</span>
            </button>
          ))}
        </div>
        <p className="tiny">Pick one and the real Bureau of Labor Statistics figure appears.</p>
      </div>
    );
  }

  return (
    <div className="sparkbox stack">
      <div className="notice" style={right
        ? { background: 'rgba(191,215,204,.12)', borderColor: 'rgba(191,215,204,.4)', color: '#dcece5' }
        : undefined}>
        <b>{right ? 'You called it.' : 'Not quite.'}</b>{' '}
        You said <b>{GUESSES.find((g) => g.k === guess).label.toLowerCase()}</b>; it actually{' '}
        <b>{truth.label.toLowerCase()}</b> — {pct(sinceGpt)} since November 2022.
        {!right && ' Worth asking where your expectation came from.'}
      </div>
      <div>
        <p className="eyebrow">Industry employment · monthly</p>
        <h3 className="h-md" style={{ marginTop: 4 }}>{career.indName}</h3>
      </div>
      <Spark series={s} lastIdx={last} color="#BFD7CC" />
      <div className="statgrid">
        <div className={'stat ' + (since19 >= 0 ? 'up' : 'down')}>
          <div className="k">Since Jan 2019</div>
          <div className="v">{pct(since19)}</div>
          <div className="n">Total nonfarm: {pct(tSince19)}</div>
        </div>
        <div className={'stat ' + (sinceGpt >= 0 ? 'up' : 'down')}>
          <div className="k">Since Nov 2022</div>
          <div className="v">{pct(sinceGpt)}</div>
          <div className="n">Total nonfarm: {pct(tSinceGpt)}</div>
        </div>
        <div className={'stat ' + (rel >= 0 ? 'up' : 'down')}>
          <div className="k">Vs. the whole economy</div>
          <div className="v">{pct(rel)}</div>
          <div className="n">Gap since generative AI went public</div>
        </div>
      </div>
      <p className="tiny">
        Source: BLS Current Employment Statistics, series <code>{career.ind}</code>, seasonally adjusted,
        through {monthLabel(last)}. <b>This is an industry, not an occupation</b> — most people in this
        industry do a different job than {career.title.toLowerCase()}, and many {career.title.toLowerCase()}s
        work in other industries. Read it as context, not as a measurement of this occupation.
      </p>
    </div>
  );
}

function CareerScreen({ selected, setSelected, sorts, setSorts, guesses, setGuesses, go }) {
  const data = useData();
  const c = byId(selected);
  const o = data.oews[c.soc] || {};
  const sort = sorts[c.id] || {};
  const [revealed, setRevealed] = usePersist('revealed', {});
  const mix = mixOf(sort, c.tasks);
  const score = scoreOf(sort, c.tasks);
  const level = levelOf(score);
  const done = mix.done;
  const show = !!revealed[c.id];

  const setTask = (i, k) => {
    setSorts((s) => ({ ...s, [c.id]: { ...(s[c.id] || {}), [i]: s[c.id]?.[i] === k ? undefined : k } }));
  };
  const filled = c.tasks.filter((_, i) => sort[i]).length;

  return (
    <div className="stack">
      <header className="stack" style={{ gap: 8 }}>
        <p className="eyebrow">Career file</p>
        <h1 className="h-lg">{c.title}</h1>
        <p className="tiny">{c.full} · SOC {c.soc.slice(0, 2)}-{c.soc.slice(2)} · {c.cluster}</p>
      </header>

      <section className="statgrid">
        <div className="stat">
          <div className="k">People employed</div>
          <div className="v">{fmtInt(o.emp)}</div>
          <div className="n">BLS OEWS, {OEWS_PERIOD}, national</div>
        </div>
        <div className="stat">
          <div className="k">Median annual pay</div>
          <div className="v">{fmtUSD(o.med)}</div>
          <div className="n">Half earn more, half earn less</div>
        </div>
        <div className="stat">
          <div className="k">Share of all US jobs</div>
          <div className="v">{o.emp ? ((o.emp / (data.ces.CES0000000001.slice(-1)[0] * 1000)) * 100).toFixed(2) + '%' : '—'}</div>
          <div className="n">Against total nonfarm employment</div>
        </div>
      </section>

      <TrendPanel career={c} guess={guesses[c.id]}
        setGuess={(g) => setGuesses((s) => ({ ...s, [c.id]: g }))} />

      {c.named && (
        <div className="notice">
          <b>This occupation is named in the research.</b> {c.named}
        </div>
      )}

      <section className="stack">
        <div>
          <p className="eyebrow">Step two — you build the estimate</p>
          <h2 className="h-lg">Sort the tasks</h2>
          <p className="lede" style={{ marginTop: 8 }}>
            Nobody publishes an official AI-exposure score. So make one. For each task, decide
            whether AI could do it <b>without a person</b>, do it <b>with a person checking</b>,
            or whether it <b>needs a human</b>. Your sort produces your score.
          </p>
        </div>

        <div className="progressline" aria-hidden="true">
          <i style={{ width: `${(filled / c.tasks.length) * 100}%` }} />
        </div>
        <p className="tiny">{filled} of {c.tasks.length} tasks sorted</p>

        <div className="tasklist">
          {c.tasks.map((t, i) => (
            <div key={i} className={'taskrow' + (sort[i] ? ' done' : '')}>
              <p>{t}</p>
              <div className="sortbtns">
                {[['automated', 'Automated'], ['aiAssisted', 'AI-assisted'], ['humanLed', 'Human-led']].map(([k, lab]) => (
                  <button key={k} data-k={k} aria-pressed={sort[i] === k} onClick={() => setTask(i, k)}>{lab}</button>
                ))}
              </div>
            </div>
          ))}
        </div>
        <p className="tiny">
          Task statements are written for this lab in the style of O*NET work activities. They are
          teaching material chosen to make the judgement hard, not a complete job description.
        </p>
      </section>

      {filled > 0 && (
        <section className="readout">
          <div className={'gaugecard ' + (level === 'low' ? 'low' : level === 'moderate' ? 'mod' : 'high')}>
            <p className="eyebrow dark">Your exposure estimate</p>
            <h3 className="h-lg" style={{ marginTop: 6, marginBottom: 10 }}>{LEVEL_TEXT[level]}</h3>
            <Gauge score={score} />
            <p style={{ fontSize: '.86rem', marginTop: 10 }}>
              Automated tasks count 1, AI-assisted count ½, human-led count 0.
              {!done && <b> Sort all {c.tasks.length} to finish.</b>}
            </p>
          </div>
          <div className="donutcard">
            <p className="eyebrow" style={{ color: 'rgba(255,255,255,.7)' }}>Your task mix</p>
            <h3 className="h-md" style={{ marginTop: 6, marginBottom: 12 }}>How you split the work</h3>
            <Donut mix={mix} />
            <div style={{ marginTop: 14 }}>
              {[['Automated', mix.automated, '#FF6658'], ['AI-assisted', mix.aiAssisted, '#F6C94C'], ['Human-led', mix.humanLed, '#BFD7CC']].map(([l, v, col]) => (
                <div className="mixrow" key={l}>
                  <span className="sw" style={{ background: col }} />
                  <b>{l}</b><span className="pc">{v}%</span>
                </div>
              ))}
            </div>
          </div>
        </section>
      )}

      {done && (
        <section className="deltabox">
          {!show ? (
            <>
              <h3 className="h-md">Now compare it</h3>
              <p style={{ fontSize: '.92rem' }}>
                You have committed to a number. Published exposure research suggests a band for
                this occupation. Reveal it — then decide who you think is closer, and why.
              </p>
              <div><button className="btn" onClick={() => setRevealed((r) => ({ ...r, [c.id]: true }))}>
                Reveal the research band <span className="knob"><I.eye /></span>
              </button></div>
            </>
          ) : (
            <>
              <h3 className="h-md">You vs. the research</h3>
              <div className="bandbar" role="img" aria-label={`Your score ${score}. Research band ${c.band[0]} to ${c.band[1]}.`}>
                <div style={{
                  position: 'absolute', top: 0, bottom: 0,
                  left: `${c.band[0]}%`, width: `${c.band[1] - c.band[0]}%`,
                  background: 'rgba(22,22,22,.55)', border: '2px solid #6E69F8', borderRadius: 8,
                }} />
                <div className="me" style={{ left: `calc(${clamp(score, 1, 99)}% - 2px)` }} />
              </div>
              <div className="bandkey">
                <span><i style={{ background: '#161616', boxShadow: '0 0 0 2px #FFF7E7' }} />Your score: <b>{score}</b></span>
                <span><i style={{ background: '#6E69F8' }} />Research band: <b>{c.band[0]}–{c.band[1]}</b> ({c.bandLabel.toLowerCase()} exposure)</span>
                <span>Gap: <b>{score < c.band[0] ? c.band[0] - score + ' below' : score > c.band[1] ? score - c.band[1] + ' above' : 'inside the band'}</b></span>
              </div>
              <p style={{ fontSize: '.92rem' }}>{c.bandWhy}</p>
              <p className="tiny">
                <b>What this band is:</b> a range this lab assigns by reading published task-exposure
                work (Felten et al.; Eloundou et al.; the Stanford payroll analysis) alongside the task
                list above. It is an argument, not a measurement. No agency publishes an official
                exposure score, and if one did you should still interrogate it.
              </p>
              <p style={{ fontSize: '.92rem', borderTop: '1px solid rgba(255,247,231,.12)', paddingTop: 12 }}>
                <b style={{ color: 'var(--mustard)' }}>What AI means here:</b> {c.aiNote}
              </p>
            </>
          )}
        </section>
      )}

      <section className="readout">
        <div className="panel stack">
          <div><p className="eyebrow">The human edge</p><h3 className="h-md" style={{ marginTop: 4 }}>What is hard to hand over</h3></div>
          {c.edge.map(([l, n], i) => (
            <div key={l} style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
              <span style={{ width: 36, height: 36, borderRadius: '50%', background: ['var(--mint)', 'var(--mustard)', 'var(--coral)'][i], color: 'var(--ink)', display: 'grid', placeItems: 'center', flex: 'none' }}>
                {React.createElement([I.head, I.heart, I.medal][i], { style: { width: 19, height: 19 } })}
              </span>
              <div><b style={{ fontFamily: 'var(--head)', textTransform: 'uppercase', letterSpacing: '.05em' }}>{l}</b>
                <p className="tiny" style={{ color: '#c6c0b3' }}>{n}</p></div>
            </div>
          ))}
        </div>
        <div className="panel stack">
          <div><p className="eyebrow">If you wanted this job</p><h3 className="h-md" style={{ marginTop: 4 }}>What to build next</h3></div>
          {c.next.map((n, i) => (
            <div key={i} style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
              <span style={{ width: 30, height: 30, borderRadius: '50%', background: 'var(--mustard)', color: 'var(--ink)', display: 'grid', placeItems: 'center', flex: 'none', fontWeight: 900, fontFamily: 'var(--head)' }}>{i + 1}</span>
              <p style={{ fontSize: '.94rem' }}>{n}</p>
            </div>
          ))}
          <img src="assets/art/hero-route.png" alt="" style={{ width: '100%', maxWidth: 220, alignSelf: 'center', marginTop: 6, mixBlendMode: 'lighten' }} />
        </div>
      </section>

      <div className="chipset">
        <button className="btn" onClick={() => go('evidence')}>Read the evidence <span className="knob"><I.arrow /></span></button>
        <button className="btn ghost" onClick={() => go('compare')}>Compare with another career</button>
      </div>
    </div>
  );
}

/* =====================================================================
   11. SCREEN: COMPARE
   ===================================================================== */

function CompareCol({ id, other, sorts, tone }) {
  const data = useData();
  const c = byId(id);
  const o = data.oews[c.soc] || {};
  const s = sorts[c.id] || {};
  const score = scoreOf(s, c.tasks);
  const mix = mixOf(s, c.tasks);
  const series = data.ces[c.ind];
  const gpt = idxOfMonth(2022, 11);
  const sinceGpt = series && series[gpt] ? ((series[series.length - 1] - series[gpt]) / series[gpt]) * 100 : null;
  return (
    <div className="card" style={{ background: tone === 'a' ? 'var(--mustard)' : 'var(--violet)', color: tone === 'a' ? 'var(--ink)' : '#fff', display: 'flex', flexDirection: 'column' }}>
      <div style={{ padding: '20px 20px 8px', display: 'flex', alignItems: 'center', gap: 10 }}>
        <h3 className="h-md" style={{ flex: 1 }}>{c.title}</h3>
        {React.createElement(I[c.icon], { style: { width: 26, height: 26 } })}
      </div>
      <div style={{ background: tone === 'a' ? 'rgba(255,247,231,.62)' : 'rgba(255,255,255,.14)', padding: 20, display: 'grid', gap: 12 }}>
        <div>
          <p className="eyebrow dark" style={{ color: tone === 'a' ? 'rgba(22,22,22,.6)' : 'rgba(255,255,255,.7)' }}>Bureau of Labor Statistics</p>
          <p style={{ fontSize: '.9rem', marginTop: 6 }}>
            <b>{fmtInt(o.emp)}</b> employed · median <b>{fmtUSD(o.med)}</b>
          </p>
          <p style={{ fontSize: '.82rem', opacity: .8, marginTop: 4 }}>
            {c.indName}: <b>{sinceGpt == null ? '—' : pct(sinceGpt)}</b> since Nov 2022
          </p>
        </div>
        <div>
          <p className="eyebrow dark" style={{ color: tone === 'a' ? 'rgba(22,22,22,.6)' : 'rgba(255,255,255,.7)' }}>Your exposure sort</p>
          {score == null
            ? <p style={{ fontSize: '.9rem', marginTop: 6 }}>Not sorted yet — open this career file and sort its tasks.</p>
            : (
              <>
                <p style={{ fontFamily: 'var(--head)', fontWeight: 900, fontSize: '2.2rem', lineHeight: 1, marginTop: 6 }}>
                  {score}<span style={{ fontSize: '1rem' }}> / 100</span>
                </p>
                <p style={{ fontSize: '.86rem', marginTop: 4 }}>{LEVEL_TEXT[levelOf(score)]}</p>
                <div style={{ display: 'flex', height: 10, borderRadius: 99, overflow: 'hidden', marginTop: 10 }}>
                  <span style={{ width: mix.automated + '%', background: '#FF6658' }} />
                  <span style={{ width: mix.aiAssisted + '%', background: '#F6C94C' }} />
                  <span style={{ width: mix.humanLed + '%', background: '#BFD7CC' }} />
                </div>
              </>
            )}
        </div>
        <div>
          <p className="eyebrow dark" style={{ color: tone === 'a' ? 'rgba(22,22,22,.6)' : 'rgba(255,255,255,.7)' }}>Research band</p>
          <p style={{ fontSize: '.9rem', marginTop: 6 }}><b>{c.band[0]}–{c.band[1]}</b> — {c.bandLabel.toLowerCase()} exposure</p>
        </div>
      </div>
      <div style={{ background: 'var(--ink)', color: 'var(--cream)', padding: 20, flex: 1 }}>
        <p className="eyebrow">What AI means here</p>
        <p style={{ fontSize: '.88rem', marginTop: 6 }}>{c.aiNote}</p>
      </div>
    </div>
  );
}

function Compare({ sorts, cmp, setCmp }) {
  const data = useData();
  const a = byId(cmp[0]), b = byId(cmp[1]);
  const sa = scoreOf(sorts[a.id] || {}, a.tasks), sb = scoreOf(sorts[b.id] || {}, b.tasks);
  const oa = data.oews[a.soc] || {}, ob = data.oews[b.soc] || {};
  return (
    <div className="stack">
      <header>
        <p className="eyebrow">Side by side</p>
        <h1 className="h-lg">Compare careers</h1>
        <p className="lede" style={{ marginTop: 8 }}>Two occupations, the same questions. The differences are the argument.</p>
      </header>

      <div className="panel" style={{ display: 'grid', gap: 12, gridTemplateColumns: '1fr auto 1fr', alignItems: 'center' }}>
        <select value={cmp[0]} onChange={(e) => setCmp([e.target.value, cmp[1]])}
          className="txtin" aria-label="First career">
          {CAREERS.map((c) => <option key={c.id} value={c.id}>{c.title}</option>)}
        </select>
        <span style={{ fontFamily: 'var(--head)', fontWeight: 900, color: 'var(--muted)' }}>VS</span>
        <select value={cmp[1]} onChange={(e) => setCmp([cmp[0], e.target.value])}
          className="txtin" aria-label="Second career">
          {CAREERS.map((c) => <option key={c.id} value={c.id}>{c.title}</option>)}
        </select>
      </div>

      <div className="readout">
        <CompareCol id={cmp[0]} other={cmp[1]} sorts={sorts} tone="a" />
        <CompareCol id={cmp[1]} other={cmp[0]} sorts={sorts} tone="b" />
      </div>

      <section className="panel stack">
        <div style={{ display: 'flex', gap: 14, alignItems: 'center', flexWrap: 'wrap' }}>
          <span style={{ width: 46, height: 46, borderRadius: '50%', background: 'var(--mint)', color: 'var(--ink)', display: 'grid', placeItems: 'center', flex: 'none' }}>
            <I.scales style={{ width: 24, height: 24 }} />
          </span>
          <div style={{ flex: 1, minWidth: 200 }}>
            <p className="eyebrow">Reading the difference</p>
            <h3 className="h-md">What actually separates them</h3>
          </div>
        </div>
        <ul style={{ margin: 0, paddingLeft: 20, display: 'grid', gap: 9, fontSize: '.93rem' }}>
          <li>
            <b>Pay gap:</b> {oa.med && ob.med
              ? `${a.title} ${fmtUSD(oa.med)} vs ${b.title} ${fmtUSD(ob.med)} — a difference of ${fmtUSD(Math.abs(oa.med - ob.med))} a year.`
              : 'Pay data unavailable.'} Pay is a signal about scarcity and responsibility, not about safety from automation.
          </li>
          <li>
            <b>Size:</b> {a.title} employs {fmtInt(oa.emp)}; {b.title} employs {fmtInt(ob.emp)}
            {oa.emp && ob.emp && Math.max(oa.emp, ob.emp) / Math.min(oa.emp, ob.emp) > 1.5
              ? ` — about ${(Math.max(oa.emp, ob.emp) / Math.min(oa.emp, ob.emp)).toFixed(1)}× the difference.`
              : ' — roughly the same size.'}{' '}
            Size decides whether a change shows up in a national statistic at all. A small occupation
            can be completely rebuilt without moving any headline number.
          </li>
          <li>
            <b>Your exposure sorts:</b> {sa != null && sb != null
              ? `${sa} vs ${sb}. ${Math.abs(sa - sb) < 10 ? 'You judged these very similarly — is that right, or is the task list hiding the difference?' : `You judged ${sa > sb ? a.title : b.title} substantially more exposed.`}`
              : 'Sort both careers\' tasks to compare your own estimates.'}
          </li>
          <li>
            <b>The trap:</b> a high exposure score is not a prediction of unemployment. It is a
            statement about tasks. Whether the job survives depends on regulation, liability,
            cost, trust and who has power to decide — none of which are in the score.
          </li>
        </ul>
      </section>
    </div>
  );
}

/* =====================================================================
   12. SCREEN: EVIDENCE DESK
   ===================================================================== */

function EvidenceCard({ e, verdict, setVerdict, note, setNote }) {
  const Icon = I[e.icon];
  return (
    <article className="evcard">
      <div className="src">
        <span className="badge" style={{ background: e.tone, color: e.tone === '#BFD7CC' || e.tone === '#F6C94C' || e.tone === '#DDEAE4' ? '#161616' : '#fff' }}>
          <Icon />
        </span>
        <div>
          <p className="kind">{e.kind}</p>
          <p style={{ fontSize: '.86rem', fontWeight: 700 }}>{e.org}</p>
        </div>
      </div>
      <h3>{e.title}</h3>
      <p className="tiny">{e.who}</p>
      <p className="claim">{e.claim}</p>
      <div className="twocol">
        <div className="showsbox yes"><b>What it can show</b>{e.shows}</div>
        <div className="showsbox no"><b>What it cannot show</b>{e.limits}</div>
      </div>
      <div className="stack" style={{ gap: 9 }}>
        <p className="eyebrow">Your verdict</p>
        <div className="chipset">
          {VERDICTS.map((v) => (
            <button key={v.k} className={v.cls} aria-pressed={verdict === v.k}
              onClick={() => setVerdict(verdict === v.k ? null : v.k)}>{v.label}</button>
          ))}
        </div>
        <textarea rows="2" placeholder="What would change your mind about this source?"
          value={note || ''} onChange={(e2) => setNote(e2.target.value)} />
      </div>
      <p className="srcline">
        <I.arrowUp style={{ width: 12, height: 12 }} />
        <a href={e.url} target="_blank" rel="noopener noreferrer">Read the source</a>
      </p>
    </article>
  );
}

function Evidence({ verdicts, setVerdicts, notes, setNotes, go }) {
  const answered = EVIDENCE.filter((e) => verdicts[e.id]).length;
  return (
    <div className="stack">
      <header>
        <p className="eyebrow">Step three</p>
        <h1 className="h-lg">The evidence desk</h1>
        <p className="lede" style={{ marginTop: 8, maxWidth: '62ch' }}>
          Six real sources on AI and work. They do not agree. That is not a flaw in the research —
          it is what an unsettled question looks like while it is still being settled. Read each one,
          note what it can and cannot show, and record a verdict.
        </p>
      </header>

      <div className="notice">
        <b>Before you start.</b> Three things go wrong when people read this literature.
        <b> Exposure ≠ elimination</b> — a task score is not a job forecast.
        <b> Postings ≠ employment</b> — job-ad counts move for many reasons.
        <b> Correlation ≠ cause</b> — the same years contain interest-rate rises and a tech correction.
      </div>

      <div className="progressline" aria-hidden="true"><i style={{ width: `${(answered / EVIDENCE.length) * 100}%` }} /></div>
      <p className="tiny">{answered} of {EVIDENCE.length} sources judged</p>

      <div className="evgrid">
        {EVIDENCE.map((e) => (
          <EvidenceCard key={e.id} e={e}
            verdict={verdicts[e.id]}
            setVerdict={(v) => setVerdicts((s) => ({ ...s, [e.id]: v }))}
            note={notes[e.id]}
            setNote={(v) => setNotes((s) => ({ ...s, [e.id]: v }))}
          />
        ))}
      </div>

      <section className="panel stack">
        <div style={{ display: 'flex', gap: 14, alignItems: 'center' }}>
          <img src="assets/svg/props/balance-scale.svg" alt="" width="52" height="52" style={{ filter: 'invert(1) brightness(1.6)' }} />
          <div>
            <p className="eyebrow">Holding two things at once</p>
            <h3 className="h-md">The honest summary</h3>
          </div>
        </div>
        <p style={{ fontSize: '.95rem' }}>
          The best current reading is that there is <b>no economy-wide AI jobs collapse</b> in the national
          statistics, <b>and</b> there is a <b>real, measured, age-specific squeeze</b> at the entry level in the
          most automatable occupations. Both can be true, because national averages are made of millions of
          people and the effect is concentrated in a few hundred thousand of them — the ones just starting out.
        </p>
        <p style={{ fontSize: '.95rem' }}>
          Which matters more to you depends on where you are standing. If you are twenty-two and applying for
          your first job in an exposed occupation, the national average is not the number that describes your life.
        </p>
        <div><button className="btn" onClick={() => go('test')}>Now check it against the numbers <span className="knob"><I.arrow /></span></button></div>
      </section>
    </div>
  );
}

/* =====================================================================
   12b. SCREEN: PRESSURE TEST — claims vs the official numbers
   ===================================================================== */

function ClaimCard({ claim, children, verdict, setVerdict }) {
  return (
    <article className="evcard" style={{ borderLeft: `6px solid ${claim.tone}` }}>
      <div>
        <p className="kind">You have heard this · {claim.heard}</p>
        <h3 style={{ fontSize: '1.35rem', marginTop: 6, color: claim.tone }}>&ldquo;{claim.claim}&rdquo;</h3>
      </div>
      <p className="tiny"><b>What we can check:</b> {claim.check}</p>
      {children}
      <div className="stack" style={{ gap: 9 }}>
        <p className="eyebrow">Does the data support the claim?</p>
        <div className="chipset">
          {[['supports', 'Supports it'], ['partly', 'Partly'], ['against', 'Cuts against it'], ['cant', 'Cannot tell']].map(([k, l]) => (
            <button key={k} className={'chip' + (k === 'against' ? ' coral' : k === 'partly' ? ' mustard' : k === 'cant' ? ' violet' : '')}
              aria-pressed={verdict === k} onClick={() => setVerdict(verdict === k ? null : k)}>{l}</button>
          ))}
        </div>
      </div>
      <p className="srcline">Source: {claim.source}</p>
    </article>
  );
}

function PressureTest({ verdicts, setVerdicts, go }) {
  const data = useData();
  const wiki = useWiki();
  const E = data.extra || EXTRA_SNAPSHOT;
  const gpt = idxOfMonth(2022, 11);
  const set = (id, v) => setVerdicts((s) => ({ ...s, [id]: v }));

  const young = E[CPS_YOUNG] || [], prime = E[CPS_PRIME] || [];
  const gapAt = (i) => (young[i] != null && prime[i] != null) ? young[i] - prime[i] : null;
  const gapNow = gapAt(young.length - 1), gapGpt = gapAt(gpt);
  const gapPre = gapAt(gpt - 36);
  const gapSeries = young.map((v, i) => gapAt(i));

  const open = E[JOLTS_OPEN] || [], hire = E[JOLTS_HIRE] || [];
  const openPeak = Math.max(...open), openPeakAt = open.indexOf(openPeak);
  const openNow = open[open.length - 1];
  const total = data.ces.CES0000000001;

  const prod = E[PROD_IDX] || [];
  const qIdx = (y, q) => (y - CES_MONTH0[0]) * 4 + (q - 1);
  const preA = prod[0], preB = prod[qIdx(2022, 4)];
  const postA = prod[qIdx(2022, 4)], postB = prod[prod.length - 1];
  const annual = (a, b, quarters) => (Math.pow(b / a, 4 / quarters) - 1) * 100;
  const preRate = annual(preA, preB, qIdx(2022, 4));
  const postRate = annual(postA, postB, prod.length - 1 - qIdx(2022, 4));

  const views = wiki.views || WIKI_SNAPSHOT;
  const gptViews = views.ChatGPT || [];
  const vSeen = gptViews.filter((v) => v != null);
  const vPeak = vSeen.length ? Math.max(...vSeen) : 0;
  /* The live array is padded to a fixed length, so the last slot is usually
     empty — take the last month that actually reported. */
  const vLast = vSeen.length ? vSeen[vSeen.length - 1] : 0;
  const answered = CLAIMS.filter((c) => verdicts[c.id]).length;

  return (
    <div className="stack">
      <header>
        <p className="eyebrow">Step four</p>
        <h1 className="h-lg">Pressure test</h1>
        <p className="lede" style={{ marginTop: 8, maxWidth: '64ch' }}>
          Four claims you have almost certainly heard about AI and work. Each one is checked
          here against a number the U.S. government publishes, pulled live. Some hold up.
          Some do not. Some cannot be settled this way at all — and noticing which is which
          is the whole skill.
        </p>
      </header>

      <div className="progressline" aria-hidden="true"><i style={{ width: `${(answered / CLAIMS.length) * 100}%` }} /></div>
      <p className="tiny">{answered} of {CLAIMS.length} claims tested</p>

      <div className="evgrid">
        {/* ---------- 1. young workers ---------- */}
        <ClaimCard claim={CLAIMS[0]} verdict={verdicts.young} setVerdict={(v) => set('young', v)}>
          <MultiSpark share lines={[
            { label: 'Aged 20–24', color: '#FF6658', data: young },
            { label: 'Aged 25 and over', color: '#BFD7CC', data: prime },
          ]} />
          <ChartLegend lines={[
            { label: 'Unemployment rate, 20–24', color: '#FF6658' },
            { label: 'Unemployment rate, 25+', color: '#BFD7CC' },
          ]} />
          <div className="statgrid">
            <div className="stat"><div className="k">Gap, 3 yrs before ChatGPT</div><div className="v">{gapPre != null ? gapPre.toFixed(1) + ' pts' : '—'}</div><div className="n">Nov 2019</div></div>
            <div className="stat"><div className="k">Gap at ChatGPT</div><div className="v">{gapGpt != null ? gapGpt.toFixed(1) + ' pts' : '—'}</div><div className="n">Nov 2022</div></div>
            <div className={'stat ' + (gapNow > gapGpt ? 'down' : 'up')}><div className="k">Gap now</div><div className="v">{gapNow != null ? gapNow.toFixed(1) + ' pts' : '—'}</div><div className="n">{monthLabel(young.length - 1)}</div></div>
          </div>
          <div className="twocol">
            <div className="showsbox yes"><b>What holds up</b>Young workers really are unemployed at roughly twice the rate of everyone else — and they always have been. The rate for 20–24s rose through 2024 and 2025.</div>
            <div className="showsbox no"><b>What does not</b>The gap has not opened dramatically since 2022; it was already this wide in 2019. This national average cannot isolate AI, which is precisely why the Stanford team went to payroll records for specific occupations instead.</div>
          </div>
        </ClaimCard>

        {/* ---------- 2. openings ---------- */}
        <ClaimCard claim={CLAIMS[1]} verdict={verdicts.openings} setVerdict={(v) => set('openings', v)}>
          <MultiSpark lines={[
            { label: 'Job openings', color: '#FF6658', data: open },
            { label: 'Employment', color: '#BFD7CC', data: total, dash: '5 4', weight: 2 },
          ]} />
          <ChartLegend lines={[
            { label: 'Job openings', color: '#FF6658', scale: 'own scale' },
            { label: 'Total employment', color: '#BFD7CC', scale: 'own scale' },
          ]} note="Two different measures on one time axis, each on its own scale — the shapes are the point, not the heights." />
          <div className="statgrid">
            <div className="stat"><div className="k">Openings at peak</div><div className="v">{(openPeak / 1000).toFixed(1)}M</div><div className="n">{monthLabel(openPeakAt)}</div></div>
            <div className="stat down"><div className="k">Openings now</div><div className="v">{(openNow / 1000).toFixed(1)}M</div><div className="n">{pct(((openNow - openPeak) / openPeak) * 100, 0)} from peak</div></div>
            <div className="stat up"><div className="k">People employed</div><div className="v">{pct(((total[total.length - 1] - total[gpt]) / total[gpt]) * 100)}</div><div className="n">Since Nov 2022</div></div>
          </div>
          <div className="twocol">
            <div className="showsbox yes"><b>What holds up</b>Openings really did collapse — down roughly {Math.round(((openPeak - openNow) / openPeak) * 100)}% from the 2022 peak, and the hires rate fell with them. Getting hired is genuinely harder.</div>
            <div className="showsbox no"><b>What does not</b>Employment kept rising through the same period, so jobs did not disappear — advertised jobs did. And the fall began in <b>{monthLabel(openPeakAt)}</b>, before ChatGPT existed, when interest rates were climbing.</div>
          </div>
        </ClaimCard>

        {/* ---------- 3. productivity ---------- */}
        <ClaimCard claim={CLAIMS[2]} verdict={verdicts.productivity} setVerdict={(v) => set('productivity', v)}>
          <MultiSpark quarterly lines={[{ label: 'Output per hour', color: '#F6C94C', data: prod }]} />
          <ChartLegend lines={[{ label: 'Output per hour, nonfarm business (2017 = 100)', color: '#F6C94C' }]} />
          <div className="statgrid">
            <div className="stat"><div className="k">Growth 2019 → 2022</div><div className="v">{preRate.toFixed(1)}%<span style={{ fontSize: '.8rem' }}>/yr</span></div><div className="n">Before ChatGPT</div></div>
            <div className="stat up"><div className="k">Growth since 2022</div><div className="v">{postRate.toFixed(1)}%<span style={{ fontSize: '.8rem' }}>/yr</span></div><div className="n">After ChatGPT</div></div>
            <div className="stat"><div className="k">Difference</div><div className="v">{(postRate - preRate >= 0 ? '+' : '') + (postRate - preRate).toFixed(1)}<span style={{ fontSize: '.8rem' }}> pts</span></div><div className="n">Annualised</div></div>
          </div>
          <div className="twocol">
            <div className="showsbox yes"><b>What holds up</b>Productivity growth has genuinely been faster since late 2022 than in the years before it. That acceleration is real and it is measured.</div>
            <div className="showsbox no"><b>What does not</b>&ldquo;Boom&rdquo; is doing a lot of work. A shift of about {Math.abs(postRate - preRate).toFixed(1)} points a year is modest next to the claims being made — and post-pandemic recovery, business investment and a changed workforce all sit in the same window.</div>
          </div>
        </ClaimCard>

        {/* ---------- 4. attention ---------- */}
        <ClaimCard claim={CLAIMS[3]} verdict={verdicts.attention} setVerdict={(v) => set('attention', v)}>
          <MultiSpark lines={[
            { label: 'ChatGPT article views', color: '#8C86FF', data: gptViews },
            { label: 'Total US employment', color: '#BFD7CC', data: total, dash: '5 4', weight: 2 },
          ]} />
          <ChartLegend lines={[
            { label: 'Wikipedia views, “ChatGPT”', color: '#8C86FF', scale: 'own scale' },
            { label: 'Total US employment', color: '#BFD7CC', scale: 'own scale' },
          ]} note={`Wikipedia data ${wiki.status === 'live' ? 'fetched live' : 'from the bundled snapshot'} via the Wikimedia REST API.`} />
          <div className="statgrid">
            <div className="stat"><div className="k">Peak monthly views</div><div className="v">{(vPeak / 1e6).toFixed(1)}M</div><div className="n">One article, one month</div></div>
            <div className="stat"><div className="k">Employment change</div><div className="v">{pct(((total[total.length - 1] - total[gpt]) / total[gpt]) * 100)}</div><div className="n">Same period</div></div>
            <div className="stat"><div className="k">Views now vs peak</div><div className="v">{vPeak ? Math.round((vLast / vPeak) * 100) : '—'}%</div><div className="n">Attention decays</div></div>
          </div>
          <div className="twocol">
            <div className="showsbox yes"><b>What holds up</b>Public attention did change overnight. One Wikipedia article went from nothing to {(vPeak / 1e6).toFixed(1)} million views in a month — a genuinely unusual event.</div>
            <div className="showsbox no"><b>What does not</b>Attention is not employment. The green line barely registers the moment the purple one explodes. How much you have heard about something is a poor guide to how much it has changed — and it is the main input most people are working from.</div>
          </div>
        </ClaimCard>
      </div>

      <section className="panel stack">
        <div>
          <p className="eyebrow">The habit worth keeping</p>
          <h3 className="h-md">Ask what would settle it</h3>
        </div>
        <p style={{ fontSize: '.95rem' }}>
          Each of these claims could in principle be true. What separates them is not how
          confident the person saying it sounds — it is whether the number they are pointing at
          can actually carry the weight. National averages hide subgroups. Job ads are not jobs.
          A window that contains a rate-rise cycle cannot tell you about AI on its own.
        </p>
        <p style={{ fontSize: '.95rem' }}>
          When you next read a headline about AI and work, the useful question is not
          &ldquo;is this true?&rdquo; but <b>&ldquo;what measurement would show me if it were false?&rdquo;</b>
        </p>
        <div><button className="btn" onClick={() => go('decide')}>Now make the decision yourself <span className="knob"><I.arrow /></span></button></div>
      </section>
    </div>
  );
}

/* =====================================================================
   12c. SCREEN: THE DECISION — a scenario with no free choices
   ===================================================================== */

function Meter({ label, note, value }) {
  const v = clamp(value, -6, 6);
  const pctW = (Math.abs(v) / 6) * 50;
  const good = v >= 0;
  return (
    <div style={{ display: 'grid', gap: 5 }}>
      <div style={{ display: 'flex', gap: 10, alignItems: 'baseline' }}>
        <b style={{ fontFamily: 'var(--head)', textTransform: 'uppercase', letterSpacing: '.05em', fontSize: '.95rem' }}>{label}</b>
        <span style={{ marginLeft: 'auto', fontFamily: 'var(--head)', fontWeight: 900, color: good ? 'var(--mint)' : 'var(--coral-2)' }}>
          {v > 0 ? '+' : ''}{v}
        </span>
      </div>
      <div style={{ position: 'relative', height: 14, borderRadius: 99, background: 'var(--charcoal-3)' }}>
        <span style={{ position: 'absolute', left: '50%', top: -3, bottom: -3, width: 2, background: 'rgba(255,247,231,.3)' }} />
        <span style={{
          position: 'absolute', top: 0, bottom: 0, borderRadius: 99,
          left: good ? '50%' : `${50 - pctW}%`, width: `${pctW}%`,
          background: good ? 'var(--mint)' : 'var(--coral)',
        }} />
      </div>
      <p className="tiny">{note}</p>
    </div>
  );
}

function Decide({ selected, decisions, setDecisions, go }) {
  const c = byId(selected);
  const picks = decisions[c.id] || {};
  const done = ROUNDS.every((r) => picks[r.id]);
  const meters = ROUNDS.reduce((acc, r) => {
    const opt = r.options.find((o) => o.k === picks[r.id]);
    if (opt) METERS.forEach((m) => { acc[m.k] += opt.d[m.k]; });
    return acc;
  }, { output: 0, jobs: 0, quality: 0, worker: 0 });
  const [head, body] = DECISION_VERDICT(meters);
  const answered = ROUNDS.filter((r) => picks[r.id]).length;
  const pick = (rid, k) => setDecisions((s) => ({ ...s, [c.id]: { ...(s[c.id] || {}), [rid]: k } }));

  return (
    <div className="stack">
      <section className="hero" style={{ paddingTop: 10 }}>
        <div className="stack">
          <div>
            <p className="eyebrow">Step five</p>
            <h1 className="h-lg">The decision</h1>
            <p className="lede" style={{ marginTop: 8 }}>
              Automation does not happen to a workplace. Somebody decides. For the next few
              minutes that somebody is you, and the workplace employs <b>{c.title.toLowerCase()}s</b>.
              Every option here is one a real organisation has chosen. None of them is free.
            </p>
          </div>
        </div>
        <div className="hero-art" style={{ maxWidth: 300 }}>
          <img src="assets/art/hero-plan.png" alt="A student and a robot working through a plan together." />
        </div>
      </section>

      <div className="progressline" aria-hidden="true"><i style={{ width: `${(answered / ROUNDS.length) * 100}%` }} /></div>
      <p className="tiny">{answered} of {ROUNDS.length} decisions made</p>

      {ROUNDS.map((r, i) => {
        const chosen = r.options.find((o) => o.k === picks[r.id]);
        const locked = i > 0 && !picks[ROUNDS[i - 1].id];
        return (
          <section key={r.id} className="panel stack" style={locked ? { opacity: .45 } : undefined}>
            <div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
              <span style={{
                width: 38, height: 38, borderRadius: '50%', flex: 'none', display: 'grid', placeItems: 'center',
                background: chosen ? 'var(--mint)' : 'var(--charcoal-3)', color: chosen ? 'var(--ink)' : 'var(--muted)',
                fontFamily: 'var(--head)', fontWeight: 900,
              }}>{i + 1}</span>
              <div>
                <p className="eyebrow">Round {i + 1}</p>
                <h3 className="h-md">{r.title}</h3>
              </div>
            </div>
            {locked ? (
              <p className="tiny">Make the previous decision first — the situation depends on it.</p>
            ) : (
              <>
                <p style={{ fontSize: '1rem' }}>{r.setup(c.title)}</p>
                <div className="stack" style={{ gap: 10 }}>
                  {r.options.map((o) => {
                    const on = picks[r.id] === o.k;
                    return (
                      <button key={o.k} onClick={() => pick(r.id, o.k)} aria-pressed={on}
                        style={{
                          textAlign: 'left', padding: '14px 16px', borderRadius: 'var(--r-md)',
                          border: on ? '2px solid var(--mustard)' : '2px solid rgba(255,247,231,.18)',
                          background: on ? 'rgba(246,201,76,.1)' : 'var(--charcoal-3)',
                          display: 'grid', gap: 8,
                        }}>
                        <b style={{ fontSize: '.98rem' }}>{o.label}</b>
                        {on && <span className="tiny" style={{ color: '#ded7c8' }}>{o.why}</span>}
                      </button>
                    );
                  })}
                </div>
                {chosen && (
                  <p className="tiny">
                    You can change your mind — nothing here is scored, and reconsidering is the point.
                  </p>
                )}
              </>
            )}
          </section>
        );
      })}

      {done && (
        <section className="deltabox">
          <div>
            <p className="eyebrow">Debrief</p>
            <h3 className="h-md" style={{ marginTop: 4 }}>{head}</h3>
          </div>
          <p style={{ fontSize: '.98rem' }}>{body}</p>
          <div className="stack" style={{ gap: 16, marginTop: 6 }}>
            {METERS.map((m) => <Meter key={m.k} label={m.label} note={m.note} value={meters[m.k]} />)}
          </div>
          <p style={{ fontSize: '.95rem', borderTop: '1px solid rgba(255,247,231,.12)', paddingTop: 14 }}>
            <b style={{ color: 'var(--mustard)' }}>The point of the exercise:</b> the tool was the
            same in every path. The difference in outcome came entirely from decisions people
            made about who absorbs the change. When you read that &ldquo;AI cut those jobs&rdquo;,
            somebody made a choice like the ones you just made — and could have chosen otherwise.
          </p>
          <div><button className="btn" onClick={() => go('reflect')}>Write it up <span className="knob"><I.pencil /></span></button></div>
        </section>
      )}
    </div>
  );
}

/* =====================================================================
   13. SCREEN: DECISION LAB
   ===================================================================== */

function Reflect({ selected, answers, setAnswers, conf, setConf, scenIdx, setScenIdx, go }) {
  const c = byId(selected);
  const a = answers[c.id] || {};
  const setA = (qid, v) => setAnswers((s) => ({ ...s, [c.id]: { ...(s[c.id] || {}), [qid]: v } }));
  const written = REFLECT_Q.filter((q) => (a[q.id] || '').trim().length > 3).length;

  return (
    <div className="stack">
      <section className="hero" style={{ paddingTop: 10 }}>
        <div className="stack">
          <div>
            <p className="eyebrow">Step six · Reflect · Question · Decide</p>
            <h1 className="h-lg">Decision Lab</h1>
            <p className="lede" style={{ marginTop: 8 }}>
              You have real numbers, your own task sort, and six sources that argue with each other.
              Now say what you think about <b>{c.title}</b> — and say what would change your mind.
            </p>
          </div>
        </div>
        <div className="hero-art" style={{ maxWidth: 340 }}>
          <img src="assets/art/hero-reflect.png" alt="A student writing in a notebook titled My Career Reflections beside a robot." />
        </div>
      </section>

      <section className="scenario">
        <p className="eyebrow" style={{ color: 'rgba(255,255,255,.75)' }}>Scenario</p>
        <p className="q">{SCENARIOS[scenIdx % SCENARIOS.length]}</p>
        <div className="chipset">
          <button className="btn dark" onClick={() => setScenIdx((i) => (i + 1) % SCENARIOS.length)}>
            Another scenario <span className="knob"><I.arrow /></span>
          </button>
        </div>
      </section>

      <div className="progressline" aria-hidden="true"><i style={{ width: `${(written / REFLECT_Q.length) * 100}%` }} /></div>
      <p className="tiny">{written} of {REFLECT_Q.length} answered</p>

      <section className="stack">
        {REFLECT_Q.map((q, i) => {
          const Icon = I[q.icon];
          return (
            <div className="qcard" key={q.id}>
              <div className="qhead">
                <span className="ico" style={{ background: q.tone }}><Icon /></span>
                <div>
                  <h3>{i + 1}. {q.q}</h3>
                </div>
              </div>
              <textarea rows="3" placeholder="Write your thoughts…"
                value={a[q.id] || ''} onChange={(e) => setA(q.id, e.target.value)} />
            </div>
          );
        })}
      </section>

      <section className="confbox">
        <div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
          <div style={{ flex: 1, minWidth: 200 }}>
            <h3 className="h-md">How informed is your judgement?</h3>
            <p style={{ fontSize: '.88rem', marginTop: 6 }}>Slide to rate your own confidence — honestly.</p>
          </div>
          <div style={{ width: 170, flex: 'none' }}><Gauge score={(conf - 1) * 25} size={170} /></div>
        </div>
        <input type="range" min="1" max="5" step="1" value={conf}
          onChange={(e) => setConf(Number(e.target.value))} aria-label="Confidence, 1 to 5" />
        <div className="conflabels"><span>1 · Not informed</span><span>3 · Somewhat</span><span>5 · Very</span></div>
        <p style={{ fontFamily: 'var(--head)', fontWeight: 900, fontSize: '1.4rem' }}>{conf} — {CONF_LABELS[conf]}</p>
      </section>

      <div className="chipset">
        <button className="btn" onClick={() => go('card')}>Build my card <span className="knob"><I.arrow /></span></button>
      </div>
    </div>
  );
}

/* =====================================================================
   14. EXPORT CARD
   ===================================================================== */

function buildCardHTML(ctx) {
  const { c, o, data, sort, score, mix, conf, answers, verdicts, notes, name, when,
    guess, claimVerdicts, picks } = ctx;
  const level = levelOf(score);
  const series = data.ces[c.ind];
  const gpt = idxOfMonth(2022, 11);
  const sinceGpt = series && series[gpt] ? ((series[series.length - 1] - series[gpt]) / series[gpt]) * 100 : null;
  const lastM = monthLabel((series ? series.length : 1) - 1);
  const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (m) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[m]));

  const gaugeAngle = ((score ?? 0) / 100) * 180 - 180;
  const nx = 256 + Math.cos(gaugeAngle * Math.PI / 180) * 132;
  const ny = 347 + Math.sin(gaugeAngle * Math.PI / 180) * 132;

  const bucket = (k) => c.tasks.filter((_, i) => sort[i] === k);

  const verdictRows = EVIDENCE.filter((e) => verdicts[e.id] || (notes[e.id] || '').trim())
    .map((e) => `<tr><td><b>${esc(e.org)}</b><br><span class="s">${esc(e.title)}</span></td>
      <td class="v">${esc((VERDICTS.find((v) => v.k === verdicts[e.id]) || {}).label || '—')}</td>
      <td>${esc(notes[e.id] || '')}</td></tr>`).join('');

  const CLAIM_LABELS = { supports: 'Supports it', partly: 'Partly', against: 'Cuts against it', cant: 'Cannot tell' };
  const claimRows = CLAIMS.filter((cl) => claimVerdicts[cl.id])
    .map((cl) => `<tr><td>&ldquo;${esc(cl.claim)}&rdquo;<br><span class="s">${esc(cl.source)}</span></td>
      <td class="v">${esc(CLAIM_LABELS[claimVerdicts[cl.id]])}</td></tr>`).join('');

  const decisionRows = ROUNDS.filter((r) => picks[r.id]).map((r) => {
    const o = r.options.find((x) => x.k === picks[r.id]);
    return `<tr><td><b>${esc(r.title)}</b></td><td>${esc(o.label)}</td></tr>`;
  }).join('');
  const meters = ROUNDS.reduce((acc, r) => {
    const o = r.options.find((x) => x.k === picks[r.id]);
    if (o) METERS.forEach((m) => { acc[m.k] += o.d[m.k]; });
    return acc;
  }, { output: 0, jobs: 0, quality: 0, worker: 0 });
  const allDecided = ROUNDS.every((r) => picks[r.id]);
  const meterBars = METERS.map((m) => {
    const v = clamp(meters[m.k], -6, 6), w = (Math.abs(v) / 6) * 50;
    return `<div class="mrow"><span class="ml">${esc(m.label)}</span>
      <span class="mt"><i style="left:${v >= 0 ? 50 : 50 - w}%;width:${w}%;background:${v >= 0 ? '#BFD7CC' : '#FF6658'}"></i></span>
      <span class="mv" style="color:${v >= 0 ? '#BFD7CC' : '#FF806F'}">${v > 0 ? '+' : ''}${v}</span></div>`;
  }).join('');

  const gInfo = guess ? GUESSES.find((g) => g.k === guess) : null;
  const indSeries = data.ces[c.ind];
  const actualChg = indSeries && indSeries[gpt] ? ((indSeries[indSeries.length - 1] - indSeries[gpt]) / indSeries[gpt]) * 100 : null;
  const gTruth = actualChg == null ? null : guessOf(actualChg);

  const answerBlocks = REFLECT_Q.map((q) => {
    const v = (answers[q.id] || '').trim();
    if (!v) return '';
    return `<div class="q"><p class="ql">${esc(q.q)}</p><p class="qa">${esc(v)}</p></div>`;
  }).join('');

  return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Career Futures — ${esc(c.title)} — ${esc(name || 'Student')}</title>
<style>
  :root{--coral:#FF6658;--violet:#6E69F8;--mustard:#F6C94C;--mint:#BFD7CC;--cream:#FFF7E7;--ink:#161616;--char:#1F1F1F;--char2:#2A2A2A;--muted:#90A89E}
  *{box-sizing:border-box}
  body{margin:0;background:#C7D7CF;font-family:Inter,system-ui,-apple-system,Segoe UI,Arial,sans-serif;
       color:var(--cream);padding:28px 16px;line-height:1.55}
  .card{max-width:760px;margin:0 auto;background:var(--char);border-radius:32px;overflow:hidden;
        box-shadow:0 24px 70px rgba(20,30,26,.3)}
  h1,h2,h3{font-family:"Arial Narrow","Roboto Condensed",Impact,sans-serif;font-weight:900;
           text-transform:uppercase;letter-spacing:.015em;margin:0;line-height:.98}
  .hd{padding:30px 30px 26px;background:linear-gradient(150deg,#2A2A2A,#1B1B1B)}
  .eyebrow{font-size:.64rem;letter-spacing:.19em;text-transform:uppercase;font-weight:800;color:var(--mint);margin:0 0 8px}
  .hd h1{font-size:2.5rem}
  .hd .sub{color:#c3bdb0;font-size:.9rem;margin-top:8px}
  .who{margin-top:16px;display:flex;gap:10px;flex-wrap:wrap;font-size:.74rem}
  .who span{background:rgba(255,247,231,.09);border:1px solid rgba(255,247,231,.16);border-radius:99px;padding:5px 13px}
  .sec{padding:24px 30px;border-top:1px solid rgba(255,247,231,.09)}
  .sec h2{font-size:1.15rem;color:var(--mustard);margin-bottom:14px}
  .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px}
  .stat{background:var(--char2);border:1px solid rgba(255,247,231,.09);border-radius:18px;padding:13px 15px}
  .stat .k{font-size:.58rem;letter-spacing:.15em;text-transform:uppercase;color:var(--muted);font-weight:800}
  .stat .v{font-family:"Arial Narrow",Impact,sans-serif;font-weight:900;font-size:1.6rem;margin-top:4px;line-height:1}
  .stat .n{font-size:.66rem;color:var(--muted);margin-top:4px}
  .score{display:flex;gap:22px;align-items:center;flex-wrap:wrap;background:${level === 'low' ? '#BFD7CC' : level === 'moderate' ? '#F6C94C' : '#FF6658'};
         color:var(--ink);border-radius:26px;padding:22px}
  .score h2{color:var(--ink);margin:0}
  .score .big{font-family:"Arial Narrow",Impact,sans-serif;font-weight:900;font-size:3.6rem;line-height:.9}
  .score svg{width:190px;height:auto}
  .bar{display:flex;height:16px;border-radius:99px;overflow:hidden;margin-top:12px}
  .key{display:flex;gap:14px;flex-wrap:wrap;font-size:.74rem;margin-top:9px;color:#c9c3b6}
  .key i{display:inline-block;width:11px;height:11px;border-radius:3px;margin-right:5px;vertical-align:-1px}
  .cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px}
  .col{background:var(--char2);border-radius:18px;padding:14px;border-top:4px solid}
  .col h3{font-size:.76rem;letter-spacing:.12em;margin-bottom:9px}
  .col ul{margin:0;padding-left:17px;font-size:.8rem;color:#cbc5b8;display:grid;gap:6px}
  .band{position:relative;height:30px;border-radius:99px;margin-top:8px;
        background:linear-gradient(90deg,#BFD7CC 0 33%,#F6C94C 33% 66%,#FF6658 66% 100%)}
  .band .r{position:absolute;top:0;bottom:0;background:rgba(22,22,22,.5);border:2px solid var(--violet);border-radius:8px}
  .band .m{position:absolute;top:-4px;bottom:-4px;width:4px;border-radius:2px;background:var(--ink);box-shadow:0 0 0 2px var(--cream)}
  .q{background:var(--char2);border-radius:18px;padding:15px;margin-bottom:10px;border-left:4px solid var(--violet)}
  .ql{font-size:.74rem;color:var(--mint);font-weight:700;margin:0 0 6px;text-transform:uppercase;letter-spacing:.08em}
  .qa{margin:0;font-size:.92rem;white-space:pre-wrap}
  table{width:100%;border-collapse:collapse;font-size:.8rem}
  td{padding:9px 8px;border-bottom:1px solid rgba(255,247,231,.09);vertical-align:top}
  td.v{font-weight:800;color:var(--mustard);white-space:nowrap}
  .s{color:var(--muted);font-size:.72rem}
  .conf{background:var(--violet);border-radius:26px;padding:20px;color:#fff}
  .conf .big{font-family:"Arial Narrow",Impact,sans-serif;font-weight:900;font-size:2rem}
  .pred{background:var(--char2);border-radius:18px;padding:15px;border-left:4px solid var(--mustard);font-size:.88rem}
  .mrow{display:grid;grid-template-columns:auto 1fr auto;gap:12px;align-items:center;margin-bottom:9px;font-size:.82rem}
  .ml{font-weight:700;min-width:104px}
  .mt{position:relative;height:12px;border-radius:99px;background:#1b1b1b;display:block}
  .mt i{position:absolute;top:0;bottom:0;border-radius:99px}
  .mv{font-family:"Arial Narrow",Impact,sans-serif;font-weight:900;font-size:1rem;min-width:26px;text-align:right}
  .ft{padding:24px 30px;background:var(--ink);font-size:.72rem;color:#9a948a;line-height:1.6}
  .ft b{color:var(--mint)}
  .ft a{color:var(--mustard)}
  @media print{body{background:#fff;padding:0}.card{box-shadow:none;border-radius:0;max-width:none}}
</style></head><body>
<div class="card">
  <div class="hd">
    <p class="eyebrow">Career Futures · Exposure Lab</p>
    <h1>${esc(c.title)}</h1>
    <p class="sub">${esc(c.full)} · SOC ${c.soc.slice(0, 2)}-${c.soc.slice(2)} · ${esc(c.cluster)}</p>
    <div class="who">
      <span><b>${esc(name || 'Student')}</b></span>
      <span>${esc(when)}</span>
      <span>Data: ${data.status === 'live' ? 'live BLS API' : 'bundled BLS snapshot'}</span>
    </div>
  </div>

  <div class="sec">
    <h2>What the Bureau of Labor Statistics says</h2>
    <div class="grid">
      <div class="stat"><div class="k">Employed</div><div class="v">${fmtInt(o.emp)}</div><div class="n">OEWS ${OEWS_PERIOD}</div></div>
      <div class="stat"><div class="k">Median pay</div><div class="v">${fmtUSD(o.med)}</div><div class="n">Annual, national</div></div>
      <div class="stat"><div class="k">${esc(c.indName)}</div><div class="v">${sinceGpt == null ? '—' : pct(sinceGpt)}</div><div class="n">Since Nov 2022 · to ${esc(lastM)}</div></div>
    </div>
    ${gInfo && gTruth ? `<div class="pred" style="margin-top:12px">
      <b>Before I looked</b>, I predicted employment in this industry had
      <b>${esc(gInfo.label.toLowerCase())}</b> since ChatGPT launched. It actually
      <b>${esc(gTruth.label.toLowerCase())}</b> — ${pct(actualChg)}.
      ${gInfo.k === gTruth.k ? 'I was right.' : 'I was wrong.'}</div>` : ''}
  </div>

  <div class="sec">
    <h2>My exposure estimate</h2>
    <div class="score">
      <div style="flex:1;min-width:180px">
        <h2>${esc(LEVEL_TEXT[level] || 'Not scored')}</h2>
        <p class="big">${score == null ? '—' : score}<span style="font-size:1.1rem"> / 100</span></p>
        <p style="font-size:.8rem;margin-top:6px">Automated = 1 · AI-assisted = ½ · Human-led = 0</p>
      </div>
      <svg viewBox="0 0 512 380" aria-hidden="true">
        <g fill="none" stroke-linecap="round" stroke="#FFF7E7" stroke-width="66">
          <path d="M90 347A166 166 0 0 1 173 203"/>
          <path d="M173 203A166 166 0 0 1 339 203"/>
          <path d="M339 203A166 166 0 0 1 422 347"/>
        </g>
        <g fill="none" stroke-linecap="round" stroke-width="52">
          <path d="M90 347A166 166 0 0 1 173 203" stroke="#BFD7CC"/>
          <path d="M173 203A166 166 0 0 1 339 203" stroke="#F6C94C"/>
          <path d="M339 203A166 166 0 0 1 422 347" stroke="#FF6658"/>
        </g>
        ${score == null ? '' : `<g stroke="#161616" stroke-width="10" stroke-linecap="round">
          <path d="M256 347L${nx.toFixed(1)} ${ny.toFixed(1)}"/>
          <circle cx="256" cy="347" r="20" fill="#161616"/>
          <circle cx="256" cy="347" r="7" fill="#FFF7E7" stroke="none"/></g>`}
      </svg>
    </div>
    <div class="bar">
      <span style="width:${mix.automated}%;background:#FF6658"></span>
      <span style="width:${mix.aiAssisted}%;background:#F6C94C"></span>
      <span style="width:${mix.humanLed}%;background:#BFD7CC"></span>
    </div>
    <div class="key">
      <span><i style="background:#FF6658"></i>Automated ${mix.automated}%</span>
      <span><i style="background:#F6C94C"></i>AI-assisted ${mix.aiAssisted}%</span>
      <span><i style="background:#BFD7CC"></i>Human-led ${mix.humanLed}%</span>
    </div>
    <p style="font-size:.78rem;color:var(--muted);margin-top:12px">Compared with this lab's research band of
      <b style="color:var(--cream)">${c.band[0]}–${c.band[1]}</b> (${esc(c.bandLabel.toLowerCase())} exposure).</p>
    <div class="band">
      <div class="r" style="left:${c.band[0]}%;width:${c.band[1] - c.band[0]}%"></div>
      ${score == null ? '' : `<div class="m" style="left:calc(${clamp(score, 1, 99)}% - 2px)"></div>`}
    </div>
  </div>

  <div class="sec">
    <h2>How I sorted the tasks</h2>
    <div class="cols">
      <div class="col" style="border-color:#FF6658"><h3 style="color:#FF6658">Automated</h3>
        <ul>${bucket('automated').map((t) => `<li>${esc(t)}</li>`).join('') || '<li>—</li>'}</ul></div>
      <div class="col" style="border-color:#F6C94C"><h3 style="color:#F6C94C">AI-assisted</h3>
        <ul>${bucket('aiAssisted').map((t) => `<li>${esc(t)}</li>`).join('') || '<li>—</li>'}</ul></div>
      <div class="col" style="border-color:#BFD7CC"><h3 style="color:#BFD7CC">Human-led</h3>
        <ul>${bucket('humanLed').map((t) => `<li>${esc(t)}</li>`).join('') || '<li>—</li>'}</ul></div>
    </div>
  </div>

  ${verdictRows ? `<div class="sec"><h2>How I judged the evidence</h2><table>${verdictRows}</table></div>` : ''}

  ${claimRows ? `<div class="sec"><h2>Claims I pressure-tested</h2><table>${claimRows}</table></div>` : ''}

  ${decisionRows ? `<div class="sec">
    <h2>The decision I made</h2>
    <p style="font-size:.84rem;color:var(--muted);margin:0 0 12px">Running a workplace that employs ${esc(c.title.toLowerCase())}s.</p>
    <table>${decisionRows}</table>
    ${allDecided ? `<div style="margin-top:16px">${meterBars}
      <p style="font-size:.8rem;color:var(--muted);margin-top:10px"><b style="color:var(--mint)">${esc(DECISION_VERDICT(meters)[0])}</b>
      ${esc(DECISION_VERDICT(meters)[1])}</p></div>` : ''}
  </div>` : ''}

  ${answerBlocks ? `<div class="sec"><h2>My reflections</h2>${answerBlocks}</div>` : ''}

  <div class="sec">
    <div class="conf">
      <p class="eyebrow" style="color:rgba(255,255,255,.75)">How informed is my judgement?</p>
      <p class="big">${conf} of 5 — ${esc(CONF_LABELS[conf])}</p>
    </div>
  </div>

  <div class="ft">
    <p><b>Sources.</b> Employment and wage figures: U.S. Bureau of Labor Statistics, Occupational Employment
    and Wage Statistics, ${OEWS_PERIOD}, national, series prefix OEU. Industry employment trend: BLS Current
    Employment Statistics, series ${esc(c.ind)}, seasonally adjusted, through ${esc(lastM)}.
    Retrieved via the BLS Public Data API${data.status === 'live' ? '' : ` (bundled snapshot taken ${SNAPSHOT_TAKEN})`}.
    BLS does not endorse this work.</p>
    <p style="margin-top:9px"><b>What is not a BLS statistic.</b> The exposure score on this card was produced by a
    student sorting a task list written for this lab. The comparison band is this lab's reading of published
    task-exposure research (Felten et al.; Eloundou et al.; Stanford Digital Economy Lab). Neither is an official
    figure, and exposure is not a prediction that any job will disappear.</p>
    <p style="margin-top:9px"><b>Evidence consulted:</b> Stanford Digital Economy Lab, <i>Canaries in the Coal Mine?</i>;
    The Budget Lab at Yale, <i>Evaluating the Impact of AI on the Labor Market</i>; Anthropic Economic Index;
    BLS Employment Projections; Goldman Sachs Research; contemporary business-press coverage.</p>
    <p style="margin-top:9px">Career Futures — Exposure Lab. Built with Claude (Anthropic) for classroom use.
    Student work on this card was written by the student named above.</p>
  </div>
</div></body></html>`;
}

function CardScreen({ selected, sorts, answers, verdicts, notes, conf, name, setName,
  guesses, claimVerdicts, decisions }) {
  const data = useData();
  const c = byId(selected);
  const o = data.oews[c.soc] || {};
  const sort = sorts[c.id] || {};
  const [toast, setToast] = useState(null);
  const when = useMemo(() => new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }), []);

  const picks = decisions[c.id] || {};
  const html = useMemo(() => buildCardHTML({
    c, o, data, sort,
    score: scoreOf(sort, c.tasks),
    mix: mixOf(sort, c.tasks),
    conf, answers: answers[c.id] || {}, verdicts, notes, name, when,
    guess: guesses[c.id], claimVerdicts, picks,
  }), [c, o, data, sort, conf, answers, verdicts, notes, name, when, guesses, claimVerdicts, picks]);

  const flash = (m) => { setToast(m); setTimeout(() => setToast(null), 2200); };

  const download = () => {
    const b = new Blob([html], { type: 'text/html' });
    const a = document.createElement('a');
    a.href = URL.createObjectURL(b);
    a.download = `career-futures-${c.id}-${(name || 'student').toLowerCase().replace(/\W+/g, '-')}.html`;
    a.click();
    setTimeout(() => URL.revokeObjectURL(a.href), 4000);
    flash('Card downloaded');
  };

  const printPdf = () => {
    const w = window.open('', '_blank');
    if (!w) { flash('Allow pop-ups to print'); return; }
    w.document.write(html);
    w.document.close();
    w.onload = () => { w.focus(); w.print(); };
    setTimeout(() => { try { w.focus(); w.print(); } catch (e) { /* already printed */ } }, 700);
  };

  const plain = () => {
    const s = scoreOf(sort, c.tasks), m = mixOf(sort, c.tasks);
    const a = answers[c.id] || {};
    const L = [];
    L.push(`CAREER FUTURES — EXPOSURE LAB`);
    L.push(`${c.title} (${c.full}, SOC ${c.soc.slice(0, 2)}-${c.soc.slice(2)})`);
    L.push(`${name || 'Student'} · ${when}`);
    L.push('');
    L.push(`BLS (OEWS ${OEWS_PERIOD}): ${fmtInt(o.emp)} employed · median ${fmtUSD(o.med)} a year`);
    L.push('');
    L.push(`MY EXPOSURE ESTIMATE: ${s == null ? 'not scored' : s + '/100 — ' + LEVEL_TEXT[levelOf(s)]}`);
    L.push(`Task mix: ${m.automated}% automated · ${m.aiAssisted}% AI-assisted · ${m.humanLed}% human-led`);
    L.push(`Lab research band: ${c.band[0]}–${c.band[1]} (${c.bandLabel.toLowerCase()})`);
    L.push('');
    ['automated', 'aiAssisted', 'humanLed'].forEach((k) => {
      const lab = { automated: 'AUTOMATED', aiAssisted: 'AI-ASSISTED', humanLed: 'HUMAN-LED' }[k];
      const list = c.tasks.filter((_, i) => sort[i] === k);
      if (list.length) L.push(`${lab}: ` + list.map((t) => '\n  - ' + t).join(''));
    });
    const g = guesses[c.id];
    const ind = data.ces[c.ind];
    const gi = idxOfMonth(2022, 11);
    if (g && ind && ind[gi]) {
      const actual = ((ind[ind.length - 1] - ind[gi]) / ind[gi]) * 100;
      L.push('', `PREDICTION: I said this industry had "${GUESSES.find((x) => x.k === g).label.toLowerCase()}" since Nov 2022.`);
      L.push(`  Actual: ${pct(actual)} — "${guessOf(actual).label.toLowerCase()}". ${g === guessOf(actual).k ? 'Correct.' : 'Wrong.'}`);
    }
    const ev = EVIDENCE.filter((e) => verdicts[e.id] || (notes[e.id] || '').trim());
    if (ev.length) {
      L.push('', 'EVIDENCE VERDICTS');
      ev.forEach((e) => L.push(`  ${e.org} — ${(VERDICTS.find((v) => v.k === verdicts[e.id]) || {}).label || '—'}${notes[e.id] ? ' — ' + notes[e.id] : ''}`));
    }
    const CL = { supports: 'Supports it', partly: 'Partly', against: 'Cuts against it', cant: 'Cannot tell' };
    const cl = CLAIMS.filter((x) => claimVerdicts[x.id]);
    if (cl.length) {
      L.push('', 'CLAIMS PRESSURE-TESTED');
      cl.forEach((x) => L.push(`  "${x.claim}" — ${CL[claimVerdicts[x.id]]}`));
    }
    const dr = ROUNDS.filter((r) => picks[r.id]);
    if (dr.length) {
      L.push('', 'THE DECISION');
      dr.forEach((r) => L.push(`  ${r.title}: ${r.options.find((o) => o.k === picks[r.id]).label}`));
      if (ROUNDS.every((r) => picks[r.id])) {
        const mm = ROUNDS.reduce((a, r) => {
          const o = r.options.find((x) => x.k === picks[r.id]);
          METERS.forEach((m) => { a[m.k] += o.d[m.k]; });
          return a;
        }, { output: 0, jobs: 0, quality: 0, worker: 0 });
        L.push('  ' + METERS.map((m) => `${m.label} ${mm[m.k] > 0 ? '+' : ''}${mm[m.k]}`).join(' · '));
        L.push('  ' + DECISION_VERDICT(mm)[0]);
      }
    }
    const ans = REFLECT_Q.filter((q) => (a[q.id] || '').trim());
    if (ans.length) {
      L.push('', 'REFLECTIONS');
      ans.forEach((q) => L.push(`  ${q.q}\n    ${a[q.id].trim()}`));
    }
    L.push('', `CONFIDENCE: ${conf}/5 — ${CONF_LABELS[conf]}`);
    L.push('', `Sources: BLS OEWS ${OEWS_PERIOD} and BLS CES series ${c.ind}, via the BLS Public Data API. Exposure score is a student judgement, not a BLS statistic. Exposure is not a prediction of job loss.`);
    return L.join('\n');
  };

  const copy = async () => {
    const text = plain();
    try {
      if (navigator.clipboard && window.ClipboardItem) {
        await navigator.clipboard.write([new ClipboardItem({
          'text/html': new Blob([html], { type: 'text/html' }),
          'text/plain': new Blob([text], { type: 'text/plain' }),
        })]);
      } else {
        await navigator.clipboard.writeText(text);
      }
      flash('Copied to clipboard');
    } catch (e) {
      const ta = document.createElement('textarea');
      ta.value = text; document.body.appendChild(ta); ta.select();
      try { document.execCommand('copy'); flash('Copied to clipboard'); }
      catch (e2) { flash('Copy failed — select the text manually'); }
      ta.remove();
    }
  };

  return (
    <div className="stack">
      <header>
        <p className="eyebrow">Step seven</p>
        <h1 className="h-lg">Your card</h1>
        <p className="lede" style={{ marginTop: 8 }}>
          Everything you did in this lab, on one page, with its sources attached.
        </p>
      </header>

      <div className="panel stack">
        <label className="eyebrow" htmlFor="nm">Name on the card</label>
        <input id="nm" className="txtin" value={name} onChange={(e) => setName(e.target.value)}
          placeholder="Your name" maxLength="60" />
        <div className="chipset">
          <button className="btn" onClick={download}>Download HTML <span className="knob"><I.arrowUp /></span></button>
          <button className="btn dark" onClick={printPdf}>Print / save PDF <span className="knob"><I.notebook /></span></button>
          <button className="btn ghost" onClick={copy}>Copy</button>
        </div>
        <p className="tiny">
          Everything stays on this device. Nothing you write is uploaded anywhere — the only network
          request this page makes is to the public BLS API for employment figures.
        </p>
      </div>

      <div className="exportwrap">
        <iframe title="Card preview" srcDoc={html} className="cardframe"
          onLoad={(e) => {
            const d = e.currentTarget.contentDocument;
            if (d) e.currentTarget.style.height = (d.documentElement.scrollHeight + 24) + 'px';
          }}
          style={{ width: '100%', height: 900, border: 0, background: '#C7D7CF', borderRadius: 20 }} />
      </div>

      {toast && <div className="toast">{toast}</div>}
    </div>
  );
}

/* =====================================================================
   15. APP SHELL
   ===================================================================== */

const TABS = [
  { id: 'home', label: 'Home', icon: 'home' },
  { id: 'career', label: 'Career', icon: 'careers' },
  { id: 'compare', label: 'Compare', icon: 'compare' },
  { id: 'evidence', label: 'Evidence', icon: 'resources' },
  { id: 'test', label: 'Test', icon: 'eye' },
  { id: 'decide', label: 'Decide', icon: 'scales' },
  { id: 'reflect', label: 'Reflect', icon: 'pencil' },
  { id: 'card', label: 'Card', icon: 'saved' },
];

function DataChip() {
  const d = useData();
  const [open, setOpen] = useState(false);
  const cls = d.status === 'live' ? 'live' : d.status === 'loading' ? 'load' : 'snap';
  const label = d.status === 'live' ? (d.cached ? 'BLS · cached' : 'BLS · live') : d.status === 'loading' ? 'BLS · loading' : 'BLS · snapshot';
  return (
    <>
      <button className={'datachip ' + cls} onClick={() => setOpen(true)} title="Where these numbers came from">
        <span className="dot" />{label}
      </button>
      {open && (
        <div className="modal" onClick={() => setOpen(false)}>
          <div className="box" onClick={(e) => e.stopPropagation()}>
            <h2 className="h-md">Where these numbers come from</h2>
            <p style={{ fontSize: '.92rem' }}>
              Employment and pay figures are pulled live from the <b>U.S. Bureau of Labor Statistics
              Public Data API</b> — Occupational Employment and Wage Statistics ({OEWS_PERIOD}) for
              occupations, and Current Employment Statistics for the monthly industry trends.
            </p>
            <p style={{ fontSize: '.92rem' }}>
              {d.status === 'live' && <>This session is showing <b>live API data</b>{d.cached ? ', from a cached response less than seven days old.' : ', fetched just now.'}</>}
              {d.status === 'loading' && <>Currently loading. Snapshot values are shown until the call returns.</>}
              {d.status === 'snapshot' && <>The live call did not succeed, so this session is showing a <b>bundled snapshot taken {SNAPSHOT_TAKEN}</b>. The public API allows 25 requests per IP address per day and a whole classroom shares one address, so this is normal and expected.{d.error ? ` (${d.error})` : ''}</>}
            </p>
            <p className="tiny">
              The BLS does not endorse this lab. Exposure scores in this app are <b>not</b> BLS statistics —
              they are student judgements plus this lab's reading of published research.
            </p>
            <div><button className="btn sm" onClick={() => setOpen(false)}>Close</button></div>
          </div>
        </div>
      )}
    </>
  );
}

function App() {
  const data = useBLS();
  const [tab, setTab] = usePersist('tab', 'home');
  const [selected, setSelected] = usePersist('selected', 'paralegal');
  const [sorts, setSorts] = usePersist('sorts', {});
  const [answers, setAnswers] = usePersist('answers', {});
  const [verdicts, setVerdicts] = usePersist('verdicts', {});
  const [notes, setNotes] = usePersist('notes', {});
  const [conf, setConf] = usePersist('conf', 3);
  const [name, setName] = usePersist('name', '');
  const [cmp, setCmp] = usePersist('cmp', ['teacher', 'bookkeeper']);
  const [scenIdx, setScenIdx] = usePersist('scen', 0);
  const [claimVerdicts, setClaimVerdicts] = usePersist('claimVerdicts', {});
  const [decisions, setDecisions] = usePersist('decisions', {});
  const [guesses, setGuesses] = usePersist('guesses', {});

  useEffect(() => { document.getElementById('boot')?.setAttribute('hidden', ''); }, []);
  useEffect(() => { window.scrollTo({ top: 0, behavior: 'auto' }); }, [tab]);

  const go = (t) => setTab(t);
  const c = byId(selected);
  const sorted = mixOf(sorts[c.id] || {}, c.tasks).done;

  return (
    <DataCtx.Provider value={data}>
      <header className="topbar">
        <img className="mark" src="assets/svg/brand/app-mark.svg" alt="" />
        <div className="wm"><b>Career Futures</b><span>Exposure Lab</span></div>
        <div className="spacer" />
        <DataChip />
      </header>

      <nav className="nav" aria-label="Sections">
        {TABS.map((t) => {
          const Icon = I[t.icon];
          return (
            <button key={t.id} aria-current={tab === t.id} onClick={() => go(t.id)}>
              <Icon /><span>{t.label}</span>
            </button>
          );
        })}
      </nav>

      <main className="shell">
        {tab === 'home' && <Home selected={selected} setSelected={setSelected} go={go} />}
        {tab === 'career' && <CareerScreen selected={selected} setSelected={setSelected} sorts={sorts} setSorts={setSorts}
          guesses={guesses} setGuesses={setGuesses} go={go} />}
        {tab === 'compare' && <Compare sorts={sorts} cmp={cmp} setCmp={setCmp} />}
        {tab === 'evidence' && <Evidence verdicts={verdicts} setVerdicts={setVerdicts} notes={notes} setNotes={setNotes} go={go} />}
        {tab === 'test' && <PressureTest verdicts={claimVerdicts} setVerdicts={setClaimVerdicts} go={go} />}
        {tab === 'decide' && <Decide selected={selected} decisions={decisions} setDecisions={setDecisions} go={go} />}
        {tab === 'reflect' && <Reflect selected={selected} answers={answers} setAnswers={setAnswers}
          conf={conf} setConf={setConf} scenIdx={scenIdx} setScenIdx={setScenIdx} go={go} />}
        {tab === 'card' && <CardScreen selected={selected} sorts={sorts} answers={answers}
          verdicts={verdicts} notes={notes} conf={conf} name={name} setName={setName}
          guesses={guesses} claimVerdicts={claimVerdicts} decisions={decisions} />}

        <footer style={{ marginTop: 46, paddingTop: 22, borderTop: '1px solid rgba(255,247,231,.09)' }}>
          <p className="tiny" style={{ maxWidth: '72ch' }}>
            <b style={{ color: 'var(--mint)' }}>Career Futures — Exposure Lab.</b> Employment and wage data:
            U.S. Bureau of Labor Statistics Public Data API (OEWS {OEWS_PERIOD}; CES monthly).
            The BLS does not endorse this lab. Exposure scores are student judgements, not official statistics,
            and exposure to automation is not a prediction that a job will disappear.
            Design system from the Career Futures site asset package. Interface and text built with Claude
            (Anthropic); all student writing stays on the student's own device.
            {!sorted && <> · <button style={{ color: 'var(--mustard)', textDecoration: 'underline' }} onClick={() => go('career')}>You have not finished sorting {c.title} tasks.</button></>}
          </p>
        </footer>
      </main>
    </DataCtx.Provider>
  );
}

createRoot(document.getElementById('root')).render(<App />);
