Spaces:
Sleeping
Sleeping
File size: 139,534 Bytes
f9aaea2 40eec04 f9aaea2 40eec04 f9aaea2 40eec04 f9aaea2 40eec04 f9aaea2 479db49 f9aaea2 f80bbbc f9aaea2 f80bbbc f9aaea2 f80bbbc f9aaea2 479db49 f9aaea2 479db49 f9aaea2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 1923 1924 1925 1926 1927 1928 1929 1930 1931 1932 1933 1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961 1962 1963 1964 1965 1966 1967 1968 1969 1970 1971 1972 1973 1974 1975 1976 1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047 2048 2049 2050 2051 2052 2053 2054 2055 2056 2057 2058 2059 2060 2061 2062 2063 2064 2065 2066 2067 2068 2069 2070 2071 2072 2073 2074 2075 2076 2077 2078 2079 2080 2081 2082 2083 2084 2085 2086 2087 2088 2089 2090 2091 2092 2093 2094 2095 2096 2097 2098 2099 2100 2101 2102 2103 2104 2105 2106 2107 2108 2109 2110 2111 2112 2113 2114 2115 2116 2117 2118 2119 2120 2121 2122 2123 2124 2125 2126 2127 2128 2129 2130 2131 2132 2133 2134 2135 2136 2137 2138 2139 2140 2141 2142 2143 2144 2145 2146 2147 2148 2149 2150 2151 2152 2153 2154 2155 2156 2157 2158 2159 2160 2161 2162 2163 2164 2165 2166 2167 2168 2169 2170 2171 2172 2173 2174 2175 2176 2177 2178 2179 2180 2181 2182 2183 2184 2185 2186 2187 2188 2189 2190 2191 2192 2193 2194 2195 2196 2197 2198 2199 2200 2201 2202 2203 2204 2205 2206 2207 2208 2209 2210 2211 2212 2213 2214 2215 2216 2217 2218 2219 2220 2221 2222 2223 2224 2225 2226 2227 2228 2229 2230 2231 2232 2233 2234 2235 2236 2237 2238 2239 2240 2241 2242 2243 2244 2245 2246 2247 2248 2249 2250 2251 2252 2253 2254 2255 2256 2257 2258 2259 2260 2261 2262 2263 2264 2265 2266 2267 2268 2269 2270 2271 2272 2273 2274 2275 2276 2277 2278 2279 2280 2281 2282 2283 2284 2285 2286 2287 2288 2289 2290 2291 2292 2293 2294 2295 2296 2297 2298 2299 2300 2301 2302 2303 2304 2305 2306 2307 2308 2309 2310 2311 2312 2313 2314 2315 2316 2317 2318 2319 2320 2321 2322 2323 2324 2325 2326 2327 2328 2329 2330 2331 2332 2333 2334 2335 2336 2337 2338 2339 2340 2341 2342 2343 2344 2345 2346 2347 2348 2349 2350 2351 2352 2353 2354 2355 2356 2357 2358 2359 2360 2361 2362 2363 2364 2365 2366 2367 2368 2369 2370 2371 2372 2373 2374 2375 2376 2377 2378 2379 2380 2381 2382 2383 2384 2385 2386 2387 2388 2389 2390 2391 2392 2393 2394 2395 2396 2397 2398 2399 2400 2401 2402 2403 2404 2405 2406 2407 2408 2409 2410 2411 2412 2413 2414 2415 2416 2417 2418 2419 2420 2421 2422 2423 2424 2425 2426 2427 2428 2429 2430 2431 2432 2433 2434 2435 2436 2437 2438 2439 2440 2441 2442 2443 2444 2445 2446 2447 2448 2449 2450 2451 2452 2453 2454 2455 2456 2457 2458 2459 2460 2461 2462 2463 2464 2465 2466 2467 2468 2469 2470 2471 2472 2473 2474 2475 2476 2477 2478 2479 2480 2481 2482 2483 2484 2485 2486 2487 2488 2489 2490 2491 2492 2493 2494 2495 2496 2497 2498 2499 2500 2501 2502 2503 2504 2505 2506 2507 2508 2509 2510 2511 2512 2513 2514 2515 2516 2517 2518 2519 2520 2521 2522 2523 2524 2525 2526 2527 2528 2529 2530 2531 2532 2533 2534 2535 2536 2537 2538 2539 2540 2541 2542 2543 2544 2545 2546 2547 2548 2549 2550 2551 2552 2553 2554 2555 2556 2557 2558 2559 2560 2561 2562 2563 2564 2565 2566 2567 2568 2569 2570 2571 2572 2573 2574 2575 2576 2577 2578 2579 2580 2581 2582 2583 2584 2585 2586 2587 2588 2589 2590 2591 2592 2593 2594 2595 2596 2597 2598 2599 2600 2601 2602 2603 2604 2605 2606 2607 2608 2609 2610 2611 2612 2613 2614 2615 2616 2617 2618 2619 2620 2621 2622 2623 2624 2625 2626 2627 2628 2629 2630 2631 2632 2633 2634 2635 2636 2637 2638 2639 2640 2641 2642 2643 2644 2645 2646 2647 2648 2649 2650 2651 2652 2653 2654 2655 2656 2657 2658 2659 2660 2661 2662 2663 2664 2665 2666 2667 2668 2669 2670 2671 2672 2673 2674 2675 2676 2677 2678 2679 2680 2681 2682 2683 2684 2685 2686 2687 2688 2689 2690 2691 2692 2693 2694 2695 2696 2697 2698 2699 2700 2701 2702 2703 2704 2705 2706 2707 2708 2709 2710 2711 2712 2713 2714 2715 2716 2717 2718 2719 2720 2721 2722 2723 2724 2725 2726 2727 2728 2729 2730 2731 2732 2733 2734 2735 2736 2737 2738 2739 2740 2741 2742 2743 2744 2745 2746 2747 2748 2749 2750 2751 2752 2753 2754 2755 2756 2757 2758 2759 2760 2761 2762 2763 2764 2765 2766 2767 2768 2769 2770 2771 2772 2773 2774 2775 2776 2777 2778 2779 2780 2781 2782 2783 2784 2785 2786 2787 2788 2789 2790 2791 2792 2793 2794 2795 2796 2797 2798 2799 2800 2801 2802 2803 2804 2805 2806 2807 2808 2809 2810 2811 2812 2813 2814 2815 2816 2817 2818 2819 2820 2821 2822 2823 2824 2825 2826 2827 2828 2829 2830 2831 2832 2833 2834 2835 2836 2837 2838 2839 2840 2841 2842 2843 2844 2845 2846 2847 2848 2849 2850 2851 2852 2853 2854 2855 2856 2857 2858 2859 2860 2861 2862 2863 2864 2865 2866 2867 2868 2869 2870 2871 2872 2873 2874 2875 2876 2877 2878 2879 2880 2881 2882 2883 2884 2885 2886 2887 2888 2889 2890 2891 2892 2893 2894 2895 2896 2897 2898 2899 2900 2901 2902 2903 2904 2905 2906 2907 2908 2909 2910 2911 2912 2913 2914 2915 2916 2917 2918 2919 2920 2921 2922 2923 2924 2925 2926 2927 2928 2929 2930 2931 2932 2933 2934 2935 2936 2937 2938 2939 2940 2941 2942 2943 2944 2945 2946 2947 2948 2949 2950 2951 2952 2953 2954 2955 2956 2957 2958 2959 2960 2961 2962 2963 2964 2965 2966 2967 2968 2969 2970 2971 2972 2973 2974 2975 2976 2977 2978 2979 2980 2981 2982 2983 2984 2985 2986 2987 2988 2989 2990 2991 2992 2993 2994 2995 2996 2997 2998 2999 3000 3001 3002 3003 3004 3005 3006 3007 3008 3009 3010 | <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Video user study v2 — side-by-side rating</title>
<style>
:root {
--bg: #0f1115;
--panel: #161922;
--panel-2: #1c2030;
--panel-3: #232839;
--border: #2a2f42;
--border-strong: #3a4060;
--fg: #e6e8ef;
--muted: #8b91a8;
--muted-2: #5e6480;
--accent: #6aa9ff;
--accent-strong: #8ec0ff;
--good: #28c76f;
--warn: #ff9f43;
--bad: #ea5455;
--tag: #232a3d;
--score-1: #ea5455;
--score-2: #ff7d52;
--score-3: #ffa84a;
--score-4: #8acf6d;
--score-5: #28c76f;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Helvetica Neue",
"PingFang SC", "Microsoft YaHei", sans-serif;
height: 100%; font-size: 14px; line-height: 1.5; }
#app { display: flex; flex-direction: column; height: 100vh; }
/* ---------- header ---------- */
header.top { padding: 10px 18px; border-bottom: 1px solid var(--border);
background: var(--panel); display: flex; gap: 16px; align-items: center;
flex-wrap: wrap; }
.title { font-weight: 600; font-size: 15px; }
.subtitle { color: var(--muted); font-size: 12px; }
.progress { margin-left: auto; display: flex; gap: 14px; align-items: center; }
.progress-bar { width: 200px; height: 6px; background: var(--panel-3);
border-radius: 3px; overflow: hidden; }
.progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--good));
width: 0%; transition: width 0.3s; }
.progress-text { font-size: 12px; color: var(--muted); white-space: nowrap; }
.progress-text .num { color: var(--fg); font-weight: 600; }
/* ---------- toolbar ---------- */
.toolbar { padding: 8px 18px; background: var(--panel); border-bottom: 1px solid var(--border);
display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.btn { background: var(--panel-3); border: 1px solid var(--border-strong);
color: var(--fg); padding: 5px 12px; border-radius: 4px; font-size: 12px;
cursor: pointer; font-family: inherit; transition: background 0.15s; }
.btn:hover { background: #2c3450; }
.btn.primary { background: var(--accent); color: #0a1a2a; border-color: var(--accent); font-weight: 600; }
.btn.primary:hover { background: var(--accent-strong); }
.btn.ghost { background: transparent; border-color: var(--border); color: var(--muted); }
.btn.ghost:hover { color: var(--fg); border-color: var(--border-strong); }
.toolbar .sep { width: 1px; height: 18px; background: var(--border); margin: 0 4px; }
.rater-box { display: flex; gap: 6px; align-items: center; font-size: 12px; color: var(--muted); }
.rater-box input { background: var(--panel-3); border: 1px solid var(--border);
color: var(--fg); padding: 4px 8px; border-radius: 3px; font-family: inherit;
font-size: 12px; width: 140px; }
/* ---------- main layout ---------- */
main { flex: 1; display: flex; min-height: 0; }
/* Sidebar is hidden by default; user clicks the reopen rail on the left
edge to show it. The hide button now lives inside the sidebar header. */
:root { --sidebar-w: 0px; }
body:not(.sidebar-expanded) aside.list { display: none; }
body.sidebar-expanded { --sidebar-w: 240px; }
aside.list { width: 240px; border-right: 1px solid var(--border); background: var(--panel);
overflow-y: auto; flex-shrink: 0; }
.list-header { padding: 10px 12px; font-size: 11px; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--border);
position: sticky; top: 0; background: var(--panel);
display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.list-header .hide-btn { background: var(--panel-3); border: 1px solid var(--border);
color: var(--muted); padding: 2px 8px; border-radius: 4px;
font-family: inherit; font-size: 11px; cursor: pointer;
text-transform: none; letter-spacing: 0; }
.list-header .hide-btn:hover { color: var(--fg); border-color: var(--border-strong); }
.case-row { padding: 9px 14px; border-bottom: 1px solid var(--border); cursor: pointer;
display: flex; align-items: center; gap: 8px; font-size: 13px; }
.case-row:hover { background: var(--panel-2); }
.case-row.active { background: var(--panel-2); border-left: 3px solid var(--accent);
padding-left: 11px; }
.case-row .id { font-family: ui-monospace, "SF Mono", Menlo, monospace; flex: 1; }
.case-row .status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
background: var(--muted-2); }
.case-row.partial .status-dot { background: var(--warn); }
.case-row.complete .status-dot { background: var(--good); }
/* Thin vertical rail on the left edge that re-opens the sidebar. Visible
only when the sidebar is collapsed (default). */
.sidebar-rail { display: none; position: fixed; left: 0;
top: 50%; transform: translateY(-50%);
width: 24px; padding: 14px 0; border: 1px solid var(--border); border-left: none;
border-radius: 0 6px 6px 0; background: var(--panel); color: var(--muted);
font-size: 14px; line-height: 1; cursor: pointer; z-index: 20;
writing-mode: vertical-rl; text-align: center; user-select: none; }
.sidebar-rail:hover { color: var(--fg); border-color: var(--border-strong); }
body:not(.sidebar-expanded) .sidebar-rail { display: block; }
/* section.detail becomes a non-scrolling flex column: instructions panel at
the top (also non-scrolling), then the two-pane wrap that fills the rest.
Inside the wrap the LEFT pane is fixed-position-like (it doesn't move,
since the parent has no scroll), and only the RIGHT pane has its own
overflow-y: auto. Net effect: scrolling rating cards on the right while
prompt + video + instructions remain pinned. */
section.detail { flex: 1; overflow: hidden; padding: 14px 24px 80px;
min-width: 0; min-height: 0;
display: flex; flex-direction: column; gap: 10px; }
section.detail > .instructions { flex: 0 0 auto; margin-bottom: 0; }
/* ---------- instructions panel ---------- */
.instructions { background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
padding: 14px 18px; margin-bottom: 18px; }
.instructions summary { font-weight: 600; cursor: pointer; user-select: none;
list-style: none; display: flex; align-items: center; gap: 8px; }
.instructions summary::before { content: '▸'; color: var(--muted); font-size: 11px; }
.instructions[open] summary::before { content: '▾'; }
.instructions h4 { font-size: 13px; color: var(--accent-strong);
margin: 14px 0 6px; text-transform: uppercase; letter-spacing: 0.4px; }
.instructions ul { margin: 4px 0 4px 18px; padding: 0; color: var(--muted); }
.instructions li { margin: 3px 0; }
.instructions p { color: var(--muted); margin: 6px 0; }
.instructions code { background: var(--panel-3); padding: 1px 5px; border-radius: 3px;
font-size: 12px; color: var(--accent-strong); }
.scale-row { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
.scale-row .scale-pill { padding: 3px 10px; border-radius: 12px; font-size: 11px;
font-weight: 600; color: #0a1a0a; }
/* ---------- prompt + ref ---------- */
.case-header { display: flex; gap: 16px; align-items: flex-start; margin-bottom: 14px;
flex-wrap: wrap; }
.case-header .case-id { font-family: ui-monospace, monospace; font-size: 18px;
font-weight: 600; }
.case-header .case-counter { color: var(--muted); font-size: 12px; }
.prompt-box { background: var(--panel); border: 1px solid var(--border); border-radius: 6px;
padding: 14px 16px; line-height: 1.7; font-size: 15px; flex: 1;
min-width: 280px; max-width: var(--left-w); align-self: start;
width: 100%; box-sizing: border-box; }
.prompt-box .label { font-size: 10px; text-transform: uppercase; color: var(--muted);
letter-spacing: 0.5px; margin-bottom: 8px; }
/* Highlight imperative verbs + control directives (Add / Use / Make ...) so
raters can spot what controllability they should be scoring against. */
.ctrl { color: var(--bad); font-weight: 700; }
/* Admin-curated highlights look identical in colour to the auto-detected
ones (also red) but get a subtle dotted underline so admins can tell
which marks are their own. */
.ctrl-admin { text-decoration: underline; text-decoration-style: dotted;
text-decoration-color: var(--bad); text-underline-offset: 2px; }
/* Admin highlight toolbar lives inside the prompt-box (admins only). */
.hi-toolbar { margin-top: 10px; padding-top: 8px;
border-top: 1px dashed var(--border); }
.hi-toolbar .hi-row { display: flex; align-items: center; gap: 8px;
flex-wrap: wrap; }
.hi-toolbar .hi-list { display: flex; flex-wrap: wrap; gap: 4px;
margin-top: 6px; }
.hi-toolbar .hi-tag { display: inline-flex; align-items: center; gap: 4px;
background: var(--panel-3); border: 1px solid var(--border-strong);
border-radius: 10px; padding: 2px 6px 2px 8px; font-size: 11px;
color: var(--fg); max-width: 100%; }
.hi-toolbar .hi-tag-text { color: var(--bad); font-weight: 600;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
max-width: 220px; }
.hi-toolbar .hi-tag-x { background: transparent; border: none;
color: var(--muted); padding: 0 2px; cursor: pointer; font-size: 12px; }
.hi-toolbar .hi-tag-x:hover { color: var(--bad); }
.ref-img { max-height: 140px; max-width: 220px; border-radius: 6px;
border: 1px solid var(--border); display: block; }
/* ---------- two-pane case layout ---------- */
.case-pane-wrap { flex: 1 1 auto; min-height: 0;
display: grid; gap: 12px;
/* Left pane is sized to fit the unified video / controls / prompt width
(--left-w + a bit of breathing room); right pane takes the remaining
space and packs its cards from the left edge. */
grid-template-columns: calc(var(--left-w) + 24px) minmax(0, 1fr);
align-items: stretch; }
@media (max-width: 900px) {
.case-pane-wrap { grid-template-columns: 1fr; }
}
/* Left pane fills its grid cell vertically. Doesn't scroll the page; if
its own content exceeds the cell height, the prompt-box scrolls
internally. */
.left-pane { display: flex; flex-direction: column; gap: 8px;
padding-top: 4px; padding-bottom: 8px;
min-height: 0; max-height: 100%; }
.left-pane > .video-panel, .left-pane > .video-controls,
.left-pane > .case-meta { flex: 0 0 auto; }
.left-pane > .prompt-box { flex: 1 1 auto; min-height: 5em;
max-height: none; overflow-y: auto; }
/* Right pane is the only scroll surface on this page. Grid of question
cards with FIXED-width columns that pack from the left, so the cards
visually align under the left pane's content instead of being stretched
to fill the column. */
.right-pane { min-width: 0; min-height: 0; max-height: 100%;
overflow-y: auto; padding-right: 4px;
display: grid; gap: 10px; align-content: start;
justify-content: start;
grid-template-columns: repeat(auto-fill, 230px); }
.right-pane > .dim-header { grid-column: 1 / -1; }
/* ---------- video tabs + single active video ---------- */
.video-tabs { display: flex; gap: 4px; margin: 8px 0 6px; flex-wrap: wrap; }
.tab-btn { background: var(--panel-3); border: 1px solid var(--border);
color: var(--muted); padding: 6px 14px; border-radius: 6px;
font-family: ui-monospace, monospace; font-size: 13px; cursor: pointer;
min-width: 44px; }
.tab-btn:hover { color: var(--fg); border-color: var(--border-strong); }
.tab-btn.active { background: var(--accent); color: #0a1a2a;
border-color: var(--accent); font-weight: 700; }
.video-tile { display: flex; flex-direction: column; gap: 6px; }
.video-tile.hidden { display: none; }
/* Video, control bar, and prompt-box all share --left-w as their max width
so they stack as a single coherent column inside the left pane. */
:root { --left-w: 620px; }
.video-frame { position: relative; background: #000; border: 1px solid var(--border);
border-radius: 6px; overflow: hidden; aspect-ratio: 16 / 9;
max-height: 34vh; max-width: var(--left-w); align-self: start; }
.video-frame video { width: 100%; height: 100%; display: block; background: #000; }
.video-frame .empty-state { position: absolute; inset: 0; display: flex;
flex-direction: column; align-items: center; justify-content: center;
background: repeating-linear-gradient(45deg, #1a1d28 0 10px, #16191f 10px 20px);
color: var(--muted); font-size: 12px; gap: 6px; padding: 12px; text-align: center; }
.video-frame .empty-state .icon { font-size: 28px; opacity: 0.3; }
.video-frame .empty-state .path { font-family: ui-monospace, monospace; font-size: 10px;
color: var(--muted-2); word-break: break-all; max-width: 90%; }
/* ---------- video controls (compact) ---------- */
.video-controls { background: var(--panel); border: 1px solid var(--border);
border-radius: 5px; padding: 4px 8px; display: flex; gap: 6px;
align-items: center; flex-wrap: wrap; margin: 6px 0 0; font-size: 11px;
color: var(--muted); max-width: var(--left-w); align-self: start; }
.video-controls .ctl-btn { background: var(--panel-3); border: 1px solid var(--border-strong);
color: var(--fg); padding: 2px 8px; border-radius: 3px; font-size: 11px;
line-height: 1.4; cursor: pointer; font-family: inherit; min-height: 24px; }
.video-controls .ctl-btn:hover { background: #2c3450; }
.video-controls .ctl-btn.on { background: var(--accent); color: #0a1a2a; border-color: var(--accent); }
.video-controls select { background: var(--panel-3); border: 1px solid var(--border);
color: var(--fg); padding: 1px 5px; border-radius: 3px; font-size: 11px;
font-family: inherit; min-height: 24px; }
/* ---------- right-pane question cards ---------- */
.dim-header { font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;
color: var(--accent-strong); font-weight: 700; margin: 18px 2px 8px;
display: flex; align-items: baseline; gap: 8px; }
.dim-header:first-child { margin-top: 0; }
.dim-header .dim-hint { font-weight: 400; color: var(--muted); font-size: 11px;
text-transform: none; letter-spacing: 0; }
.q-card { background: var(--panel); border: 1px solid var(--border);
border-radius: 8px; padding: 8px 10px; }
.q-card .q-name { font-size: 13px; font-weight: 600; color: var(--fg); }
.q-card .q-desc { font-size: 11px; color: var(--muted); margin: 2px 0 6px;
line-height: 1.4; }
.q-card .video-rate { display: flex; align-items: center; gap: 6px;
padding: 3px 0; }
.q-card .video-rate + .video-rate { border-top: 1px solid var(--border); }
.q-card .slot-chip { display: inline-flex; align-items: center;
justify-content: center; min-width: 24px; height: 24px; border-radius: 4px;
background: var(--panel-3); color: var(--fg); font-weight: 700;
font-size: 12px; font-family: ui-monospace, monospace;
border: 1px solid var(--border-strong); flex-shrink: 0; }
.q-card .likert { display: flex; gap: 3px; flex: 1; justify-content: flex-start; }
.q-card .likert button { flex: 1 1 0; min-width: 0; width: auto;
height: 30px; border: 1px solid var(--border);
background: var(--panel-3); color: var(--fg); border-radius: 4px;
cursor: pointer; font-family: inherit; font-size: 12px; font-weight: 500;
transition: transform 0.08s, border-color 0.15s; padding: 0; }
.q-card .likert button:hover { border-color: var(--accent); transform: translateY(-1px); }
.q-card .likert button.selected { color: #0a1a0a; font-weight: 700;
border-color: rgba(255,255,255,0.2); }
.q-card .likert button.selected[data-score="1"] { background: var(--score-1); }
.q-card .likert button.selected[data-score="2"] { background: var(--score-2); }
.q-card .likert button.selected[data-score="3"] { background: var(--score-3); }
.q-card .likert button.selected[data-score="4"] { background: var(--score-4); }
.q-card .likert button.selected[data-score="5"] { background: var(--score-5); }
/* ---------- footer / nav ---------- */
footer.bottom { position: fixed; bottom: 0; left: var(--sidebar-w); right: 0;
background: var(--panel); border-top: 1px solid var(--border);
padding: 10px 24px; display: flex; gap: 12px; align-items: center;
z-index: 10; }
footer.bottom .summary { color: var(--muted); font-size: 12px; flex: 1; }
footer.bottom .summary .num { color: var(--fg); font-weight: 600; }
footer.bottom .nav-btns { display: flex; gap: 8px; }
/* ---------- placeholder when no manifest ---------- */
.empty-app { padding: 40px; max-width: 760px; margin: 0 auto; }
.empty-app h2 { color: var(--fg); margin-bottom: 8px; }
.empty-app p { color: var(--muted); margin: 8px 0; line-height: 1.7; }
.empty-app pre { background: var(--panel); border: 1px solid var(--border);
border-radius: 6px; padding: 14px; font-size: 12px; color: var(--accent-strong);
overflow-x: auto; line-height: 1.5; }
.empty-app code { background: var(--panel-3); padding: 1px 5px; border-radius: 3px;
font-size: 12px; color: var(--accent-strong); }
/* ---------- toast ---------- */
.toast { position: fixed; bottom: 70px; right: 20px; background: var(--good);
color: #0a1a0a; padding: 10px 16px; border-radius: 6px; font-size: 13px;
font-weight: 600; opacity: 0; transition: opacity 0.2s; z-index: 100;
pointer-events: none; }
.sync-pill { padding: 4px 10px; border-radius: 12px; font-size: 11px;
font-family: ui-monospace, monospace; background: var(--panel-3); color: var(--muted);
border: 1px solid var(--border); white-space: nowrap; cursor: help; }
.sync-pill[data-state="idle"] { background: #1f2a44; color: var(--accent); border-color: #3a4a78; }
.sync-pill[data-state="submitting"] { background: #1f2a44; color: var(--accent); border-color: #3a4a78; }
.sync-pill[data-state="submitted"] { background: #1c3422; color: var(--good); border-color: #2a5a37; }
.sync-pill[data-state="local-only"] { background: #3a3520; color: var(--warn); border-color: #5a4a25; }
.sync-pill[data-state="error"] { background: #3a2020; color: var(--bad); border-color: #5a2828; }
.toast.show { opacity: 1; }
/* ---------- registration / login overlay ---------- */
.overlay { position: fixed; inset: 0; background: rgba(8, 10, 16, 0.82);
backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
display: flex; align-items: center; justify-content: center;
z-index: 200; padding: 20px; }
.overlay.hidden { display: none; }
.reg-card { background: var(--panel); border: 1px solid var(--border-strong);
border-radius: 10px; padding: 28px 30px; max-width: 460px; width: 100%;
box-shadow: 0 20px 60px rgba(0,0,0,0.5); }
.reg-card h2 { margin: 0 0 6px; font-size: 18px; }
.reg-card .lead { color: var(--muted); font-size: 13px; line-height: 1.6;
margin: 0 0 18px; }
.reg-card label { display: block; font-size: 11px; text-transform: uppercase;
letter-spacing: 0.5px; color: var(--muted); margin: 10px 0 4px; }
.reg-card input, .reg-card textarea { width: 100%; background: var(--panel-3);
border: 1px solid var(--border); color: var(--fg); padding: 8px 10px;
border-radius: 5px; font-family: inherit; font-size: 14px; }
.reg-card input:focus, .reg-card textarea:focus { outline: none;
border-color: var(--accent); }
.reg-card textarea { resize: vertical; min-height: 56px; }
.reg-card .row-actions { margin-top: 18px; display: flex; gap: 10px;
align-items: center; }
.reg-card .reg-err { color: var(--bad); font-size: 12px; min-height: 16px;
margin-top: 8px; }
.reg-card .reg-foot { color: var(--muted-2); font-size: 11px; margin-top: 14px;
line-height: 1.5; }
/* ---------- submitted celebration overlay ---------- */
.submitted-card { background: var(--panel); border: 1px solid var(--good);
border-radius: 10px; padding: 32px 34px; max-width: 460px; width: 100%;
text-align: center; box-shadow: 0 20px 60px rgba(0,0,0,0.5); }
.submitted-card .check { font-size: 48px; color: var(--good); }
.submitted-card h2 { margin: 10px 0 6px; font-size: 18px; }
.submitted-card p { color: var(--muted); margin: 6px 0; font-size: 13px; line-height: 1.6; }
.submitted-card code { background: var(--panel-3); padding: 2px 6px; border-radius: 3px;
font-size: 11px; color: var(--accent-strong); word-break: break-all; }
.submitted-card .row-actions { margin-top: 18px; display: flex; gap: 10px;
justify-content: center; }
/* ---------- submit button states ---------- */
.btn.submit-primary { background: var(--good); color: #0a1a0a;
border-color: var(--good); font-weight: 700; }
.btn.submit-primary:hover { filter: brightness(1.1); }
.btn.submit-primary:disabled { background: var(--panel-3); color: var(--muted-2);
border-color: var(--border); cursor: not-allowed; font-weight: 500; }
.submitted-badge { display: inline-flex; align-items: center; gap: 5px;
padding: 3px 9px; border-radius: 10px; background: #1c3422; color: var(--good);
font-size: 11px; font-weight: 600; border: 1px solid #2a5a37; }
kbd { background: var(--panel-3); border: 1px solid var(--border-strong);
border-radius: 3px; padding: 1px 5px; font-family: ui-monospace, monospace;
font-size: 11px; color: var(--fg); }
</style>
</head>
<body>
<div id="app"></div>
<div class="toast" id="toast"></div>
<!-- One-time registration: name + email. No password. Shown until both fields are
filled, then hidden until the user clears their state. Strings are filled
in by applyRegistrationOverlayI18n() based on LANG. -->
<div id="loginOverlay" class="overlay hidden" role="dialog" aria-modal="true">
<div class="reg-card">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:8px">
<h2 id="regHeader">Register to start rating</h2>
<div style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted)">
<span id="regLangLabel">Language</span>
<select id="regLangSelect"
onchange="LANG=this.value;document.documentElement.lang=LANG;saveState();applyRegistrationOverlayI18n();"></select>
</div>
</div>
<p class="lead" id="regLead"></p>
<label for="regName" id="regNameLabel">Your name</label>
<input id="regName" type="text" autocomplete="name" maxlength="80" />
<label for="regEmail" id="regEmailLabel">Email</label>
<input id="regEmail" type="email" autocomplete="email" maxlength="120" />
<label for="regNote" id="regNoteLabel">Affiliation / notes (optional)</label>
<textarea id="regNote" maxlength="280"></textarea>
<div class="reg-err" id="regErr"></div>
<div class="row-actions">
<button class="btn primary" id="regGoBtn"><span id="regContinueText">Continue →</span></button>
<span style="color:var(--muted-2);font-size:11px" id="regSideNote"></span>
</div>
<div class="reg-foot" id="regFootText"></div>
</div>
</div>
<!-- Shown after a successful POST /api/submit. -->
<div id="submittedOverlay" class="overlay hidden" role="dialog" aria-modal="true">
<div class="submitted-card">
<div class="check">✓</div>
<h2 id="submittedHeading">Submission recorded</h2>
<p id="submittedDetail"></p>
<p style="font-size:12px"><code id="submittedRef">—</code></p>
<div class="row-actions">
<button class="btn ghost" id="submittedCloseBtn" onclick="hideSubmittedOverlay()">Close</button>
<button class="btn" id="submittedDownloadBtn" onclick="exportJSON()">↓ Also download backup JSON</button>
</div>
</div>
</div>
<script>
/* =========================================================================
* Auto-redirect to mobile.html on phone-sized / touch-first devices.
* Run before anything else so the wrong page never renders.
* Override with `?force=desktop` (sticky for the rest of the tab session).
* ========================================================================= */
(function autoRedirectIfMobile() {
try {
const url = new URL(location.href);
if (url.searchParams.has("force")) {
sessionStorage.setItem("user_study_view_force", url.searchParams.get("force"));
}
const forced = sessionStorage.getItem("user_study_view_force");
if (forced === "desktop") return;
const ua = navigator.userAgent || "";
const isMobileUA = /Mobi|Android|iPhone|iPod|BlackBerry|Windows Phone/i.test(ua);
const isSmall = window.innerWidth < 720;
const isCoarse = window.matchMedia && window.matchMedia("(pointer: coarse)").matches;
if (forced === "mobile" || isMobileUA || (isSmall && isCoarse)) {
location.replace("./mobile.html");
}
} catch (e) { /* ignore — fall through to desktop */ }
})();
/* =========================================================================
* Rubric — 3 dimensions × 8 sub-questions
* ========================================================================= */
const DIMENSIONS = [
{
key: "instruction_following",
name: "Instruction following",
hint: "Re-read the prompt — especially the red-highlighted phrases — before scoring.",
questions: [
{ key: "prompt_adherence",
name: "Prompt adherence",
desc: "Does the content match what the prompt described (subjects, actions, setting, time, and story arc)? Focus primarily on the prompt's text content." },
{ key: "no_irrelevant_frames",
name: "No irrelevant frames",
desc: "Does the video stay on-prompt? Higher = fewer / no frames or content that weren't asked for by the prompt." },
],
},
{
key: "temporal_consistency",
name: "Temporal consistency",
hint: "Focus on how characters, objects, and the environment change across frames.",
questions: [
{ key: "person_stability",
name: "Person stability",
desc: "Faces, identities, and body proportions stay consistent across frames." },
{ key: "object_stability",
name: "Object stability",
desc: "Props, clothing, and objects keep their shape and position over time." },
{ key: "environment_stability",
name: "Environment stability",
desc: "Background, lighting, and scene layout stay coherent across frames (including absence of brightness / color / texture flicker)." },
],
},
{
// Key kept as `motion_realism` for backwards-compatibility with prior
// submissions; display name + hint broadened to "Realism" now that the
// `realism` sub-question (formerly under Visual quality) lives here.
key: "motion_realism",
name: "Realism",
hint: "Notice obvious out-of-place or unnatural moments in the video.",
questions: [
{ key: "realism",
name: "Static realism",
desc: "Photorealism or stylistic fidelity (per-frame appearance). When the prompt specifies a style, the video should match it. No uncanny or broken artifacts." },
{ key: "motion_logic",
name: "Motion logic",
desc: "Does body and object motion flow smoothly and follow physical / logical patterns (gravity, collisions, fluid / cloth dynamics)? No warping, teleporting, nonsensical, or impossible movements. Score low if there is little to no motion." },
{ key: "camera",
name: "Camera",
desc: "Camera movement (pan, dolly, focal change) feels natural and intentional. Score low if there is little to no camera movement." },
],
},
];
// v2 keys RATINGS by model name (per case) instead of by slot index, so
// adding/removing videos in a case doesn't misalign previously-rated cells.
// Old v1 storage is migrated on first load (using the v1 shuffle it saved
// alongside, to map slot_idx → original_index → model name).
const STORAGE_KEY = "user_study_v2_ratings_v1";
const STORAGE_KEY_LEGACY = "user_study_v2_ratings_v0"; // inert: v2 study starts fresh, never migrate v1-study data
const TOTAL_QUESTIONS = DIMENSIONS.reduce((n, d) => n + d.questions.length, 0);
/* =========================================================================
* Admins — these names see the "Reveal models" toggle.
* Matched case-insensitively against RATER.name. Spoofable by typing the
* name in the registration form, but cases.json on the wire contains the
* model names anyway — the gate is a UX nicety, not a security boundary.
* ========================================================================= */
const ADMIN_NAMES = new Set([
"deheng zhang",
"zhendong li",
]);
function isAdmin() {
const n = (RATER.name || "").toLowerCase().trim();
return ADMIN_NAMES.has(n);
}
/* =========================================================================
* i18n — English / 简体中文 / Български
* t(key, ...args) does positional {0}, {1} substitution.
* Rubric translations live in RUBRIC_I18N keyed by dim/question.
* ========================================================================= */
const LANGUAGES = [
{ code: "en", label: "English" },
{ code: "zh", label: "Chinese" },
{ code: "bg", label: "Bulgarian" },
];
let LANG = "en";
const STRINGS = {
en: {
"title": "Video user study — side-by-side rating",
"subtitle": "{0} cases · 3 dimensions · {1} sub-questions per video · 1–5 Likert",
"lang_label": "Language",
"rater": "Rater:",
"rater_placeholder": "your name",
"rater_edit_title": "click to edit name",
"anonymized": "🔒 Anonymized",
"reveal_models": "👁 Reveal models",
"submit_pending": "Submit study ({0} left)",
"submit_ready": "Submit study ({0} ratings)",
"submit_resubmit": "✓ Re-submit",
"submit_disabled_title": "rate every question to enable — {0} of {1} still missing",
"submit_ready_title": "post your full rating set to the dataset",
"submit_resubmit_title": "you've already submitted; click again to overwrite",
"submit_enable_hint": "rate every question to enable",
"save_progress": "💾 Save progress",
"save_progress_title": "upload your current ratings to the dataset under submissions/in_progress",
"export_json": "↓ Export JSON",
"export_title": "save a local backup of your ratings",
"admin_badge": "⚙ admin",
"admin_badge_title": "your name matches the admin list — submissions are flagged is_admin=true",
"reset_case": "↺ Reset case",
"reset_all": "✕ Reset all",
"submitted_badge": "✓ submitted",
"submitted_badge_title": "you've already submitted; further edits clear this flag",
"pill_idle": "● server ready",
"pill_submitting": "↻ submitting…",
"pill_submitted": "✓ submitted",
"pill_local_only": "💾 local-only",
"pill_error": "✕ submit error",
"pill_checking": "● checking…",
"pill_default_title": "click 'Submit study' when 100% complete",
"cases_n": "Cases ({0})",
"case_n_of_m": "Case {0} of {1}",
"prompt_label": "Prompt / instruction",
"no_prompt": "(no prompt provided)",
"play_all": "▶ Play all",
"pause": "⏸ Pause",
"restart": "⏮ Restart",
"loop": "↻ Loop",
"mute": "🔇 Mute",
"muted": "🔇 Muted",
"sound": "🔊 Sound",
"speed": "Speed:",
"sub_question_col": "Sub-question",
"video_label_anon": "Video {0}",
"video_label_revealed": "Video {0} · {1}",
"video_col_anon": "Video {0}",
"video_col_revealed": "{0} · {1}",
"no_video_file": "no video file at this path",
"drop_mp4_hint": "drop the .mp4 there, then refresh",
"footer_summary": "Case {0} / {1} · {2} / {3} ratings on this case",
"footer_complete_suffix": " · ✓ complete",
"prev": "← Prev",
"next": "Next →",
"instructions_toggle": "How to rate (click to expand) — 3 dimensions, {0} sub-questions, 1–5 each",
"instructions_dismiss": "Got it",
"instructions_intro": "For each case, watch all videos side by side, then rate <strong>each video independently</strong> on every sub-question. The rating table groups sub-questions by dimension; videos appear as columns.",
"scale_legend": "Likert scale (1 = worst, 5 = best)",
"dimensions_header": "Dimensions",
"dim_inst_short": "<strong>Instruction following</strong> — prompt adherence, no irrelevant frames.",
"dim_temporal_short": "<strong>Temporal consistency</strong> — person / object / environment stability (incl. anti-flicker).",
"dim_motion_short": "<strong>Realism</strong> — realism (visual + physical), motion logic, camera motion.",
"tips_header": "Tips",
"tip_keys": "Use <kbd>Space</kbd> to play / pause all videos in sync, <kbd>R</kbd> to restart, <kbd>L</kbd> to toggle loop.",
"tip_nav": "Use <kbd>←</kbd> / <kbd>→</kbd> (or <kbd>j</kbd> / <kbd>k</kbd>) to jump between cases.",
"tip_autosave": "Ratings autosave to your browser as you go — but they only count once you click <strong>Submit study</strong>.",
"tip_anon": "Videos are anonymized by default — model identity is hidden unless an admin toggles reveal.",
"tip_missing": "If a tile shows \"no video file\", the manifest path doesn't exist on disk yet — drop a real <code>.mp4</code> at that path and refresh.",
"tip_save_then_submit": "Click <code>💾</code> (Save progress) at any time — in-progress ratings stay safe. <strong>You must click <code>Submit study</code> once every question is filled in</strong>; otherwise your scores do not count.",
"tip_audio": "For BGM / soundtrack / subtitle questions, listen to the video — toggle the speaker button to unmute.",
"scale_1": "Very poor",
"scale_2": "Poor",
"scale_3": "Fair",
"scale_4": "Good",
"scale_5": "Excellent",
"reg_title": "Register to start rating",
"reg_lead": "We record who submitted each set of ratings so we can match results back to participants and resolve duplicates. No password — just your name and an email address that uniquely identifies you.",
"reg_name": "Your name",
"reg_name_ph": "e.g. Alex Chen",
"reg_email": "Email",
"reg_email_ph": "e.g. alex@example.com",
"reg_note": "Affiliation / notes (optional)",
"reg_note_ph": "e.g. INSAIT, internal pilot, etc.",
"reg_continue": "Continue →",
"reg_side_note": "Your ratings are saved locally as you go. Submission is a single final action when 100% complete.",
"reg_foot": "You can change your name later in the toolbar. Once you submit the study, your name + email + ratings are written to the <code>dehezhang2/Frameworker_User_Study</code> dataset on Hugging Face.",
"reg_err_name": "Please enter your name.",
"reg_err_email": "Please enter a valid email address.",
"submitted_title": "Submission recorded",
"submitted_thanks": "Thanks, {0}. We recorded {1} ratings across {2} videos to the dataset.",
"submitted_close": "Close",
"submitted_download": "↓ Also download backup JSON",
"toast_registered_as": "Registered as {0}",
"toast_pls_signin": "Please sign in before submitting",
"toast_missing_first": "{0} ratings still missing — first: {1}",
"toast_no_server": "No server reachable — use ↓ Export JSON to save your submission",
"toast_submit_failed_http": "Submit failed (HTTP {0})",
"toast_submit_failed": "Submit failed: {0}",
"toast_exported": "Exported JSON",
"toast_progress_saved": "Progress saved ({0} ratings)",
"toast_progress_save_failed": "Progress save failed: {0}",
"toast_registered_saved": "Registered as {0} — start time recorded on the server",
"toast_cleared_case": "Cleared ratings for {0}",
"toast_reset_all": "All ratings reset",
"toast_loaded_example": "Loaded cases.example.json — drop a real cases.json to override",
"toast_server_not_configured": "Server not configured — Export JSON instead",
"toast_save_failed": "⚠ failed to save: {0}",
"confirm_reset_case": "Reset all ratings for {0}?",
"confirm_reset_all": "Reset all ratings, registration, and rater id? This cannot be undone.",
"pill_server_ready_detail": "server ready — submissions go to {0}",
"pill_no_token_detail": "server reachable but HF_TOKEN secret is missing",
"pill_unhealthy": "server unhealthy",
"pill_health_http": "health probe HTTP {0}",
"pill_no_endpoint": "no /api/submit endpoint reachable",
"pill_uploading": "uploading to dataset…",
"pill_recorded_as": "recorded as {0} ({1} scores)",
"pill_changed_after_submit": "ratings changed after submission — submit again to update",
"empty_app_title": "Video user study",
"empty_app_intro_html": "This page expects a manifest at <code>./cases.json</code> describing the cases to rate. A working example lives at <code>./cases.example.json</code>.",
"empty_app_format_header": "cases.json format",
"empty_app_serve_header": "Then serve this directory and open the page:",
"empty_app_missing_tip_html": "Tip: missing video files render as a striped \"no video\" placeholder, so you can scaffold the rating UI before any real <code>.mp4</code>s exist.",
"empty_app_load_error": "Could not load ./cases.json or ./cases.example.json — either run via python -m http.server, or drop a manifest in this directory.",
"empty_app_empty_manifest": "Manifest is empty — add at least one case to cases.json.",
},
zh: {
"title": "视频用户研究 — 并排评分",
"subtitle": "{0} 个案例 · 3 个维度 · 每个视频 {1} 个子问题 · 1–5 分李克特量表",
"lang_label": "Language",
"rater": "评分人:",
"rater_placeholder": "您的姓名",
"rater_edit_title": "点击修改姓名",
"anonymized": "🔒 已匿名",
"reveal_models": "👁 显示模型名称",
"submit_pending": "提交研究(还剩 {0} 项)",
"submit_ready": "提交研究({0} 项评分)",
"submit_resubmit": "✓ 重新提交",
"submit_disabled_title": "请回答全部问题后再提交 — 共 {1} 项中尚有 {0} 项未完成",
"submit_ready_title": "将完整评分发送至数据集",
"submit_resubmit_title": "您已提交过;再次点击将覆盖",
"submit_enable_hint": "请完成全部评分后再启用",
"save_progress": "💾 保存进度",
"save_progress_title": "将当前评分上传到数据集的 submissions/in_progress 目录",
"export_json": "↓ 导出 JSON",
"export_title": "将评分另存为本地备份",
"admin_badge": "⚙ 管理员",
"admin_badge_title": "您的姓名在管理员列表中 — 提交将标记 is_admin=true",
"reset_case": "↺ 重置当前案例",
"reset_all": "✕ 全部重置",
"submitted_badge": "✓ 已提交",
"submitted_badge_title": "您已提交;继续编辑将清除此标记",
"pill_idle": "● 服务器就绪",
"pill_submitting": "↻ 正在提交…",
"pill_submitted": "✓ 已提交",
"pill_local_only": "💾 仅本地",
"pill_error": "✕ 提交出错",
"pill_checking": "● 检查中…",
"pill_default_title": "全部完成后点击「提交研究」",
"cases_n": "案例 ({0})",
"case_n_of_m": "第 {0} / {1} 个案例",
"prompt_label": "提示词 / 指令",
"no_prompt": "(未提供提示词)",
"play_all": "▶ 全部播放",
"pause": "⏸ 暂停",
"restart": "⏮ 重新开始",
"loop": "↻ 循环",
"mute": "🔇 静音",
"muted": "🔇 已静音",
"sound": "🔊 取消静音",
"speed": "速度:",
"sub_question_col": "子问题",
"video_label_anon": "视频 {0}",
"video_label_revealed": "视频 {0} · {1}",
"video_col_anon": "视频 {0}",
"video_col_revealed": "{0} · {1}",
"no_video_file": "该路径下没有视频文件",
"drop_mp4_hint": "请将 .mp4 文件放入该路径后刷新",
"footer_summary": "案例 {0} / {1} · 本案例已评 {2} / {3} 项",
"footer_complete_suffix": " · ✓ 已完成",
"prev": "← 上一个",
"next": "下一个 →",
"instructions_toggle": "评分说明(点击展开)— 共 3 个维度 / {0} 个子问题,每项 1–5 分",
"instructions_dismiss": "知道了",
"instructions_intro": "请并排观看所有视频,然后<strong>独立</strong>对每个视频的每个子问题打分。评分表按维度分组;视频以列方式呈现。",
"scale_legend": "李克特量表(1 = 最差, 5 = 最佳)",
"dimensions_header": "评估维度",
"dim_inst_short": "<strong>指令遵循</strong> — 提示一致性、无无关画面。",
"dim_temporal_short": "<strong>时间一致性</strong> — 人物 / 物体 / 环境稳定性(含防闪烁)。",
"dim_motion_short": "<strong>真实感</strong> — 真实感、物理规律、运动逻辑、相机运动。",
"tips_header": "操作提示",
"tip_keys": "<kbd>Space</kbd> 同步播放 / 暂停所有视频,<kbd>R</kbd> 重新开始,<kbd>L</kbd> 切换循环。",
"tip_nav": "使用 <kbd>←</kbd> / <kbd>→</kbd> (或 <kbd>j</kbd> / <kbd>k</kbd>) 在案例间切换。",
"tip_autosave": "评分过程中会自动保存在浏览器内 — 但只有在点击<strong>提交研究</strong>之后才会被记录。",
"tip_anon": "默认情况下视频已匿名 — 仅管理员可切换显示模型名称。",
"tip_missing": "若卡片显示「没有视频文件」,说明清单中的路径在磁盘上不存在 — 请将真实的 <code>.mp4</code> 放置在该路径后刷新。",
"tip_save_then_submit": "评分过程中可随时点击 <code>💾</code>(保存进度),已打分的内容会安全保留。<strong>全部完成后必须点击「提交研究」</strong>,否则您的评分不会被计入。",
"tip_audio": "涉及 BGM / 配乐 / 字幕等问题时,请打开视频声音聆听 — 点击扬声器按钮可取消静音。",
"scale_1": "很差",
"scale_2": "差",
"scale_3": "一般",
"scale_4": "好",
"scale_5": "极佳",
"reg_title": "注册以开始评分",
"reg_lead": "我们会记录每份评分的提交者,以便将结果对应到参与者并处理重复提交。无需密码 — 只需您的姓名和一个可唯一识别您的邮箱地址。",
"reg_name": "您的姓名",
"reg_name_ph": "例如:陈小明",
"reg_email": "邮箱",
"reg_email_ph": "例如:xiaoming@example.com",
"reg_note": "所属单位 / 备注(可选)",
"reg_note_ph": "例如:INSAIT、内部预测试 等",
"reg_continue": "继续 →",
"reg_side_note": "您的评分会在过程中自动保存到本地。提交是一次性最终操作,需在 100% 完成后执行。",
"reg_foot": "您之后可在工具栏修改姓名。提交后,姓名 + 邮箱 + 评分将写入 Hugging Face 上的 <code>dehezhang2/Frameworker_User_Study</code> 数据集。",
"reg_err_name": "请输入您的姓名。",
"reg_err_email": "请输入有效的邮箱地址。",
"submitted_title": "提交成功",
"submitted_thanks": "{0},感谢您的参与。我们已将 {2} 个视频 × {1} 项评分写入数据集。",
"submitted_close": "关闭",
"submitted_download": "↓ 同时下载备份 JSON",
"toast_registered_as": "已注册为 {0}",
"toast_pls_signin": "请先登录后再提交",
"toast_missing_first": "尚有 {0} 项未完成 — 例如:{1}",
"toast_no_server": "无法连接服务器 — 请使用 ↓ 导出 JSON 来保存",
"toast_submit_failed_http": "提交失败 (HTTP {0})",
"toast_submit_failed": "提交失败:{0}",
"toast_exported": "已导出 JSON",
"toast_progress_saved": "进度已保存({0} 项评分)",
"toast_progress_save_failed": "保存进度失败:{0}",
"toast_registered_saved": "已注册为 {0} — 起始时间已记录到服务器",
"toast_cleared_case": "已清空 {0} 的评分",
"toast_reset_all": "已重置全部评分",
"toast_loaded_example": "已加载 cases.example.json — 请放入真实的 cases.json 以覆盖",
"toast_server_not_configured": "服务器未配置 — 请改用「导出 JSON」",
"toast_save_failed": "⚠ 保存失败:{0}",
"confirm_reset_case": "确定要清空 {0} 的全部评分吗?",
"confirm_reset_all": "确定要重置全部评分、注册信息和评分人 ID 吗?该操作不可撤销。",
"pill_server_ready_detail": "服务器就绪 — 提交将写入 {0}",
"pill_no_token_detail": "服务器可达但缺少 HF_TOKEN 密钥",
"pill_unhealthy": "服务器不健康",
"pill_health_http": "健康探测 HTTP {0}",
"pill_no_endpoint": "无法访问 /api/submit",
"pill_uploading": "正在上传至数据集…",
"pill_recorded_as": "已记录为 {0}({1} 项评分)",
"pill_changed_after_submit": "提交后又有修改 — 请再次提交以更新",
"empty_app_title": "视频用户研究",
"empty_app_intro_html": "本页面需要在 <code>./cases.json</code> 中提供案例清单。可参考 <code>./cases.example.json</code>。",
"empty_app_format_header": "cases.json 格式",
"empty_app_serve_header": "随后从该目录启动服务并打开页面:",
"empty_app_missing_tip_html": "提示:缺失的视频文件会显示斜条「无视频」占位,因此可以先搭好评分界面,等真实的 <code>.mp4</code> 文件就位后再使用。",
"empty_app_load_error": "无法加载 ./cases.json 或 ./cases.example.json — 请使用 python -m http.server,或将清单文件放入当前目录。",
"empty_app_empty_manifest": "清单为空 — 请在 cases.json 中至少添加一个案例。",
},
bg: {
"title": "Видео потребителско проучване — паралелно оценяване",
"subtitle": "{0} случая · 3 измерения · {1} под-въпроса на видео · Ликерт 1–5",
"lang_label": "Language",
"rater": "Оценител:",
"rater_placeholder": "вашето име",
"rater_edit_title": "щракнете, за да редактирате името",
"anonymized": "🔒 Анонимизирано",
"reveal_models": "👁 Покажи моделите",
"submit_pending": "Изпрати проучването (остават {0})",
"submit_ready": "Изпрати проучването ({0} оценки)",
"submit_resubmit": "✓ Повторно изпращане",
"submit_disabled_title": "оценете всеки въпрос, за да активирате — липсват {0} от {1}",
"submit_ready_title": "изпратете пълния набор от оценки към набора от данни",
"submit_resubmit_title": "вече сте изпратили; щракнете отново, за да презапишете",
"submit_enable_hint": "оценете всеки въпрос, за да активирате",
"save_progress": "💾 Запази прогрес",
"save_progress_title": "качи текущите оценки в submissions/in_progress на набора от данни",
"export_json": "↓ Изтегли JSON",
"export_title": "запази локално копие на оценките",
"admin_badge": "⚙ администратор",
"admin_badge_title": "името ви е в списъка на администраторите — изпращанията се маркират с is_admin=true",
"reset_case": "↺ Изчисти случая",
"reset_all": "✕ Изчисти всичко",
"submitted_badge": "✓ изпратено",
"submitted_badge_title": "вече сте изпратили; следващи промени премахват този етикет",
"pill_idle": "● сървърът е готов",
"pill_submitting": "↻ изпращане…",
"pill_submitted": "✓ изпратено",
"pill_local_only": "💾 само локално",
"pill_error": "✕ грешка при изпращане",
"pill_checking": "● проверка…",
"pill_default_title": "щракнете „Изпрати проучването“, когато сте 100% готови",
"cases_n": "Случаи ({0})",
"case_n_of_m": "Случай {0} от {1}",
"prompt_label": "Подкана / инструкция",
"no_prompt": "(няма подкана)",
"play_all": "▶ Пусни всички",
"pause": "⏸ Пауза",
"restart": "⏮ Започни отначало",
"loop": "↻ Цикъл",
"mute": "🔇 Изключи звук",
"muted": "🔇 Без звук",
"sound": "🔊 Със звук",
"speed": "Скорост:",
"sub_question_col": "Под-въпрос",
"video_label_anon": "Видео {0}",
"video_label_revealed": "Видео {0} · {1}",
"video_col_anon": "Видео {0}",
"video_col_revealed": "{0} · {1}",
"no_video_file": "няма видео файл на този път",
"drop_mp4_hint": "поставете .mp4 файла там и презаредете",
"footer_summary": "Случай {0} / {1} · {2} / {3} оценки за този случай",
"footer_complete_suffix": " · ✓ завършен",
"prev": "← Назад",
"next": "Напред →",
"instructions_toggle": "Как се оценява (щракни за разгъване) — 3 измерения, {0} под-въпроса, по 1–5",
"instructions_dismiss": "Разбрах",
"instructions_intro": "За всеки случай гледайте всички видеа едно до друго и оценявайте <strong>всяко видео самостоятелно</strong> по всеки под-въпрос. Таблицата групира под-въпросите по измерения; видеата са колоните.",
"scale_legend": "Ликертова скала (1 = най-лошо, 5 = най-добро)",
"dimensions_header": "Измерения",
"dim_inst_short": "<strong>Следване на инструкции</strong> — съответствие с подканата, без неуместни кадри.",
"dim_temporal_short": "<strong>Времева съгласуваност</strong> — стабилност на хора / обекти / среда (вкл. анти-трептене).",
"dim_motion_short": "<strong>Реализъм</strong> — реализъм, физика, логика на движението, движение на камерата.",
"tips_header": "Съвети",
"tip_keys": "С <kbd>Space</kbd> пускате / спирате всички видеа синхронно, <kbd>R</kbd> рестартира, <kbd>L</kbd> превключва цикъл.",
"tip_nav": "С <kbd>←</kbd> / <kbd>→</kbd> (или <kbd>j</kbd> / <kbd>k</kbd>) минавате между случаите.",
"tip_autosave": "Оценките се запазват автоматично в браузъра ви — но влизат в сметка чак след като щракнете <strong>Изпрати проучването</strong>.",
"tip_anon": "По подразбиране видеата са анонимизирани — само администратор може да покаже имената на моделите.",
"tip_missing": "Ако плочка показва „няма видео файл“, пътят от манифеста не съществува — поставете истинско <code>.mp4</code> там и презаредете.",
"tip_save_then_submit": "Можете да щракнете <code>💾</code> (Запази прогрес) по всяко време — оценките ви остават запазени. <strong>Когато всички въпроси са попълнени, трябва да щракнете „Изпрати проучването“</strong>, иначе оценките ви не се броят.",
"tip_audio": "За въпроси относно BGM / музика / субтитри слушайте видео звука — превключете бутона за високоговорителя, за да включите звука.",
"scale_1": "Много слабо",
"scale_2": "Слабо",
"scale_3": "Средно",
"scale_4": "Добро",
"scale_5": "Отлично",
"reg_title": "Регистрирайте се, за да започнете оценяване",
"reg_lead": "Записваме кой е изпратил всеки набор от оценки, за да можем да съпоставяме резултатите с участниците и да разрешаваме дубликати. Без парола — само вашето име и имейл, който уникално ви идентифицира.",
"reg_name": "Вашето име",
"reg_name_ph": "напр. Алекс Иванов",
"reg_email": "Имейл",
"reg_email_ph": "напр. alex@example.com",
"reg_note": "Принадлежност / бележки (по избор)",
"reg_note_ph": "напр. INSAIT, вътрешен пилот и т.н.",
"reg_continue": "Продължи →",
"reg_side_note": "Оценките ви се запазват локално в процеса. Изпращането е еднократно финално действие при 100% готовност.",
"reg_foot": "Името можете да промените по-късно в лентата с инструменти. След като изпратите проучването, името + имейлът + оценките се записват в набора от данни <code>dehezhang2/Frameworker_User_Study</code> в Hugging Face.",
"reg_err_name": "Моля, въведете името си.",
"reg_err_email": "Моля, въведете валиден имейл адрес.",
"submitted_title": "Изпращането е записано",
"submitted_thanks": "Благодарим, {0}. Записахме {1} оценки за {2} видеа в набора от данни.",
"submitted_close": "Затвори",
"submitted_download": "↓ Изтегли резервно JSON копие",
"toast_registered_as": "Регистриран(а) като {0}",
"toast_pls_signin": "Моля, влезте преди изпращане",
"toast_missing_first": "Все още липсват {0} оценки — първите: {1}",
"toast_no_server": "Сървърът не е достъпен — използвайте ↓ Изтегли JSON, за да запазите",
"toast_submit_failed_http": "Изпращането се провали (HTTP {0})",
"toast_submit_failed": "Изпращането се провали: {0}",
"toast_exported": "JSON е изтеглен",
"toast_progress_saved": "Прогресът е запазен ({0} оценки)",
"toast_progress_save_failed": "Запазването на прогреса се провали: {0}",
"toast_registered_saved": "Регистриран(а) като {0} — началният час е записан на сървъра",
"toast_cleared_case": "Оценките за {0} са изчистени",
"toast_reset_all": "Всички оценки са изчистени",
"toast_loaded_example": "Заредено cases.example.json — поставете истински cases.json, за да го заместите",
"toast_server_not_configured": "Сървърът не е конфигуриран — използвайте „Изтегли JSON“",
"toast_save_failed": "⚠ грешка при запазване: {0}",
"confirm_reset_case": "Да се изчистят ли всички оценки за {0}?",
"confirm_reset_all": "Да се изчистят всички оценки, регистрация и rater id? Действието е необратимо.",
"pill_server_ready_detail": "сървърът е готов — изпращанията отиват в {0}",
"pill_no_token_detail": "сървърът е достъпен, но липсва тайната HF_TOKEN",
"pill_unhealthy": "сървърът не е здрав",
"pill_health_http": "проверка на здравето HTTP {0}",
"pill_no_endpoint": "няма достъпен /api/submit",
"pill_uploading": "качване в набора от данни…",
"pill_recorded_as": "записано като {0} ({1} оценки)",
"pill_changed_after_submit": "оценките са променени след изпращане — изпратете отново",
"empty_app_title": "Видео потребителско проучване",
"empty_app_intro_html": "Тази страница очаква манифест в <code>./cases.json</code> с описание на случаите за оценяване. Работещ пример има в <code>./cases.example.json</code>.",
"empty_app_format_header": "Формат на cases.json",
"empty_app_serve_header": "След това стартирайте директорията и отворете страницата:",
"empty_app_missing_tip_html": "Съвет: липсващите видеа се показват с раиран „без видео“ заместител, така че можете да подготвите интерфейса преди да съществуват реални <code>.mp4</code> файлове.",
"empty_app_load_error": "Не може да се зареди ./cases.json или ./cases.example.json — стартирайте python -m http.server или поставете манифест в тази директория.",
"empty_app_empty_manifest": "Манифестът е празен — добавете поне един случай в cases.json.",
},
};
// Per-dimension and per-sub-question translations. Keyed by dim.key / q.key.
const RUBRIC_I18N = {
zh: {
instruction_following: { name: "指令遵循", hint: "请先看清提示词,尤其是标红的部分,再作答。" },
temporal_consistency: { name: "时间一致性", hint: "请关注人物、物品和环境在帧间的变化。" },
motion_realism: { name: "真实感", hint: "请留意视频中不和谐或有明显问题的地方。" },
prompt_adherence: { name: "提示一致性",
desc: "内容是否与提示的描述一致(主体、动作、场景、时间、故事走向)?以提示词的文本内容为主要依据。" },
no_irrelevant_frames: { name: "无无关画面",
desc: "视频是否仅呈现提示中要求的内容?分数越高 = 越少出现与提示无关的帧或内容。" },
reference_image_match: { name: "参考图一致性",
desc: "若提示词附带参考图(intake_img 案例),视频中的主角形象是否与参考图一致?仅在存在参考图时打分,无参考图自动跳过。" },
realism: { name: "静态真实感",
desc: "单帧的照片级真实感或风格忠实度。若提示词指定了风格,视频应符合该风格。无诡异感或失真伪影。" },
person_stability: { name: "人物稳定性",
desc: "面部、身份及身体比例在帧间保持一致。" },
object_stability: { name: "物体稳定性",
desc: "道具、服饰及物体随时间保持形状和位置。" },
environment_stability: { name: "环境稳定性",
desc: "背景、光照及场景布局在帧间保持连贯(包括无亮度、色彩或纹理闪烁)。" },
motion_logic: { name: "运动逻辑",
desc: "身体与物体的运动是否流畅且符合物理与常识规律(重力、碰撞、流体/布料动力学)?无形变、瞬移、不合常理或不可能的动作。若几乎没有运动,请打低分。" },
camera: { name: "相机运动",
desc: "相机运动(平移、推拉、焦距变化)自然且有意图。若几乎没有相机运动,请打低分。" },
},
bg: {
instruction_following: { name: "Следване на инструкции",
hint: "Прочетете внимателно подканата — особено маркираните в червено фрази — преди да оцените." },
temporal_consistency: { name: "Времева съгласуваност",
hint: "Съсредоточете се върху промените на хора, предмети и среда между кадрите." },
motion_realism: { name: "Реализъм",
hint: "Забележете очевидни проблеми или неестествени моменти във видеото." },
prompt_adherence: { name: "Съответствие с подканата",
desc: "Съответства ли съдържанието на описаното в подканата (обекти, действия, обстановка, време и развитие на историята)? Фокусирайте се основно върху текстовото съдържание на подканата." },
no_irrelevant_frames: { name: "Без неуместни кадри",
desc: "Остава ли видеото в рамките на подканата? По-висок = по-малко / без кадри или съдържание, които не са поискани." },
reference_image_match: { name: "Съответствие с референтното изображение",
desc: "Когато подканата включва референтна снимка (случаи intake_img), съответства ли главният герой / външен вид на видеото? Оценява се само при наличие на референтно изображение — иначе се прескача." },
realism: { name: "Статичен реализъм",
desc: "Фотореалистичност или стилова достоверност на отделен кадър. Ако подканата задава стил, видеото трябва да следва този стил. Без странни или счупени артефакти." },
person_stability: { name: "Стабилност на хората",
desc: "Лица, идентичности и пропорции на тялото остават постоянни между кадрите." },
object_stability: { name: "Стабилност на обектите",
desc: "Реквизити, дрехи и обекти запазват форма и позиция във времето." },
environment_stability: { name: "Стабилност на средата",
desc: "Фон, осветление и подредба на сцената остават последователни между кадрите (включително липса на трептене на яркост, цвят или текстура)." },
motion_logic: { name: "Логика на движението",
desc: "Текат ли движенията на тела и обекти плавно и логично? Без деформации, телепортация, нелогични или невъзможни движения. Дайте нисък резултат, ако има малко или никакво движение." },
camera: { name: "Камера",
desc: "Движението на камерата (панорама, дъли, промяна на фокус) изглежда естествено и целенасочено. Дайте нисък резултат, ако има малко или никакво движение на камерата." },
},
};
function t(key, ...args) {
let s = (STRINGS[LANG] && STRINGS[LANG][key]) ?? STRINGS.en[key] ?? key;
if (args.length) {
s = s.replace(/\{(\d+)\}/g, (_, i) => args[Number(i)] ?? "");
}
return s;
}
function dimName(dim) {
if (LANG === "en") return dim.name;
return RUBRIC_I18N[LANG]?.[dim.key]?.name ?? dim.name;
}
function dimHint(dim) {
if (LANG === "en") return dim.hint;
return RUBRIC_I18N[LANG]?.[dim.key]?.hint ?? dim.hint;
}
function qName(q) {
if (LANG === "en") return q.name;
return RUBRIC_I18N[LANG]?.[q.key]?.name ?? q.name;
}
function qDesc(q) {
if (LANG === "en") return q.desc;
return RUBRIC_I18N[LANG]?.[q.key]?.desc ?? q.desc;
}
function scaleLabel(score) {
return t(`scale_${score}`);
}
/* =========================================================================
* State
* ========================================================================= */
let CASES = []; // cases visible to this rater (subset of ALL)
let ALL_CASES = []; // full manifest as fetched from cases.json
const CASES_PER_RATER = 10; // each rater sees 10 of the full set
let VIEW_AS_MODE = false; // admin "view-as" — page is read-only
let VIEW_AS_META = null; // { rater_name, rater_email, ... } for the banner
let SHUFFLE = {}; // case_id -> [orig_idx, ...] (slot -> orig)
let RATINGS = {}; // case_id -> model_name -> { qkey: 1..5 }
let RATER = { id: "", name: "", email: "", note: "",
started_at: null, submitted_at: null };
let currentIdx = 0;
let anonymize = true;
// Per-case active video slot for the tabbed left pane. Defaults to 0 (= the
// first slot in the case-level shuffle order). Reset when the user navigates.
let ACTIVE_SLOT = {};
// Submission status — pill in the toolbar reflects this.
// idle : server reachable but nothing submitted yet
// submitting : POST /api/submit in flight
// submitted : server returned 200 (final write recorded)
// local-only : no /api/submit endpoint reachable (e.g. running locally)
// error : POST /api/submit failed
let SYNC_STATUS = "idle";
let SYNC_LAST_DETAIL = "";
const API_SUBMIT = "./api/submit";
const API_HEALTH = "./api/health";
const API_SAVE_PROGRESS = "./api/save_progress";
const API_ANNOTATIONS = "./api/annotations";
// Admin-curated highlight snippets, fetched at init from /api/annotations.
// Shape: { case_id: { lang: ["snippet 1", "snippet 2", ...] } }
let ADMIN_HIGHLIGHTS = {};
// Debounce window for auto-save after the latest rating change.
const PROGRESS_DEBOUNCE_MS = 60000;
let PROGRESS_TIMER = null;
let LAST_PROGRESS_SAVED_AT = null;
/* =========================================================================
* Storage
* ========================================================================= */
function loadState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch (e) { return null; }
}
function saveState() {
// View-as is read-only — never touch localStorage with the impersonated state.
if (VIEW_AS_MODE) return;
const blob = {
rater: RATER,
anonymize,
lang: LANG,
shuffle: SHUFFLE,
ratings: RATINGS,
saved_at: new Date().toISOString(),
};
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(blob));
} catch (e) {
showToast(t("toast_save_failed", e.message), true);
}
}
function genRaterId() {
return "rater_" + Math.random().toString(36).slice(2, 8) +
"_" + Date.now().toString(36).slice(-4);
}
/* =========================================================================
* Shuffling (anonymization)
* ========================================================================= */
// Per-rater 10-case subset of the manifest. Admins see everything.
//
// Once a (non-admin) rater registers, the 10 case ids they were assigned are
// locked into RATER.chosen_case_ids and written to localStorage + every
// server-side in-progress save. On subsequent visits — and on a fresh
// device, via GET /api/rater_state — the locked list is restored verbatim
// so the rater always sees the same 10 cases regardless of any future
// manifest changes (new cases, reordering, etc.).
function pickRandomSubsetIds(allCases) {
// Deterministic Fisher–Yates seeded by rater id, sorted back into manifest
// order. Returns CASES_PER_RATER ids.
const idx = allCases.map((_, i) => i);
let s = hashStr((RATER.id || "anon") + ":case-subset");
for (let k = 0; k < 6; k++) s = (s * 1664525 + 1013904223) >>> 0;
for (let i = idx.length - 1; i > 0; i--) {
s = (s * 1664525 + 1013904223) >>> 0;
const j = (s >>> 16) % (i + 1);
[idx[i], idx[j]] = [idx[j], idx[i]];
}
return idx.slice(0, CASES_PER_RATER).sort((a, b) => a - b)
.map(i => allCases[i].id);
}
// Coverage-first assignment: ask the server which cases have the fewest
// raters so far, so un-rated videos get picked up before already-rated ones.
// Advisory only — no-op if a locked list already exists, and any failure
// (offline, server-less static hosting) falls back to the seeded random
// pick in selectCasesForRater.
async function assignCasesFromServer() {
if (isAdmin()) return;
if (Array.isArray(RATER.chosen_case_ids) && RATER.chosen_case_ids.length) return;
if (!Array.isArray(ALL_CASES) || ALL_CASES.length <= CASES_PER_RATER) return;
try {
const r = await fetch(
`./api/assign_cases?email=${encodeURIComponent(RATER.email || "")}&n=${CASES_PER_RATER}`,
{ cache: "no-cache" });
if (!r.ok) return;
const j = await r.json();
const valid = new Set(ALL_CASES.map(c => c.id));
const ids = (Array.isArray(j.case_ids) ? j.case_ids : []).filter(id => valid.has(id));
if (ids.length >= Math.min(CASES_PER_RATER, ALL_CASES.length)) {
RATER.chosen_case_ids = ids;
}
} catch (e) { /* fall back to seeded random */ }
}
function selectCasesForRater(allCases) {
if (isAdmin()) return allCases.slice();
if (allCases.length <= CASES_PER_RATER) return allCases.slice();
// Locked list wins if present and at least partially valid.
const locked = Array.isArray(RATER.chosen_case_ids) ? RATER.chosen_case_ids : null;
if (locked && locked.length) {
const byId = new Map(allCases.map(c => [c.id, c]));
const out = locked.map(id => byId.get(id)).filter(Boolean);
if (out.length >= Math.min(CASES_PER_RATER, allCases.length)) return out;
}
// First time for this rater: pick — but only LOCK the list once the rater
// has registered (email set). Pre-registration renders also land here with
// a blank rater; locking then would freeze a random subset before
// assignCasesFromServer ever gets a chance to do its coverage-first pick.
const ids = pickRandomSubsetIds(allCases);
if (RATER.email) RATER.chosen_case_ids = ids;
// saveState() will be called by the caller; we don't call it here to keep
// this function pure-ish, but the locked list is now on the in-memory RATER.
const byId = new Map(allCases.map(c => [c.id, c]));
return ids.map(id => byId.get(id)).filter(Boolean);
}
function buildShuffle(cases) {
// Deterministic per (rater, case) and ALWAYS recomputed on load. The
// recomputation is idempotent (same seed → same order), so it costs nothing
// for raters whose cached SHUFFLE already matches; it also forces a refresh
// of any stale per-case order saved by older code paths (e.g. when the
// admin reveal toggle used to short-circuit the shuffle and lock ours into
// slot A). The shuffle is independent of the reveal toggle now — the
// toggle only controls whether the model name is rendered next to the
// slot letter.
for (const c of cases) {
const indices = c.videos.map((_, i) => i);
let s = hashStr(RATER.id + ":" + c.id);
for (let k = 0; k < 6; k++) s = (s * 1664525 + 1013904223) >>> 0;
for (let i = indices.length - 1; i > 0; i--) {
s = (s * 1664525 + 1013904223) >>> 0;
const j = (s >>> 16) % (i + 1);
[indices[i], indices[j]] = [indices[j], indices[i]];
}
SHUFFLE[c.id] = indices;
}
}
function hashStr(s) {
let h = 2166136261 >>> 0;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = (h * 16777619) >>> 0;
}
return h;
}
/* =========================================================================
* Rating helpers
* ========================================================================= */
function getCaseRatings(caseId) {
if (!RATINGS[caseId]) RATINGS[caseId] = {};
return RATINGS[caseId];
}
// Translate (caseId, slotIdx) → the model name in that slot under the
// current shuffle. Returns null if the case / slot isn't known yet.
function modelForSlot(caseId, slotIdx) {
const c = CASES.find(x => x.id === caseId);
const slots = SHUFFLE[caseId];
if (!c || !slots) return null;
const origIdx = slots[slotIdx];
return c.videos[origIdx]?.model ?? null;
}
function getModelRatings(caseId, model) {
const m = getCaseRatings(caseId);
if (!m[model]) m[model] = {};
return m[model];
}
// Back-compat wrapper for the per-slot path in render code; resolves slot →
// model and returns the matching ratings dict (auto-creates if absent).
function getSlotRatings(caseId, slotIdx) {
const model = modelForSlot(caseId, slotIdx);
if (!model) return {};
return getModelRatings(caseId, model);
}
function setRating(caseId, slotIdx, qkey, score) {
if (VIEW_AS_MODE) {
showToast("View-as is read-only — open the regular study to rate.", true);
return;
}
const model = modelForSlot(caseId, slotIdx);
if (!model) return;
getModelRatings(caseId, model)[qkey] = score;
saveState();
updateProgress();
updateCaseStatus(caseId);
updateSubmitButton();
// Editing after submitting clears the "submitted" badge so the user can
// re-submit a corrected payload.
if (RATER.submitted_at) {
RATER.submitted_at = null;
saveState();
setSyncStatus(SYNC_STATUS === "local-only" ? "local-only" : "idle",
t("pill_changed_after_submit"));
}
// Debounced server-side progress save (one file per rater, overwritten).
scheduleProgressSave();
}
/* =========================================================================
* Final submission — POST the whole study to /api/submit when complete.
* Per-case auto-sync was removed deliberately: writing partial files for
* every keystroke would flood the dataset with throwaway commits.
* ========================================================================= */
function buildFullSubmissionPayload() {
return {
rater: RATER,
anonymize_used: anonymize,
rubric: DIMENSIONS,
submitted_at: new Date().toISOString(),
cases: CASES.map(c => {
const slots = SHUFFLE[c.id] || c.videos.map((_, i) => i);
return {
id: c.id,
category: c.category || null,
prompt: c.prompt,
videos: slots.map((origIdx, slotIdx) => {
const v = c.videos[origIdx];
return {
slot: String.fromCharCode(65 + slotIdx),
slot_index: slotIdx,
original_index: origIdx,
model: v.model,
path: v.path,
ratings: (RATINGS[c.id] || {})[v.model] || {},
};
}),
};
}),
};
}
// Returns { done, expected, missing: [{case_id, slot, q}, ...] } for the entire study.
// Returns the rubric questions actually applicable to a given case. Skips
// `only_if_reference_image: true` questions when the case has no reference
// photo.
function questionsForCase(c) {
const hasRef = !!c.reference_image;
return DIMENSIONS.flatMap(d => d.questions.filter(q => !q.only_if_reference_image || hasRef));
}
function studyCompleteness() {
const missing = [];
let done = 0, expected = 0;
for (const c of CASES) {
const slots = SHUFFLE[c.id] || c.videos.map((_, i) => i);
const qs = questionsForCase(c);
for (let slotIdx = 0; slotIdx < slots.length; slotIdx++) {
const origIdx = slots[slotIdx];
const model = c.videos[origIdx].model;
const rs = (RATINGS[c.id] || {})[model] || {};
const slotLetter = String.fromCharCode(65 + slotIdx);
for (const q of qs) {
expected++;
const v = rs[q.key];
if (typeof v === "number" && v >= 1 && v <= 5) done++;
else missing.push({ case_id: c.id, slot: slotLetter, q: q.key, model });
}
}
}
return { done, expected, missing };
}
async function submitWholeStudy() {
if (VIEW_AS_MODE) {
showToast("View-as is read-only — submit is disabled.", true);
return;
}
if (!RATER.email || !RATER.name) {
showToast(t("toast_pls_signin"), true);
showLoginOverlay(true);
return;
}
const comp = studyCompleteness();
if (comp.missing.length > 0) {
const first = comp.missing.slice(0, 3)
.map(m => `${m.case_id}/${m.slot}/${m.q}`).join(", ");
showToast(t("toast_missing_first", comp.missing.length, first), true);
// Jump to the first incomplete case to make it easy to finish.
const firstCaseId = comp.missing[0].case_id;
const idx = CASES.findIndex(c => c.id === firstCaseId);
if (idx >= 0) goTo(idx);
return;
}
if (SYNC_STATUS === "local-only") {
showToast(t("toast_no_server"), true);
return;
}
setSyncStatus("submitting", t("pill_uploading"));
const payload = buildFullSubmissionPayload();
try {
const resp = await fetch(API_SUBMIT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.ok) {
const body = await resp.json().catch(() => ({}));
RATER.submitted_at = new Date().toISOString();
saveState();
setSyncStatus("submitted",
t("pill_recorded_as", body.stored_as || "submission", body.n_scores ?? "?"));
updateSubmitButton();
showSubmittedOverlay(body);
} else if (resp.status === 404 || resp.status === 503) {
setSyncStatus("local-only", `HTTP ${resp.status}`);
showToast(t("toast_server_not_configured"), true);
} else {
const text = await resp.text().catch(() => "");
setSyncStatus("error", `HTTP ${resp.status}: ${text.slice(0, 120)}`);
showToast(t("toast_submit_failed_http", resp.status), true);
}
} catch (e) {
setSyncStatus("local-only", "POST /api/submit failed: " + e.message);
showToast(t("toast_submit_failed", e.message), true);
}
}
/* ----- Mid-study progress save ---------------------------------------- */
// Returns the same shape as the final-submission payload but tolerates
// incomplete ratings. The server writes this to submissions/in_progress/<slug>.json
// (one file per rater, overwritten on each save) so progress survives across
// sessions even if localStorage is wiped.
function buildProgressPayload() {
const comp = studyCompleteness();
return {
rater: RATER,
anonymize_used: anonymize,
is_admin: isAdmin(),
rubric: DIMENSIONS,
progress_saved_at: new Date().toISOString(),
completeness: {
done: comp.done,
expected: comp.expected,
missing_count: comp.missing.length,
},
cases: CASES.map(c => {
const slots = SHUFFLE[c.id] || c.videos.map((_, i) => i);
return {
id: c.id,
category: c.category || null,
prompt: c.prompt,
videos: slots.map((origIdx, slotIdx) => {
const v = c.videos[origIdx];
return {
slot: String.fromCharCode(65 + slotIdx),
slot_index: slotIdx,
original_index: origIdx,
model: v.model,
path: v.path,
ratings: (RATINGS[c.id] || {})[v.model] || {},
};
}),
};
}),
};
}
async function saveProgress(opts = {}) {
if (VIEW_AS_MODE) return; // never write back when viewing as someone else
// opts: { silent: bool, source: "register" | "auto" | "manual" | "unload" }
const silent = !!opts.silent;
if (!RATER.email || !RATER.name) return; // no rater yet
if (SYNC_STATUS === "local-only" && opts.source !== "manual") return;
const payload = buildProgressPayload();
try {
const resp = await fetch(API_SAVE_PROGRESS, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.ok) {
const body = await resp.json().catch(() => ({}));
LAST_PROGRESS_SAVED_AT = new Date().toISOString();
if (!silent) showToast(t("toast_progress_saved", body.n_scores ?? 0));
} else if (resp.status === 404 || resp.status === 503) {
// Server not present (e.g. running against a static host); switch off
// auto-save so we don't keep retrying every minute.
setSyncStatus("local-only", `save_progress HTTP ${resp.status}`);
} else if (!silent) {
showToast(t("toast_progress_save_failed", "HTTP " + resp.status), true);
}
} catch (e) {
if (!silent) showToast(t("toast_progress_save_failed", e.message), true);
}
}
function scheduleProgressSave() {
if (!RATER.email) return;
if (SYNC_STATUS === "local-only") return;
if (PROGRESS_TIMER) clearTimeout(PROGRESS_TIMER);
PROGRESS_TIMER = setTimeout(() => {
PROGRESS_TIMER = null;
saveProgress({ silent: true, source: "auto" });
}, PROGRESS_DEBOUNCE_MS);
}
// Fire-and-forget best-effort save when the tab is closed or navigated away.
// `fetch` from beforeunload is unreliable; sendBeacon is designed for this.
function beaconProgress() {
if (!RATER.email || !RATER.name) return;
if (SYNC_STATUS === "local-only") return;
try {
const blob = new Blob(
[JSON.stringify(buildProgressPayload())],
{ type: "application/json" });
navigator.sendBeacon(API_SAVE_PROGRESS, blob);
} catch (e) { /* best-effort, ignore */ }
}
function setSyncStatus(state, detail) {
SYNC_STATUS = state;
if (detail) SYNC_LAST_DETAIL = detail;
const pill = document.getElementById("syncPill");
if (!pill) return;
const labelKey = {
idle: "pill_idle",
submitting: "pill_submitting",
submitted: "pill_submitted",
"local-only": "pill_local_only",
error: "pill_error",
}[state];
pill.textContent = labelKey ? t(labelKey) : state;
pill.dataset.state = state;
pill.title = SYNC_LAST_DETAIL || t("pill_default_title");
}
async function probeSyncEndpoint() {
try {
const resp = await fetch(API_HEALTH, { method: "GET", cache: "no-cache" });
if (resp.ok) {
const j = await resp.json().catch(() => ({}));
const ok = j.ok && j.has_token;
setSyncStatus(ok ? "idle" : "local-only",
ok ? t("pill_server_ready_detail", j.dataset || "dataset")
: (j.has_token === false ? t("pill_no_token_detail") : t("pill_unhealthy")));
} else {
setSyncStatus("local-only", t("pill_health_http", resp.status));
}
} catch (e) {
setSyncStatus("local-only", t("pill_no_endpoint"));
}
}
function caseProgress(caseCase) {
const slots = SHUFFLE[caseCase.id] || [];
const expected = slots.length * TOTAL_QUESTIONS;
let done = 0;
for (let s = 0; s < slots.length; s++) {
const origIdx = slots[s];
const model = caseCase.videos[origIdx]?.model;
const r = (RATINGS[caseCase.id] || {})[model] || {};
done += Object.keys(r).length;
}
return { done, expected };
}
function totalProgress() {
let done = 0, expected = 0;
for (const c of CASES) {
const p = caseProgress(c);
done += p.done; expected += p.expected;
}
return { done, expected };
}
/* =========================================================================
* DOM helpers
* ========================================================================= */
function el(tag, attrs = {}, ...children) {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === "class") e.className = v;
else if (k === "html") e.innerHTML = v;
else if (k.startsWith("on") && typeof v === "function") {
e.addEventListener(k.slice(2).toLowerCase(), v);
} else if (v !== false && v != null) e.setAttribute(k, v);
}
for (const c of children) {
if (c == null || c === false) continue;
e.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
}
return e;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c =>
({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]);
}
/* Patterns flagging controllability cues — imperative verbs and the
* directive phrases that follow. Returned matches are wrapped in a
* <span class="ctrl"> so the rater can immediately see what the prompt
* actually controls (subjects, identities, sound design, subtitles, BGM).
* Per-language because the imperative forms differ. */
const CTRL_PATTERNS = {
en: [
// "Add ambient sounds: ..." / "Add a XXX BGM." / "Add Chinese subtitles ..."
/\bAdd\b[^.]*\./g,
// "Use this uploaded photo as the look of the cultivation male lead."
/\bUse this[^.]*\./g,
// Leading "Make a <genre> animated short drama" up to the em-dash.
/Make a[^—–]*?animated short drama/g,
],
zh: [
/制作一部[^—–]*?短剧/g,
/请?添加[^。]*。/g,
/请使用上传的[^。]*。/g,
/配以[^。]*。/g,
/环境音[::][^。]*。/g,
],
bg: [
/Създайте[^—–]*?анимационна кратка драма/g,
/Добавете[^.]*\./g,
/Използвайте качената[^.]*\./g,
],
};
function highlightControllability(text, lang, caseId) {
// Operate on the escaped string so the wrap-spans we inject stay the only
// un-escaped HTML in the output.
let html = escapeHtml(text);
const patterns = CTRL_PATTERNS[lang] || CTRL_PATTERNS.en;
// Two source of ranges: auto-detected (CTRL_PATTERNS) + admin-curated
// snippets loaded from /api/annotations. We tag each range with its kind
// so the rendered span can carry a class telling them apart.
const ranges = []; // [start, end, kind]
for (const re of patterns) {
re.lastIndex = 0;
let m;
while ((m = re.exec(html)) !== null) {
ranges.push([m.index, m.index + m[0].length, "auto"]);
if (m[0].length === 0) re.lastIndex++; // safety vs zero-width
}
}
const adminSnippets = (caseId && ADMIN_HIGHLIGHTS[caseId]?.[lang]) || [];
for (const snippet of adminSnippets) {
if (!snippet) continue;
const esc = escapeHtml(snippet);
let idx = 0;
while ((idx = html.indexOf(esc, idx)) !== -1) {
ranges.push([idx, idx + esc.length, "admin"]);
idx += esc.length || 1;
}
}
if (!ranges.length) return html;
// Merge overlapping / adjacent ranges; if any chunk of the merged range
// was admin-tagged, the whole merged span gets the admin class.
ranges.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const merged = [ranges[0].slice()];
for (let i = 1; i < ranges.length; i++) {
const top = merged[merged.length - 1];
// Strict `<`: only merge ranges that actually overlap. Adjacent ranges
// (one ending exactly where the next begins) stay as separate spans,
// which keeps Chinese punctuation-flush sentences highlighted as two
// distinct red regions matching the en/bg layouts.
if (ranges[i][0] < top[1]) {
top[1] = Math.max(top[1], ranges[i][1]);
if (ranges[i][2] === "admin") top[2] = "admin";
} else merged.push(ranges[i].slice());
}
// Splice in spans from right to left so earlier indices stay valid.
let out = html;
for (let i = merged.length - 1; i >= 0; i--) {
const [s, e, kind] = merged[i];
const cls = kind === "admin" ? "ctrl ctrl-admin" : "ctrl";
out = out.slice(0, s)
+ `<span class="${cls}">` + out.slice(s, e) + '</span>'
+ out.slice(e);
}
return out;
}
function showToast(msg, isError = false) {
const t = document.getElementById("toast");
t.textContent = msg;
t.style.background = isError ? "var(--bad)" : "var(--good)";
t.style.color = isError ? "#fff" : "#0a1a0a";
t.classList.add("show");
setTimeout(() => t.classList.remove("show"), 1800);
}
/* =========================================================================
* Rendering — shell
* ========================================================================= */
function renderShell() {
const app = document.getElementById("app");
app.innerHTML = "";
app.appendChild(renderHeader());
app.appendChild(renderToolbar());
const main = el("main");
// Thin vertical rail on the left edge that re-opens the (default-hidden)
// sidebar. Visible via CSS only when body lacks `.sidebar-expanded`.
main.appendChild(el("div", {
class: "sidebar-rail",
title: "Show cases sidebar",
onclick: openSidebar,
}, "› Cases"));
main.appendChild(renderSidebar());
main.appendChild(el("section", { class: "detail", id: "detail" }));
app.appendChild(main);
app.appendChild(renderFooter());
renderDetail();
updateProgress();
updateSubmitButton();
// Refresh the sync pill text from whatever state we were last in.
setSyncStatus(SYNC_STATUS, SYNC_LAST_DETAIL);
// Re-attach the view-as banner — renderShell wipes #app on every re-render
// (e.g. when the admin clicks Prev/Next or switches case in the sidebar).
if (VIEW_AS_MODE) addViewAsBanner();
}
function addViewAsBanner() {
const app = document.getElementById("app");
if (!app || document.getElementById("viewAsBanner")) return;
const who = VIEW_AS_META?.rater_name || VIEW_AS_META?.rater_email || "(unknown)";
const bar = el("div", { id: "viewAsBanner",
style: "background:#3a2f60;color:#d9b6ff;border-bottom:1px solid #5a4080;"
+ "padding:8px 16px;display:flex;align-items:center;gap:12px;"
+ "font-size:13px;font-weight:600;" },
el("span", {}, `👁 Viewing as ${who} · read-only`),
el("a", { href: "./admin.html", target: "_self",
style: "margin-left:auto;color:#d9b6ff;text-decoration:underline;font-size:12px" },
"← back to admin"),
el("button", {
style: "background:#5a4080;border:1px solid #6a4f90;color:#fff;"
+ "padding:4px 10px;border-radius:4px;cursor:pointer;font-size:12px",
onclick: () => {
try { sessionStorage.removeItem("user_study_view_as"); } catch (e) {}
location.replace("./");
},
}, "✕ Exit view-as"),
);
app.insertBefore(bar, app.firstChild);
}
function renderHeader() {
// Title + subtitle removed per request — only the progress bar remains in
// the top strip. The sidebar toggle now lives inside the sidebar header,
// not here.
return el("header", { class: "top" },
el("div", { class: "progress" },
el("div", { class: "progress-bar" },
el("div", { class: "progress-fill", id: "progressFill" })),
el("span", { class: "progress-text", id: "progressText" }, "0 / 0"),
),
);
}
function openSidebar() {
document.body.classList.add("sidebar-expanded");
try { localStorage.setItem("user_study_sidebar_expanded", "1"); } catch (e) {}
renderShell();
}
function closeSidebar() {
document.body.classList.remove("sidebar-expanded");
try { localStorage.setItem("user_study_sidebar_expanded", "0"); } catch (e) {}
renderShell();
}
function renderLangSelect() {
const sel = el("select", {
title: t("lang_label"),
onchange: (e) => {
LANG = e.target.value;
document.documentElement.lang = LANG;
saveState();
renderShell();
applyRegistrationOverlayI18n();
},
});
for (const L of LANGUAGES) {
const opt = el("option", { value: L.code }, L.label);
if (L.code === LANG) opt.setAttribute("selected", "");
sel.appendChild(opt);
}
return sel;
}
function renderToolbar() {
const tb = el("div", { class: "toolbar" });
const nameInput = el("input", {
type: "text",
placeholder: t("rater_placeholder"),
value: RATER.name || "",
title: t("rater_edit_title"),
onchange: (e) => {
const newName = e.target.value.trim();
if (!newName) { e.target.value = RATER.name; return; }
RATER.name = newName;
// Admin status may have changed → re-pick the visible case subset.
CASES = selectCasesForRater(ALL_CASES);
SHUFFLE = {}; buildShuffle(CASES);
saveState();
renderShell();
},
});
tb.appendChild(el("div", { class: "rater-box" },
el("span", {}, t("rater")),
nameInput,
el("span", {
style: "color:var(--muted-2);font-size:11px;font-family:ui-monospace,monospace"
}, RATER.email || ""),
));
if (isAdmin()) {
tb.appendChild(el("span", { class: "submitted-badge",
style: "background:#3a2f60;color:#d9b6ff;border-color:#5a4080",
title: t("admin_badge_title") },
t("admin_badge")));
}
if (RATER.submitted_at) {
tb.appendChild(el("span", { class: "submitted-badge",
title: t("submitted_badge_title") },
t("submitted_badge")));
}
tb.appendChild(el("div", { class: "sep" }));
// Admin-only reveal toggle + a link to the stats dashboard. Non-admins
// always see anonymized view (and anonymize is force-set to true on first
// paint below).
if (isAdmin()) {
tb.appendChild(el("button", {
class: "btn ghost",
onclick: toggleAnon,
}, anonymize ? t("anonymized") : t("reveal_models")));
tb.appendChild(el("a", {
class: "btn ghost",
href: "./admin.html", target: "_blank", rel: "noopener",
style: "text-decoration:none",
title: "open the admin dashboard (password-gated)",
}, "🛠 Admin"));
tb.appendChild(el("div", { class: "sep" }));
}
tb.appendChild(el("span", {
id: "syncPill", class: "sync-pill",
"data-state": SYNC_STATUS,
title: SYNC_LAST_DETAIL || t("pill_default_title"),
}, t("pill_checking")));
tb.appendChild(el("div", { class: "sep" }));
tb.appendChild(el("button", {
id: "submitBtn",
class: "btn submit-primary",
onclick: submitWholeStudy,
disabled: "",
title: t("submit_enable_hint"),
}, t("submit_pending", 0)));
tb.appendChild(el("button", {
class: "btn ghost",
onclick: () => saveProgress({ silent: false, source: "manual" }),
title: t("save_progress_title"),
}, t("save_progress")));
tb.appendChild(el("button", {
class: "btn ghost",
onclick: exportJSON,
title: t("export_title"),
}, t("export_json")));
tb.appendChild(el("button", {
class: "btn ghost",
onclick: resetCurrentCase,
}, t("reset_case")));
tb.appendChild(el("button", {
class: "btn ghost",
onclick: resetAll,
}, t("reset_all")));
tb.appendChild(el("div", { class: "sep" }));
tb.appendChild(el("div", { class: "rater-box" },
el("span", {}, t("lang_label") + ":"),
renderLangSelect(),
));
return tb;
}
function updateSubmitButton() {
const btn = document.getElementById("submitBtn");
if (!btn) return;
const comp = studyCompleteness();
const remaining = comp.expected - comp.done;
if (remaining > 0) {
btn.disabled = true;
btn.textContent = t("submit_pending", remaining);
btn.title = t("submit_disabled_title", remaining, comp.expected);
} else if (SYNC_STATUS === "submitted") {
btn.disabled = false;
btn.textContent = t("submit_resubmit");
btn.title = t("submit_resubmit_title");
} else {
btn.disabled = false;
btn.textContent = t("submit_ready", comp.expected);
btn.title = t("submit_ready_title");
}
}
function renderSidebar() {
const aside = el("aside", { class: "list" });
aside.appendChild(el("div", { class: "list-header" },
el("span", {}, t("cases_n", CASES.length)),
el("button", {
class: "hide-btn",
title: "Hide cases sidebar",
onclick: closeSidebar,
}, "✕"),
));
CASES.forEach((c, i) => {
const p = caseProgress(c);
let cls = "case-row";
if (i === currentIdx) cls += " active";
if (p.done === 0) { /* untouched */ }
else if (p.done < p.expected) cls += " partial";
else cls += " complete";
aside.appendChild(el("div", {
class: cls,
"data-case-id": c.id,
onclick: () => goTo(i),
},
el("span", { class: "status-dot" }),
el("span", { class: "id" }, c.id),
el("span", { style: "font-size:11px;color:var(--muted)" },
`${p.done}/${p.expected}`),
));
});
return aside;
}
function renderFooter() {
return el("footer", { class: "bottom" },
el("div", { class: "summary", id: "footerSummary" }),
el("a", {
href: "./mobile.html?force=mobile",
style: "font-size:11px;color:var(--muted);text-decoration:none;"
+ "padding:4px 10px;border:1px solid var(--border);border-radius:12px;"
+ "background:var(--panel-3);margin-right:8px",
title: "switch to the phone-optimized layout",
}, "📱 Mobile"),
el("div", { class: "nav-btns" },
el("button", { class: "btn", onclick: () => step(-1) }, t("prev")),
el("button", { class: "btn", onclick: () => step(1) }, t("next")),
),
);
}
/* =========================================================================
* Rendering — detail (instructions + videos + ratings)
* ========================================================================= */
function renderDetail() {
const detail = document.getElementById("detail");
if (!detail) return;
detail.innerHTML = "";
if (!CASES.length) return;
detail.appendChild(renderInstructions(currentIdx === 0));
const c = CASES[currentIdx];
// Two-pane layout: prompt + tabbed video on the left (sticky); scrollable
// question cards on the right. The user picks A/B/C to switch which video
// plays on the left while the right pane lists every rubric question with
// one rating row per video.
const wrap = el("div", { class: "case-pane-wrap" });
wrap.appendChild(renderLeftPane(c));
wrap.appendChild(renderRightPane(c));
detail.appendChild(wrap);
bindAutoCollapseInstructions();
const right = detail.querySelector(".right-pane");
right?.__resetAutoCollapse?.();
if (right) right.scrollTop = 0;
// Update sidebar active row + footer
document.querySelectorAll(".case-row").forEach((row, i) => {
row.classList.toggle("active", i === currentIdx);
});
updateFooterSummary();
}
function getActiveSlot(caseId) {
return (ACTIVE_SLOT[caseId] ?? 0);
}
function setActiveSlot(caseId, slotIdx) {
pauseAll();
ACTIVE_SLOT[caseId] = slotIdx;
// Re-render only the left pane (prompt + tabs + active video + controls)
// so the right-pane question cards keep their scroll position.
const wrap = document.querySelector(".case-pane-wrap");
if (!wrap) return;
const newLeft = renderLeftPane(CASES[currentIdx]);
const oldLeft = wrap.querySelector(".left-pane");
if (oldLeft) wrap.replaceChild(newLeft, oldLeft);
}
function renderLeftPane(c) {
// Order: case meta (id + N-of-M) → video tabs + active video → controls
// → prompt-box (flex-grows to fill the rest of the pane so there's no
// empty space at the bottom). Reference image, when present, sits inside
// the prompt-box block as a small thumbnail.
const pane = el("div", { class: "left-pane" });
pane.appendChild(renderCaseMeta(c));
pane.appendChild(renderVideoPanel(c));
pane.appendChild(renderVideoControls(c));
pane.appendChild(renderPromptBox(c));
return pane;
}
function renderCaseMeta(c) {
return el("div", { class: "case-meta",
style: "display:flex;align-items:baseline;gap:10px;flex-wrap:wrap" },
el("span", { class: "case-id",
style: "font-family:ui-monospace,monospace;font-size:18px;font-weight:600" }, c.id),
el("span", { class: "case-counter",
style: "color:var(--muted);font-size:12px" },
t("case_n_of_m", currentIdx + 1, CASES.length)),
);
}
function renderPromptBox(c) {
const wrap = el("div", { class: "prompt-box" });
wrap.appendChild(el("div", { class: "label" }, t("prompt_label")));
const localized = (c.prompt_i18n && c.prompt_i18n[LANG]) || c.prompt;
const txt = el("div", { class: "prompt-text" });
txt.innerHTML = highlightControllability(localized || t("no_prompt"), LANG, c.id);
wrap.appendChild(txt);
if (isAdmin()) wrap.appendChild(renderAdminHighlightToolbar(c, localized));
if (c.reference_image) {
const img = el("img", {
class: "ref-img", src: c.reference_image, alt: "reference",
onerror: function () { this.style.display = "none"; },
});
wrap.appendChild(img);
}
return wrap;
}
function renderAdminHighlightToolbar(c, promptText) {
// Admin-only block inside the prompt-box. Lets an admin highlight key
// phrases in the prompt that all raters then see in bold red.
const wrap = el("div", { class: "hi-toolbar" });
const row = el("div", { class: "hi-row" });
row.appendChild(el("button", {
class: "btn ghost",
style: "padding:3px 10px;font-size:11px",
onclick: () => addCurrentSelectionHighlight(c),
title: "Select text in the prompt above, then click to mark it",
}, "+ Mark"));
row.appendChild(el("span", {
style: "color:var(--muted-2);font-size:11px;flex:1" },
"Select a phrase in the prompt above, then click + Mark to highlight it for every rater."));
wrap.appendChild(row);
const existing = (ADMIN_HIGHLIGHTS[c.id]?.[LANG]) || [];
if (existing.length) {
const list = el("div", { class: "hi-list" });
for (const snippet of existing) {
const tag = el("span", { class: "hi-tag", title: snippet },
el("span", { class: "hi-tag-text" }, snippet.length > 48 ? snippet.slice(0, 46) + "…" : snippet),
el("button", {
class: "hi-tag-x",
title: "Remove this highlight",
onclick: () => removeHighlight(c, snippet),
}, "✕"),
);
list.appendChild(tag);
}
wrap.appendChild(list);
}
return wrap;
}
async function postAnnotations(caseId, lang, ranges) {
try {
const r = await fetch(API_ANNOTATIONS, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
rater: RATER, case_id: caseId, lang, ranges,
}),
});
if (!r.ok) {
const text = await r.text().catch(() => "");
showToast(`Highlight save failed (HTTP ${r.status}): ${text.slice(0, 80)}`, true);
return false;
}
const j = await r.json().catch(() => ({}));
// Update local cache from the server's canonical response
if (!ADMIN_HIGHLIGHTS[caseId]) ADMIN_HIGHLIGHTS[caseId] = {};
ADMIN_HIGHLIGHTS[caseId][lang] = Array.isArray(j.ranges) ? j.ranges : ranges;
return true;
} catch (e) {
showToast("Highlight save failed: " + e.message, true);
return false;
}
}
function addCurrentSelectionHighlight(c) {
const sel = window.getSelection();
if (!sel || sel.isCollapsed) { showToast("Select text in the prompt first", true); return; }
const text = sel.toString().trim();
if (!text || text.length < 2) { showToast("Selection too short", true); return; }
// Make sure the selection is anchored inside the current prompt-text node.
const promptNode = document.querySelector(".prompt-box .prompt-text");
if (!promptNode || !promptNode.contains(sel.anchorNode)) {
showToast("Select inside the prompt only", true);
return;
}
const promptStr = (c.prompt_i18n && c.prompt_i18n[LANG]) || c.prompt || "";
if (!promptStr.includes(text)) {
// Selection may span a controllability <span> boundary — collapse
// whitespace and retry.
const norm = text.replace(/\s+/g, " ");
if (!promptStr.replace(/\s+/g, " ").includes(norm)) {
showToast("Selection does not match the prompt text", true);
return;
}
}
const cur = (ADMIN_HIGHLIGHTS[c.id]?.[LANG]) || [];
if (cur.includes(text)) { showToast("Already highlighted"); return; }
const next = [...cur, text];
postAnnotations(c.id, LANG, next).then(ok => {
if (ok) {
showToast(`Marked: ${text.slice(0, 40)}${text.length > 40 ? "…" : ""}`);
sel.removeAllRanges();
renderDetail();
}
});
}
function removeHighlight(c, snippet) {
const cur = (ADMIN_HIGHLIGHTS[c.id]?.[LANG]) || [];
const next = cur.filter(s => s !== snippet);
postAnnotations(c.id, LANG, next).then(ok => {
if (ok) { showToast("Removed"); renderDetail(); }
});
}
function renderRightPane(c) {
const pane = el("div", { class: "right-pane" });
const slots = SHUFFLE[c.id];
for (const dim of DIMENSIONS) {
const dh = el("div", { class: "dim-header" },
el("span", {}, dimName(dim)),
el("span", { class: "dim-hint" }, dimHint(dim)));
pane.appendChild(dh);
for (const q of dim.questions) {
// Skip "only_if_reference_image" questions for cases without one.
if (q.only_if_reference_image && !c.reference_image) continue;
pane.appendChild(renderQuestionCard(c, q, slots));
}
}
return pane;
}
function renderVideoPanel(c) {
const slots = SHUFFLE[c.id];
const active = getActiveSlot(c.id);
const wrap = el("div", { class: "video-panel" });
// Tab buttons (one per slot). Letters stay stable per case; admin mode
// also shows the model name after the letter.
const tabs = el("div", { class: "video-tabs" });
slots.forEach((origIdx, slotIdx) => {
const letter = String.fromCharCode(65 + slotIdx);
const v = c.videos[origIdx];
const label = anonymize ? letter : `${letter} · ${v.model}`;
tabs.appendChild(el("button", {
class: "tab-btn" + (slotIdx === active ? " active" : ""),
onclick: () => setActiveSlot(c.id, slotIdx),
title: anonymize ? t("video_label_anon", letter)
: t("video_label_revealed", letter, v.model),
}, label));
});
wrap.appendChild(tabs);
// All video tiles stay in the DOM so each <video> keeps its own playback
// state (currentTime, paused) across tab switches. Only the active one is
// visible via .hidden / display:none.
slots.forEach((origIdx, slotIdx) => {
const tile = renderSingleVideoTile(c, origIdx, slotIdx);
if (slotIdx !== active) tile.classList.add("hidden");
wrap.appendChild(tile);
});
return wrap;
}
function renderSingleVideoTile(c, origIdx, slotIdx) {
const v = c.videos[origIdx];
const tile = el("div", { class: "video-tile", "data-slot-idx": String(slotIdx) });
const frame = el("div", { class: "video-frame" });
const video = el("video", {
"data-slot": String(slotIdx),
"data-case": c.id,
preload: "metadata",
playsinline: "",
controls: "",
});
const source = el("source", { src: v.path, type: "video/mp4" });
video.appendChild(source);
const empty = el("div", { class: "empty-state", style: "display:none" },
el("div", { class: "icon" }, "▶"),
el("div", {}, t("no_video_file")),
el("div", { class: "path" }, v.path),
el("div", { style: "font-size:11px;color:var(--muted-2);margin-top:4px" },
t("drop_mp4_hint")),
);
const onErr = () => { empty.style.display = "flex"; video.style.display = "none"; };
video.addEventListener("error", onErr);
source.addEventListener("error", onErr);
frame.appendChild(video);
frame.appendChild(empty);
tile.appendChild(frame);
return tile;
}
function renderQuestionCard(c, q, slots) {
const card = el("div", { class: "q-card" });
card.appendChild(el("div", { class: "q-name" }, qName(q)));
card.appendChild(el("div", { class: "q-desc" }, qDesc(q)));
const order = rowSlotOrder(c.id, q.key, slots.length);
for (const slotIdx of order) {
card.appendChild(renderRatingRow(c, slotIdx, q.key));
}
return card;
}
function renderRatingRow(c, slotIdx, qkey) {
const slots = SHUFFLE[c.id];
const origIdx = slots[slotIdx];
const v = c.videos[origIdx];
const letter = String.fromCharCode(65 + slotIdx);
const row = el("div", { class: "video-rate" });
row.appendChild(el("span", {
class: "slot-chip",
title: anonymize ? t("video_label_anon", letter)
: t("video_label_revealed", letter, v.model),
onclick: () => setActiveSlot(c.id, slotIdx), // tap chip to focus that video
style: "cursor:pointer",
}, letter));
const group = el("div", { class: "likert" });
const cur = getSlotRatings(c.id, slotIdx)[qkey];
for (let s = 1; s <= 5; s++) {
const btn = el("button", {
"data-score": String(s),
title: `${s} — ${scaleLabel(s)}`,
onclick: () => {
setRating(c.id, slotIdx, qkey, s);
Array.from(group.children).forEach((b, i) =>
b.classList.toggle("selected", (i + 1) === s));
},
}, String(s));
if (cur === s) btn.classList.add("selected");
group.appendChild(btn);
}
row.appendChild(group);
return row;
}
// Track the height of the sticky-top region and publish it as a CSS variable
// so .dim-row inside the ratings table can stick just below it.
let _STICKY_RO = null;
function observeStickyTop(node) {
if (_STICKY_RO) { _STICKY_RO.disconnect(); _STICKY_RO = null; }
const update = () => {
document.documentElement.style.setProperty(
"--sticky-top-h", node.offsetHeight + "px");
};
update();
if (typeof ResizeObserver !== "undefined") {
_STICKY_RO = new ResizeObserver(update);
_STICKY_RO.observe(node);
} else {
window.addEventListener("resize", update, { passive: true });
}
}
// Once per case visit: auto-collapse the instructions panel as soon as the
// user scrolls past the top of the right pane. Keeps the rubric out of the
// way once they start scoring. We listen on the right pane because
// section.detail is no longer the scroll container (only .right-pane is).
function bindAutoCollapseInstructions() {
const detail = document.getElementById("detail");
const right = detail?.querySelector(".right-pane");
if (!right || right.__autoCollapseBound) return;
right.__autoCollapseBound = true;
let closed = false;
right.addEventListener("scroll", () => {
if (closed) return;
if (right.scrollTop > 80) {
const ins = detail.querySelector("details.instructions[open]");
if (ins) { ins.removeAttribute("open"); closed = true; }
}
}, { passive: true });
// Reset the once-per-case flag whenever a new case is rendered.
right.__resetAutoCollapse = () => { closed = false; right.scrollTop = 0; };
}
function renderInstructions(forceOpen) {
const det = el("details", { class: "instructions" });
if (forceOpen) det.setAttribute("open", "");
det.appendChild(el("summary", {}, t("instructions_toggle", TOTAL_QUESTIONS)));
const body = el("div");
body.innerHTML = `
<p>${t("instructions_intro")}</p>
<h4>${t("scale_legend")}</h4>
<div class="scale-row">
<span class="scale-pill" style="background:var(--score-1)">1 · ${t("scale_1")}</span>
<span class="scale-pill" style="background:var(--score-2)">2 · ${t("scale_2")}</span>
<span class="scale-pill" style="background:var(--score-3)">3 · ${t("scale_3")}</span>
<span class="scale-pill" style="background:var(--score-4)">4 · ${t("scale_4")}</span>
<span class="scale-pill" style="background:var(--score-5)">5 · ${t("scale_5")}</span>
</div>
<h4>${t("dimensions_header")}</h4>
<ul>
<li>${t("dim_inst_short")}</li>
<li>${t("dim_temporal_short")}</li>
<li>${t("dim_motion_short")}</li>
</ul>
<h4>${t("tips_header")}</h4>
<ul>
<li>${t("tip_save_then_submit")}</li>
<li>${t("tip_audio")}</li>
<li>${t("tip_keys")}</li>
<li>${t("tip_nav")}</li>
<li>${t("tip_autosave")}</li>
<li>${t("tip_anon")}</li>
<li>${t("tip_missing")}</li>
</ul>
<div style="margin-top:10px;display:flex;justify-content:flex-end">
<button type="button" class="btn primary" data-action="dismiss-instructions"
style="padding:6px 14px;font-size:12px">
${escapeHtml(t("instructions_dismiss"))}
</button>
</div>
`;
// Wire the dismiss button to collapse the <details>.
body.querySelector('[data-action="dismiss-instructions"]')?.addEventListener("click", () => {
det.removeAttribute("open");
});
det.appendChild(body);
return det;
}
function renderCaseHeader(c) {
const wrap = el("div", { class: "case-header" });
const left = el("div", { style: "flex:0 0 auto;display:flex;flex-direction:column;gap:4px" },
el("span", { class: "case-id" }, c.id),
el("span", { class: "case-counter" },
t("case_n_of_m", currentIdx + 1, CASES.length)),
);
wrap.appendChild(left);
const promptBox = el("div", { class: "prompt-box" });
promptBox.appendChild(el("div", { class: "label" }, t("prompt_label")));
const localized = (c.prompt_i18n && c.prompt_i18n[LANG]) || c.prompt;
// Highlight controllability cues (imperative verbs + directive phrases) so
// the rater knows what to evaluate against the videos.
const promptText = el("div", {});
promptText.innerHTML = highlightControllability(localized || t("no_prompt"), LANG);
promptBox.appendChild(promptText);
wrap.appendChild(promptBox);
if (c.reference_image) {
const img = el("img", {
class: "ref-img", src: c.reference_image,
alt: "reference",
onerror: function () { this.style.display = "none"; },
});
wrap.appendChild(img);
}
return wrap;
}
function renderVideoControls(c) {
// The active <video> already exposes the browser's native play/pause UI,
// so there's no separate "Play" button here. Pause / Restart stay because
// they act globally / sync-style.
const ctl = el("div", { class: "video-controls" });
ctl.appendChild(el("button", {
class: "ctl-btn", onclick: pauseAll,
}, t("pause")));
ctl.appendChild(el("button", {
class: "ctl-btn", onclick: restartActive,
}, t("restart")));
const loopBtn = el("button", {
class: "ctl-btn", id: "loopBtn",
onclick: () => toggleLoop(loopBtn),
}, t("loop"));
ctl.appendChild(loopBtn);
const muteBtn = el("button", {
class: "ctl-btn", id: "muteBtn",
onclick: () => toggleMute(muteBtn),
}, t("mute"));
ctl.appendChild(muteBtn);
ctl.appendChild(el("span", { style: "margin-left:auto" }, t("speed")));
const speedSel = el("select", {
onchange: (e) => setSpeed(parseFloat(e.target.value)),
});
["0.25", "0.5", "0.75", "1", "1.5", "2"].forEach(v => {
const opt = el("option", { value: v }, v + "×");
if (v === "1") opt.setAttribute("selected", "");
speedSel.appendChild(opt);
});
ctl.appendChild(speedSel);
return ctl;
}
function activeVideo() {
// The only video that's NOT inside a .hidden tile is the active one.
return document.querySelector(".video-tile:not(.hidden) video[data-slot]");
}
function restartActive() {
const v = activeVideo();
if (v) { v.currentTime = 0; try { v.play(); } catch(e){} }
}
// Within a case, every sub-question shows its rating rows in the same A → B
// → C order. (The case-level SHUFFLE still varies which video sits under
// each letter per-rater per-case, so positional bias across cases is still
// neutralised — but inside a single case the rater always sees the same
// layout, which is easier to scan and compare.)
function rowSlotOrder(caseId, qKey, slotCount) {
return Array.from({ length: slotCount }, (_, i) => i);
}
/* =========================================================================
* Video sync controls
* ========================================================================= */
function allVideos() {
return Array.from(document.querySelectorAll("video[data-slot]"));
}
function pauseAll() { allVideos().forEach(v => v.pause()); }
function restartAll() {
allVideos().forEach(v => { v.currentTime = 0; try { v.play(); } catch(e){} });
}
let LOOPING = false;
function toggleLoop(btn) {
LOOPING = !LOOPING;
allVideos().forEach(v => v.loop = LOOPING);
btn.classList.toggle("on", LOOPING);
}
let MUTED = false;
function toggleMute(btn) {
MUTED = !MUTED;
allVideos().forEach(v => v.muted = MUTED);
btn.classList.toggle("on", MUTED);
btn.textContent = MUTED ? "🔇 Muted" : "🔊 Sound";
}
function setSpeed(rate) { allVideos().forEach(v => v.playbackRate = rate); }
// Space-bar shortcut: play/pause whichever video is on the active tab.
function toggleActive() {
const v = activeVideo();
if (!v) return;
if (v.paused) { try { v.play(); } catch(e){} } else { v.pause(); }
}
/* =========================================================================
* Navigation + status updates
* ========================================================================= */
function goTo(idx) {
if (idx < 0 || idx >= CASES.length) return;
currentIdx = idx;
pauseAll();
renderDetail();
// Scroll detail to top
const det = document.getElementById("detail");
if (det) det.scrollTop = 0;
}
function step(delta) { goTo((currentIdx + delta + CASES.length) % CASES.length); }
function updateProgress() {
const { done, expected } = totalProgress();
const pct = expected > 0 ? (done / expected) * 100 : 0;
const fill = document.getElementById("progressFill");
const txt = document.getElementById("progressText");
if (fill) fill.style.width = pct.toFixed(1) + "%";
if (txt) txt.innerHTML =
`<span class="num">${done}</span> / ${expected} ratings (${pct.toFixed(0)}%)`;
}
function updateFooterSummary() {
const c = CASES[currentIdx];
if (!c) return;
const p = caseProgress(c);
const el = document.getElementById("footerSummary");
if (!el) return;
const summary = t("footer_summary",
`<span class="num">${currentIdx + 1}</span>`,
CASES.length,
`<span class="num">${p.done}</span>`,
p.expected);
el.innerHTML = summary +
(p.done === p.expected && p.expected > 0 ? t("footer_complete_suffix") : "");
}
function updateCaseStatus(caseId) {
const c = CASES.find(x => x.id === caseId);
if (!c) return;
const p = caseProgress(c);
const row = document.querySelector(`[data-case-id="${caseId}"]`);
if (!row) return;
row.classList.remove("partial", "complete");
if (p.done === p.expected && p.expected > 0) row.classList.add("complete");
else if (p.done > 0) row.classList.add("partial");
const counter = row.querySelector("span:last-child");
if (counter) counter.textContent = `${p.done}/${p.expected}`;
updateFooterSummary();
}
/* =========================================================================
* Toolbar actions
* ========================================================================= */
function toggleAnon() {
if (!isAdmin()) return; // gate: non-admins can't reveal models
anonymize = !anonymize;
// The shuffle is anonymize-independent now, so we don't wipe SHUFFLE here —
// only re-render so the slot labels switch between "Video A" and
// "Video A · ours".
saveState();
renderShell();
}
function resetCurrentCase() {
const c = CASES[currentIdx];
if (!confirm(t("confirm_reset_case", c.id))) return;
delete RATINGS[c.id];
saveState();
renderDetail();
document.querySelector(`[data-case-id="${c.id}"]`)?.classList.remove("partial", "complete");
updateProgress();
updateCaseStatus(c.id);
showToast(t("toast_cleared_case", c.id));
}
function resetAll() {
if (!confirm(t("confirm_reset_all"))) return;
RATINGS = {};
SHUFFLE = {};
RATER = { id: genRaterId(), name: "", email: "", note: "",
started_at: new Date().toISOString(), submitted_at: null };
buildShuffle(CASES);
saveState();
showLoginOverlay(true);
showToast(t("toast_reset_all"));
}
/* =========================================================================
* Registration overlay (login without password)
* ========================================================================= */
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
function showLoginOverlay(forceShow) {
const ov = document.getElementById("loginOverlay");
if (!ov) return;
if (!forceShow && RATER.name && RATER.email && EMAIL_RE.test(RATER.email)) {
ov.classList.add("hidden");
return;
}
ov.classList.remove("hidden");
// Prefill if known
const nameI = document.getElementById("regName");
const emailI = document.getElementById("regEmail");
const noteI = document.getElementById("regNote");
if (nameI) nameI.value = RATER.name || "";
if (emailI) emailI.value = RATER.email || "";
if (noteI) noteI.value = RATER.note || "";
(nameI || emailI)?.focus();
}
function hideLoginOverlay() {
document.getElementById("loginOverlay")?.classList.add("hidden");
}
function handleRegistrationSubmit() {
const name = document.getElementById("regName").value.trim();
const email = document.getElementById("regEmail").value.trim().toLowerCase();
const note = document.getElementById("regNote").value.trim();
const err = document.getElementById("regErr");
err.textContent = "";
if (!name) { err.textContent = t("reg_err_name"); return; }
if (!EMAIL_RE.test(email)) {
err.textContent = t("reg_err_email");
return;
}
RATER.name = name;
RATER.email = email;
RATER.note = note;
if (!RATER.id || RATER.id.startsWith("rater_")) {
// Derive a stable, human-readable id from the email so the shuffle seed
// is deterministic per participant across sessions / browsers.
RATER.id = email.replace(/[^a-z0-9]+/gi, "_").replace(/^_+|_+$/g, "");
}
if (!RATER.started_at) RATER.started_at = new Date().toISOString();
hideLoginOverlay();
// First: try to recover any prior server-side state for this email so
// their locked case subset + earlier ratings carry across devices.
(async () => {
// Drop any pre-registration (anonymous) lock so the subset is re-resolved
// in priority order: server-recovered > coverage-first > seeded random.
RATER.chosen_case_ids = null;
await recoverRaterStateFromServer(email);
// Fresh rater: prefer the server's coverage-first assignment (least-rated
// cases first) over the local seeded random pick.
await assignCasesFromServer();
// Now pick (or restore) the case subset. If chosen_case_ids came back
// from the server (recovery or coverage-first assignment),
// selectCasesForRater honours it; otherwise it picks a fresh
// deterministic 10 and locks them in.
CASES = selectCasesForRater(ALL_CASES);
SHUFFLE = {};
buildShuffle(CASES);
saveState();
renderShell();
})();
// Probe the server, then drop an empty in-progress entry so the rater
// appears in submissions/in_progress/ immediately on registration. Saving
// happens asynchronously and falls back silently if the server isn't
// reachable (local-only mode).
(async () => {
await probeSyncEndpoint();
if (SYNC_STATUS !== "local-only") {
await saveProgress({ silent: true, source: "register" });
showToast(t("toast_registered_saved", name));
} else {
showToast(t("toast_registered_as", name));
}
})();
}
// Sync the registration overlay's static markup to the current LANG so it
// reads correctly when the language switcher is changed before the user
// completes registration.
function applyRegistrationOverlayI18n() {
const map = {
regHeader: "reg_title",
regLead: "reg_lead",
regNameLabel: "reg_name",
regEmailLabel: "reg_email",
regNoteLabel: "reg_note",
regContinueText: "reg_continue",
regSideNote: "reg_side_note",
regFootText: "reg_foot",
submittedHeading: "submitted_title",
submittedCloseBtn: "submitted_close",
submittedDownloadBtn: "submitted_download",
regLangLabel: "lang_label",
};
for (const [domId, key] of Object.entries(map)) {
const el = document.getElementById(domId);
if (!el) continue;
if (key.endsWith("_foot") || key.endsWith("_lead")) el.innerHTML = t(key);
else el.textContent = t(key);
}
const name = document.getElementById("regName");
const email = document.getElementById("regEmail");
const note = document.getElementById("regNote");
if (name) name.placeholder = t("reg_name_ph");
if (email) email.placeholder = t("reg_email_ph");
if (note) note.placeholder = t("reg_note_ph");
// language selector on the registration overlay
const sel = document.getElementById("regLangSelect");
if (sel) {
sel.innerHTML = "";
for (const L of LANGUAGES) {
const opt = document.createElement("option");
opt.value = L.code; opt.textContent = L.label;
if (L.code === LANG) opt.selected = true;
sel.appendChild(opt);
}
}
}
/* =========================================================================
* Submitted celebration overlay
* ========================================================================= */
function showSubmittedOverlay(body) {
const ov = document.getElementById("submittedOverlay");
if (!ov) return;
const detail = document.getElementById("submittedDetail");
const ref = document.getElementById("submittedRef");
if (detail) {
detail.textContent = t("submitted_thanks",
RATER.name, body.n_scores ?? "?", body.n_videos ?? "?");
}
if (ref) ref.textContent = body.stored_as || "—";
ov.classList.remove("hidden");
}
function hideSubmittedOverlay() {
document.getElementById("submittedOverlay")?.classList.add("hidden");
}
function exportJSON() {
// De-anonymize for the export so downstream analysis maps slot → real model.
const out = {
rater: RATER,
anonymize_used: anonymize,
exported_at: new Date().toISOString(),
rubric: DIMENSIONS,
cases: CASES.map(c => {
const slots = SHUFFLE[c.id] || c.videos.map((_, i) => i);
return {
id: c.id,
prompt: c.prompt,
videos: slots.map((origIdx, slotIdx) => {
const v = c.videos[origIdx];
return {
slot: String.fromCharCode(65 + slotIdx),
slot_index: slotIdx,
original_index: origIdx,
model: v.model,
path: v.path,
ratings: (RATINGS[c.id] || {})[v.model] || {},
};
}),
};
}),
};
const blob = new Blob([JSON.stringify(out, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = el("a", {
href: url,
download: `user_study_${RATER.id || "anon"}_${new Date().toISOString().slice(0, 10)}.json`,
});
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
showToast(t("toast_exported"));
}
/* =========================================================================
* Empty / error state
* ========================================================================= */
function renderEmptyApp(reason) {
document.getElementById("app").innerHTML = `
<div class="empty-app">
<h2>${t("empty_app_title")}</h2>
<p style="color:var(--bad)">${escapeHtml(reason)}</p>
<p>${t("empty_app_intro_html")}</p>
<h3 style="color:var(--accent-strong);font-size:13px;text-transform:uppercase;letter-spacing:0.4px">${t("empty_app_format_header")}</h3>
<pre>[
{
"id": "case_001",
"prompt": "A cat in a chef's hat tossing pancakes...",
"reference_image": "refs/case_001.png", // optional
"videos": [
{ "model": "ours", "path": "videos/case_001_ours.mp4" },
{ "model": "baseline_a", "path": "videos/case_001_a.mp4" }
]
}
]</pre>
<p>${t("empty_app_serve_header")}</p>
<pre>cd evals/user_study
python -m http.server 8765
# open http://localhost:8765/</pre>
<p style="color:var(--muted-2)">${t("empty_app_missing_tip_html")}</p>
</div>`;
}
/* =========================================================================
* Keyboard
* ========================================================================= */
document.addEventListener("keydown", e => {
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
switch (e.key) {
case "ArrowLeft": case "k": e.preventDefault(); step(-1); break;
case "ArrowRight": case "j": e.preventDefault(); step(1); break;
case " ": e.preventDefault(); toggleActive(); break;
case "r": case "R": e.preventDefault(); restartAll(); break;
case "l": case "L":
e.preventDefault();
const lb = document.getElementById("loopBtn");
if (lb) toggleLoop(lb);
break;
}
});
// Best-effort save on tab close / navigation. `fetch` from beforeunload is
// unreliable; sendBeacon is built for fire-and-forget on unload.
window.addEventListener("beforeunload", beaconProgress);
// Some browsers throttle beforeunload but honour visibilitychange='hidden',
// which fires on tab close, switch-away, and mobile background.
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") beaconProgress();
});
/* =========================================================================
* Init
* ========================================================================= */
async function fetchManifest() {
for (const path of ["./cases.json", "./cases.example.json"]) {
try {
const resp = await fetch(path, { cache: "no-cache" });
if (resp.ok) {
const data = await resp.json();
return { data, path };
}
} catch (e) { /* try next */ }
}
return null;
}
// Wire up registration overlay events (the overlay markup is in the body
// directly, not rendered via JS, so we bind once after DOM ready).
document.getElementById("regGoBtn")?.addEventListener("click", handleRegistrationSubmit);
["regName", "regEmail"].forEach(id => {
document.getElementById(id)?.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); handleRegistrationSubmit(); }
});
});
// One-time migration from v1 (RATINGS keyed by slot_idx) to v2 (keyed by
// model name). Uses the v1 saved SHUFFLE to map slot → original_index →
// model. Cases whose v1 SHUFFLE is missing or stale are skipped; the
// rater would then see those cases as un-rated, but no existing rating
// is misattributed to a different video.
function migrateLegacyRatingsV1(legacy, allCases) {
const oldRatings = legacy?.ratings || {};
const oldShuffle = legacy?.shuffle || {};
const out = {};
let migrated = 0, skipped = 0;
for (const [caseId, slotMap] of Object.entries(oldRatings)) {
const caseObj = allCases.find(c => c.id === caseId);
const shuffle = oldShuffle[caseId];
if (!caseObj || !Array.isArray(shuffle)) {
skipped++;
continue;
}
out[caseId] = {};
for (const [slotStr, qMap] of Object.entries(slotMap || {})) {
const slotIdx = parseInt(slotStr, 10);
if (!Number.isFinite(slotIdx)) continue;
const origIdx = shuffle[slotIdx];
const video = caseObj.videos[origIdx];
if (!video || !video.model) continue;
// Merge in case the same model already has entries (shouldn't happen
// for the v1 format but defensive).
out[caseId][video.model] = Object.assign(
out[caseId][video.model] || {}, qMap);
migrated++;
}
}
return { ratings: out, migrated, skipped };
}
(async function init() {
// Restore LANG first so the empty-app + registration overlay show the right
// language even before the rest of the state is wired up.
try {
const prior0 = JSON.parse(localStorage.getItem(STORAGE_KEY) || "null");
if (prior0 && STRINGS[prior0.lang]) LANG = prior0.lang;
} catch (e) { /* ignore */ }
// Or pick browser preferred language on first visit.
if (LANG === "en") {
const nav = (navigator.language || "en").toLowerCase();
if (nav.startsWith("zh")) LANG = "zh";
else if (nav.startsWith("bg")) LANG = "bg";
}
document.documentElement.lang = LANG;
applyRegistrationOverlayI18n();
const found = await fetchManifest();
if (!found) {
renderEmptyApp(t("empty_app_load_error"));
return;
}
ALL_CASES = found.data || [];
if (!Array.isArray(ALL_CASES) || ALL_CASES.length === 0) {
renderEmptyApp(t("empty_app_empty_manifest"));
return;
}
// ===== view-as mode (admin "open the page as this rater") =====
// Triggered by admin.html → it puts the target user's submission in
// sessionStorage["user_study_view_as"] and opens this page with
// ?view_as=1. We treat the page as read-only: no localStorage writes,
// no /api/submit, no /api/save_progress.
try {
const urlVA = new URL(location.href);
if (urlVA.searchParams.get("view_as") === "1") {
const raw = sessionStorage.getItem("user_study_view_as");
if (raw) {
const va = JSON.parse(raw);
const p = va?.payload;
if (p && p.rater) {
VIEW_AS_MODE = true;
VIEW_AS_META = va.meta || { rater_name: p.rater.name, rater_email: p.rater.email };
RATER = Object.assign({
id:"", name:"", email:"", note:"",
started_at:null, submitted_at:null,
}, p.rater);
anonymize = p.anonymize_used !== false;
// Reconstruct RATINGS from the submission's cases array.
RATINGS = {};
for (const c of (p.cases || [])) {
if (!c || !c.id) continue;
RATINGS[c.id] = {};
for (const v of (c.videos || [])) {
if (!v.model) continue;
RATINGS[c.id][v.model] = v.ratings || {};
}
}
SHUFFLE = {};
CASES = selectCasesForRater(ALL_CASES);
buildShuffle(CASES);
renderShell();
addViewAsBanner();
// Still pull admin highlights so the prompt's red marks render.
// loadAnnotations() will re-render when annotations arrive.
loadAnnotations();
return;
}
}
}
} catch (e) { console.warn("view-as init failed:", e); }
// Restore prior state, if any
const prior = loadState();
const blankRater = () => ({
id: genRaterId(), name: "", email: "", note: "",
started_at: new Date().toISOString(), submitted_at: null,
});
if (prior) {
RATER = Object.assign(blankRater(), prior.rater || {});
anonymize = prior.anonymize !== false;
RATINGS = prior.ratings || {};
SHUFFLE = prior.shuffle || {};
// One-shot client-side migration: prior to the huobao→univa rename,
// RATINGS were v2-keyed by model name. If localStorage still has
// RATINGS[case_id]["huobao"], copy them under "univa" so the rater's
// previously-rated cells re-appear under the new column header.
let huobaoRenames = 0;
for (const cid of Object.keys(RATINGS)) {
const byModel = RATINGS[cid];
if (byModel && byModel.huobao && !byModel.univa) {
byModel.univa = byModel.huobao;
delete byModel.huobao;
huobaoRenames++;
}
}
if (huobaoRenames) {
console.info(`[user-study] renamed huobao→univa ratings on ${huobaoRenames} case(s)`);
}
} else {
// No v2 state. Try a one-time migration from v1.
const legacyRaw = localStorage.getItem(STORAGE_KEY_LEGACY);
if (legacyRaw) {
try {
const legacy = JSON.parse(legacyRaw);
RATER = Object.assign(blankRater(), legacy.rater || {});
anonymize = legacy.anonymize !== false;
const { ratings, migrated, skipped } = migrateLegacyRatingsV1(legacy, CASES);
RATINGS = ratings;
// SHUFFLE is recomputed every load anyway; don't carry the v1 order.
SHUFFLE = {};
console.info(
`[user-study] migrated v1 → v2: ${migrated} ratings carried over, ${skipped} cases skipped`);
localStorage.removeItem(STORAGE_KEY_LEGACY);
} catch (e) {
console.warn("[user-study] v1 migration failed:", e);
RATER = blankRater();
}
} else {
RATER = blankRater();
}
}
// Non-admins always see anonymized view — they can't toggle.
if (!isAdmin()) anonymize = true;
// Admins see all cases; regular raters see a deterministic random subset
// of size CASES_PER_RATER. Seeded by rater id so the same person always
// gets the same set across sessions / browsers.
CASES = selectCasesForRater(ALL_CASES);
// Sidebar is hidden by default; only auto-expand if the rater explicitly
// opened it in a prior session (sticky across reloads).
try {
if (localStorage.getItem("user_study_sidebar_expanded") === "1") {
document.body.classList.add("sidebar-expanded");
}
} catch (e) { /* ignore */ }
buildShuffle(CASES);
saveState();
// Gate the whole app on registration: render the shell only after the user
// has supplied a name + valid email. Returning users skip the overlay.
if (!RATER.name || !RATER.email || !EMAIL_RE.test(RATER.email)) {
showLoginOverlay(true);
} else {
renderShell();
}
if (found.path.endsWith("example.json")) {
showToast(t("toast_loaded_example"));
}
// Probe the /api/submit endpoint. Done after registration so the pill, which
// lives in the toolbar, exists in the DOM.
if (RATER.name && RATER.email) probeSyncEndpoint();
// Pull admin-curated prompt highlights. Fire-and-forget; if the endpoint
// isn't reachable the page falls back to auto-detected controllability
// cues only.
loadAnnotations();
})();
/* =========================================================================
* Cross-device recovery — read the rater's existing in-progress JSON
* from the server so their locked case subset + earlier ratings carry
* across devices / browsers. Best-effort; falls through silently when the
* server is unreachable.
* ========================================================================= */
async function recoverRaterStateFromServer(email) {
if (!email) return;
try {
const r = await fetch(`./api/rater_state?email=${encodeURIComponent(email)}`,
{ cache: "no-cache" });
if (!r.ok) return;
const j = await r.json().catch(() => ({}));
if (!j.found || !j.state) return;
const remote = j.state;
// Locked case subset wins over any local guess.
const remoteRater = remote.rater || {};
if (Array.isArray(remoteRater.chosen_case_ids) && remoteRater.chosen_case_ids.length) {
RATER.chosen_case_ids = remoteRater.chosen_case_ids;
}
// Pull in any non-empty ratings from the server side that we don't have
// locally yet — never overwrites an existing local score.
for (const c of (remote.cases || [])) {
if (!c || !c.id) continue;
RATINGS[c.id] = RATINGS[c.id] || {};
for (const v of (c.videos || [])) {
const model = v.model;
if (!model) continue;
RATINGS[c.id][model] = RATINGS[c.id][model] || {};
for (const [qk, score] of Object.entries(v.ratings || {})) {
if (typeof score === "number" && score >= 1 && score <= 5 &&
RATINGS[c.id][model][qk] === undefined) {
RATINGS[c.id][model][qk] = score;
}
}
}
}
} catch (e) { /* offline / local-only — silent */ }
}
async function loadAnnotations() {
try {
const r = await fetch(API_ANNOTATIONS, { cache: "no-cache" });
if (!r.ok) return;
const j = await r.json().catch(() => ({}));
ADMIN_HIGHLIGHTS = j.annotations || {};
// Re-render the prompt now that we have the highlights.
if (CASES.length && RATER.name && RATER.email) {
const detail = document.getElementById("detail");
if (detail && detail.querySelector(".prompt-text")) renderDetail();
}
} catch (e) { /* offline / local-only — auto-detected highlights only */ }
}
</script>
</body>
</html>
|