gowtham0992 Codex commited on
Commit
46412d9
·
verified ·
1 Parent(s): 6a6aca7

Publish kitchen table UI

Browse files

Sync production kitchen table UI from GitHub commit 05e76bdadc9661e50d236aaaa7f6b1be52d6ec06.

Co-authored-by: Codex <codex@openai.com>

Files changed (4) hide show
  1. app.py +1620 -0
  2. eval/run_eval.py +2 -13
  3. tests/test_app_guard.py +93 -2
  4. tests/test_schema.py +9 -0
app.py CHANGED
@@ -1448,6 +1448,1622 @@ def paper_shield_html() -> str:
1448
  </html>"""
1449
 
1450
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1451
  def build_server() -> gr.Server:
1452
  app = gr.Server()
1453
 
@@ -1464,6 +3080,10 @@ def build_server() -> gr.Server:
1464
 
1465
  @app.get("/", response_class=HTMLResponse)
1466
  async def homepage() -> HTMLResponse:
 
 
 
 
1467
  return HTMLResponse(paper_shield_html())
1468
 
1469
  @app.get("/health")
 
1448
  </html>"""
1449
 
1450
 
1451
+ def kitchen_table_html() -> str:
1452
+ examples_json = json.dumps(EXAMPLES).replace("</", "<\\/")
1453
+ logo_uri = logo_data_uri()
1454
+ model_label = escape(model_status_label())
1455
+ backend_label = escape(backend_status_label())
1456
+ template = """<!doctype html>
1457
+ <html lang="en">
1458
+ <head>
1459
+ <meta charset="utf-8" />
1460
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1461
+ <title>Jawbreaker</title>
1462
+ <style>
1463
+ :root {
1464
+ color-scheme: light;
1465
+ --cream: #f5ead8;
1466
+ --cream-2: #fff7e8;
1467
+ --paper: #fffdf6;
1468
+ --paper-warm: #fff2d3;
1469
+ --ink: #16120d;
1470
+ --muted: #6b5a49;
1471
+ --line: #24160e;
1472
+ --wood-dark: #6f4527;
1473
+ --wood: #b6763f;
1474
+ --wood-light: #d8a062;
1475
+ --linen: #f8ecd4;
1476
+ --linen-red: rgba(188, 57, 49, .16);
1477
+ --tape: rgba(255, 233, 156, .82);
1478
+ --yellow: #f5c400;
1479
+ --red: #d94b48;
1480
+ --red-soft: #ffe0dd;
1481
+ --green: #23794d;
1482
+ --green-soft: #dcf6e8;
1483
+ --amber: #bd7412;
1484
+ --amber-soft: #fff0c8;
1485
+ --blue: #2559d9;
1486
+ --blue-soft: #e2ecff;
1487
+ --plum: #58304f;
1488
+ --shadow: 8px 8px 0 rgba(36, 22, 14, .95);
1489
+ --soft-shadow: 0 20px 60px rgba(36, 22, 14, .18);
1490
+ --font-display: Georgia, "Times New Roman", Times, serif;
1491
+ --font-body: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
1492
+ --font-label: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
1493
+ }
1494
+
1495
+ * { box-sizing: border-box; }
1496
+
1497
+ html {
1498
+ min-height: 100%;
1499
+ overflow-x: clip;
1500
+ background: var(--cream);
1501
+ }
1502
+
1503
+ body {
1504
+ margin: 0;
1505
+ min-height: 100vh;
1506
+ overflow-x: clip;
1507
+ color: var(--ink);
1508
+ font-family: var(--font-body);
1509
+ background:
1510
+ radial-gradient(circle at 14% 10%, rgba(245, 196, 0, .22), transparent 23rem),
1511
+ radial-gradient(circle at 86% 4%, rgba(217, 75, 72, .13), transparent 19rem),
1512
+ linear-gradient(90deg, rgba(36,22,14,.04) 1px, transparent 1px),
1513
+ var(--cream);
1514
+ background-size: auto, auto, 34px 34px, auto;
1515
+ }
1516
+
1517
+ button, textarea { font: inherit; }
1518
+ button { cursor: pointer; color: inherit; }
1519
+
1520
+ .table-shell {
1521
+ width: min(1420px, calc(100% - 32px));
1522
+ margin: 0 auto;
1523
+ padding: 26px 0 54px;
1524
+ }
1525
+
1526
+ .masthead {
1527
+ display: grid;
1528
+ grid-template-columns: minmax(0, 1fr) auto;
1529
+ gap: 24px;
1530
+ align-items: center;
1531
+ padding: 0 0 18px;
1532
+ border-bottom: 3px solid var(--line);
1533
+ }
1534
+
1535
+ .brand {
1536
+ display: flex;
1537
+ align-items: center;
1538
+ gap: 14px;
1539
+ min-width: 0;
1540
+ }
1541
+
1542
+ .brand img {
1543
+ width: 54px;
1544
+ height: 54px;
1545
+ border: 3px solid var(--line);
1546
+ border-radius: 12px;
1547
+ background: var(--paper);
1548
+ box-shadow: 4px 4px 0 var(--line);
1549
+ }
1550
+
1551
+ .brand h1 {
1552
+ margin: 0;
1553
+ font-family: var(--font-display);
1554
+ font-size: clamp(2.25rem, 5vw, 4.35rem);
1555
+ line-height: .82;
1556
+ letter-spacing: 0;
1557
+ }
1558
+
1559
+ .brand p {
1560
+ margin: 8px 0 0;
1561
+ color: var(--muted);
1562
+ font-size: clamp(1rem, 1.4vw, 1.25rem);
1563
+ line-height: 1.22;
1564
+ }
1565
+
1566
+ .tech-badges {
1567
+ display: flex;
1568
+ flex-wrap: wrap;
1569
+ justify-content: flex-end;
1570
+ gap: 8px;
1571
+ max-width: 520px;
1572
+ font-family: var(--font-label);
1573
+ font-size: .72rem;
1574
+ font-weight: 900;
1575
+ text-transform: uppercase;
1576
+ }
1577
+
1578
+ .tech-badges span {
1579
+ display: inline-flex;
1580
+ align-items: center;
1581
+ min-height: 28px;
1582
+ padding: 6px 10px;
1583
+ border: 2px solid var(--line);
1584
+ border-radius: 999px;
1585
+ background: var(--paper);
1586
+ }
1587
+
1588
+ .hero-copy {
1589
+ display: grid;
1590
+ grid-template-columns: minmax(0, .9fr) minmax(280px, .72fr);
1591
+ gap: clamp(22px, 4vw, 64px);
1592
+ align-items: end;
1593
+ padding: 26px 0 28px;
1594
+ }
1595
+
1596
+ .hero-copy h2 {
1597
+ margin: 0;
1598
+ max-width: 13ch;
1599
+ font-family: var(--font-display);
1600
+ font-size: clamp(2.35rem, 6vw, 5.35rem);
1601
+ line-height: .86;
1602
+ letter-spacing: 0;
1603
+ }
1604
+
1605
+ .hero-copy p {
1606
+ margin: 0;
1607
+ color: var(--muted);
1608
+ font-size: clamp(1rem, 1.35vw, 1.25rem);
1609
+ line-height: 1.45;
1610
+ }
1611
+
1612
+ .notice-row {
1613
+ display: flex;
1614
+ flex-wrap: wrap;
1615
+ gap: 10px;
1616
+ margin-top: 16px;
1617
+ }
1618
+
1619
+ .notice {
1620
+ width: fit-content;
1621
+ max-width: 100%;
1622
+ padding: 8px 11px;
1623
+ border: 2px solid var(--line);
1624
+ border-radius: 999px;
1625
+ background: var(--paper);
1626
+ color: var(--muted);
1627
+ font-family: var(--font-label);
1628
+ font-size: .72rem;
1629
+ font-weight: 900;
1630
+ line-height: 1.25;
1631
+ }
1632
+
1633
+ .family-rule {
1634
+ position: relative;
1635
+ margin-top: 18px;
1636
+ max-width: 620px;
1637
+ border: 2px solid var(--line);
1638
+ border-radius: 12px;
1639
+ background: var(--paper);
1640
+ padding: 14px 16px 14px 54px;
1641
+ box-shadow: 5px 5px 0 rgba(36,22,14,.88);
1642
+ transform: rotate(.6deg);
1643
+ }
1644
+
1645
+ .family-rule::before {
1646
+ content: "";
1647
+ position: absolute;
1648
+ left: 16px;
1649
+ top: 17px;
1650
+ width: 24px;
1651
+ height: 24px;
1652
+ border: 2px solid var(--line);
1653
+ border-radius: 50%;
1654
+ background:
1655
+ radial-gradient(circle at center, var(--red) 0 36%, transparent 37%),
1656
+ var(--yellow);
1657
+ }
1658
+
1659
+ .family-rule strong {
1660
+ display: block;
1661
+ margin-bottom: 3px;
1662
+ font-family: var(--font-label);
1663
+ font-size: .72rem;
1664
+ text-transform: uppercase;
1665
+ }
1666
+
1667
+ .family-rule span {
1668
+ color: var(--muted);
1669
+ line-height: 1.35;
1670
+ }
1671
+
1672
+ .tabletop {
1673
+ position: relative;
1674
+ display: grid;
1675
+ grid-template-columns: minmax(340px, 430px) minmax(0, 1fr);
1676
+ gap: clamp(28px, 4vw, 56px);
1677
+ align-items: start;
1678
+ padding: clamp(18px, 2.5vw, 34px);
1679
+ border: 3px solid var(--line);
1680
+ border-radius: 18px;
1681
+ background:
1682
+ linear-gradient(90deg, var(--linen-red) 0 10px, transparent 10px 34px),
1683
+ linear-gradient(0deg, var(--linen-red) 0 10px, transparent 10px 34px),
1684
+ radial-gradient(ellipse at 17% 16%, rgba(255,255,255,.22), transparent 10rem),
1685
+ repeating-linear-gradient(88deg, rgba(255,255,255,.12) 0 3px, transparent 3px 17px),
1686
+ linear-gradient(115deg, var(--wood-light), var(--wood) 44%, var(--wood-dark));
1687
+ background-size: 68px 68px, 68px 68px, auto, auto, auto;
1688
+ box-shadow: var(--soft-shadow);
1689
+ overflow: hidden;
1690
+ isolation: isolate;
1691
+ }
1692
+
1693
+ .tabletop::before {
1694
+ content: "";
1695
+ position: absolute;
1696
+ inset: 18px auto auto 46%;
1697
+ width: 150px;
1698
+ height: 150px;
1699
+ border: 10px solid rgba(80, 43, 24, .18);
1700
+ border-radius: 50%;
1701
+ transform: rotate(-14deg);
1702
+ pointer-events: none;
1703
+ }
1704
+
1705
+ .kitchen-prop {
1706
+ position: absolute;
1707
+ z-index: 0;
1708
+ pointer-events: none;
1709
+ }
1710
+
1711
+ .recipe-box {
1712
+ right: clamp(18px, 3vw, 56px);
1713
+ top: clamp(18px, 3vw, 42px);
1714
+ width: 178px;
1715
+ height: 88px;
1716
+ border: 3px solid var(--line);
1717
+ border-radius: 10px;
1718
+ background: #c84b38;
1719
+ box-shadow: 6px 6px 0 rgba(36,22,14,.95);
1720
+ transform: rotate(4deg);
1721
+ opacity: .92;
1722
+ }
1723
+
1724
+ .recipe-box::before,
1725
+ .recipe-box::after {
1726
+ content: "";
1727
+ position: absolute;
1728
+ left: 16px;
1729
+ right: 16px;
1730
+ height: 28px;
1731
+ border: 2px solid var(--line);
1732
+ border-bottom: 0;
1733
+ border-radius: 8px 8px 0 0;
1734
+ background: var(--paper);
1735
+ }
1736
+
1737
+ .recipe-box::before { top: -24px; transform: rotate(-3deg); }
1738
+ .recipe-box::after { top: -14px; transform: rotate(3deg); }
1739
+
1740
+ .tea-cup {
1741
+ right: clamp(22px, 4vw, 82px);
1742
+ bottom: clamp(22px, 4vw, 72px);
1743
+ width: 118px;
1744
+ height: 118px;
1745
+ border: 4px solid var(--line);
1746
+ border-radius: 50%;
1747
+ background:
1748
+ radial-gradient(circle at center, #6f3825 0 28%, #f4ead7 29% 46%, transparent 47%),
1749
+ var(--paper);
1750
+ box-shadow: 7px 7px 0 rgba(36,22,14,.9);
1751
+ transform: rotate(-8deg);
1752
+ opacity: .95;
1753
+ }
1754
+
1755
+ .tea-cup::after {
1756
+ content: "";
1757
+ position: absolute;
1758
+ right: -26px;
1759
+ top: 34px;
1760
+ width: 36px;
1761
+ height: 46px;
1762
+ border: 4px solid var(--line);
1763
+ border-left: 0;
1764
+ border-radius: 0 999px 999px 0;
1765
+ background: transparent;
1766
+ }
1767
+
1768
+ .spoon {
1769
+ left: 48%;
1770
+ bottom: 20px;
1771
+ width: 18px;
1772
+ height: 190px;
1773
+ border: 3px solid var(--line);
1774
+ border-radius: 999px;
1775
+ background: linear-gradient(#f7f1df, #bfb5a2);
1776
+ box-shadow: 4px 4px 0 rgba(36,22,14,.85);
1777
+ transform: rotate(58deg);
1778
+ opacity: .52;
1779
+ }
1780
+
1781
+ .quilt-runner {
1782
+ left: -42px;
1783
+ top: 18%;
1784
+ width: 180px;
1785
+ height: 70%;
1786
+ border: 3px solid rgba(36,22,14,.62);
1787
+ border-radius: 999px;
1788
+ background:
1789
+ linear-gradient(45deg, rgba(255,255,255,.22) 25%, transparent 25% 50%, rgba(255,255,255,.22) 50% 75%, transparent 75%),
1790
+ linear-gradient(135deg, rgba(217,75,72,.25), rgba(245,196,0,.22));
1791
+ background-size: 34px 34px, auto;
1792
+ transform: rotate(8deg);
1793
+ opacity: .62;
1794
+ }
1795
+
1796
+ .tabletop::after {
1797
+ content: "";
1798
+ position: absolute;
1799
+ right: 46px;
1800
+ bottom: 34px;
1801
+ width: 72px;
1802
+ height: 72px;
1803
+ border-radius: 50%;
1804
+ border: 18px solid rgba(255,255,255,.26);
1805
+ background: rgba(255,255,255,.14);
1806
+ box-shadow: 0 0 0 3px rgba(36,22,14,.2);
1807
+ pointer-events: none;
1808
+ }
1809
+
1810
+ .left-stack,
1811
+ .right-stack {
1812
+ position: relative;
1813
+ z-index: 1;
1814
+ display: grid;
1815
+ gap: 18px;
1816
+ }
1817
+
1818
+ .clipboard {
1819
+ border: 3px solid var(--line);
1820
+ border-radius: 14px;
1821
+ background: #f2d18d;
1822
+ box-shadow: var(--shadow);
1823
+ padding: 16px;
1824
+ transform: rotate(-1.2deg);
1825
+ }
1826
+
1827
+ .clip {
1828
+ width: 92px;
1829
+ height: 26px;
1830
+ margin: -4px auto 12px;
1831
+ border: 3px solid var(--line);
1832
+ border-radius: 0 0 14px 14px;
1833
+ background: linear-gradient(#c48a36, #8c5b25);
1834
+ box-shadow: inset 0 3px 0 rgba(255,255,255,.28);
1835
+ }
1836
+
1837
+ .pause-note {
1838
+ width: fit-content;
1839
+ max-width: 100%;
1840
+ margin: 0 auto 14px;
1841
+ padding: 9px 13px;
1842
+ border: 2px solid var(--line);
1843
+ border-radius: 4px;
1844
+ background: var(--tape);
1845
+ font-family: var(--font-label);
1846
+ font-size: .72rem;
1847
+ font-weight: 950;
1848
+ text-transform: uppercase;
1849
+ transform: rotate(1.8deg);
1850
+ box-shadow: 3px 3px 0 rgba(36,22,14,.72);
1851
+ }
1852
+
1853
+ .paper-card {
1854
+ position: relative;
1855
+ border: 2.5px solid var(--line);
1856
+ border-radius: 10px;
1857
+ background:
1858
+ linear-gradient(var(--paper) 31px, rgba(36,22,14,.07) 32px),
1859
+ var(--paper);
1860
+ background-size: 100% 32px;
1861
+ padding: 20px;
1862
+ }
1863
+
1864
+ .paper-card::after {
1865
+ content: "";
1866
+ position: absolute;
1867
+ right: 18px;
1868
+ top: -11px;
1869
+ width: 72px;
1870
+ height: 23px;
1871
+ border: 2px solid rgba(36,22,14,.22);
1872
+ background: var(--tape);
1873
+ transform: rotate(3deg);
1874
+ }
1875
+
1876
+ .paper-card h3,
1877
+ .memo h3,
1878
+ .history-card h3 {
1879
+ margin: 0 0 12px;
1880
+ font-family: var(--font-label);
1881
+ font-size: .82rem;
1882
+ font-weight: 950;
1883
+ text-transform: uppercase;
1884
+ letter-spacing: .02em;
1885
+ }
1886
+
1887
+ .privacy-note {
1888
+ margin: 0 0 14px;
1889
+ color: var(--muted);
1890
+ font-family: var(--font-label);
1891
+ font-size: .72rem;
1892
+ font-weight: 850;
1893
+ line-height: 1.35;
1894
+ }
1895
+
1896
+ textarea {
1897
+ width: 100%;
1898
+ min-height: 246px;
1899
+ resize: vertical;
1900
+ border: 2px solid var(--line);
1901
+ border-radius: 8px;
1902
+ background:
1903
+ linear-gradient(var(--paper) 31px, rgba(36,22,14,.08) 32px),
1904
+ var(--paper);
1905
+ background-size: 100% 32px;
1906
+ padding: 16px;
1907
+ color: var(--ink);
1908
+ font-size: 1.02rem;
1909
+ line-height: 1.55;
1910
+ outline: 3px solid transparent;
1911
+ outline-offset: 2px;
1912
+ }
1913
+
1914
+ textarea:focus-visible {
1915
+ outline-color: rgba(245,196,0,.88);
1916
+ box-shadow: 0 0 0 5px rgba(245,196,0,.22);
1917
+ }
1918
+
1919
+ .check-button {
1920
+ position: relative;
1921
+ width: 100%;
1922
+ min-height: 64px;
1923
+ margin-top: 18px;
1924
+ border: 3px solid var(--line);
1925
+ border-radius: 999px;
1926
+ background: var(--yellow);
1927
+ color: var(--ink);
1928
+ font-family: var(--font-label);
1929
+ font-size: 1rem;
1930
+ font-weight: 950;
1931
+ text-transform: uppercase;
1932
+ box-shadow: 0 7px 0 var(--line);
1933
+ transition: transform .16s ease, box-shadow .16s ease, opacity .16s ease;
1934
+ }
1935
+
1936
+ .check-button::after {
1937
+ content: "";
1938
+ position: absolute;
1939
+ inset: 9px 14px auto auto;
1940
+ width: 42px;
1941
+ height: 10px;
1942
+ border-radius: 999px;
1943
+ background: rgba(255,255,255,.35);
1944
+ }
1945
+
1946
+ .check-button:active {
1947
+ transform: translateY(5px);
1948
+ box-shadow: 0 2px 0 var(--line);
1949
+ }
1950
+
1951
+ .check-button[disabled] {
1952
+ opacity: .62;
1953
+ cursor: wait;
1954
+ }
1955
+
1956
+ .check-button:focus-visible,
1957
+ .sample:focus-visible,
1958
+ .copy-button:focus-visible {
1959
+ outline: 3px solid var(--blue);
1960
+ outline-offset: 3px;
1961
+ }
1962
+
1963
+ .samples {
1964
+ display: grid;
1965
+ gap: 10px;
1966
+ margin-top: 20px;
1967
+ }
1968
+
1969
+ .sample-label {
1970
+ display: flex;
1971
+ align-items: center;
1972
+ gap: 8px;
1973
+ margin: 18px 0 -8px;
1974
+ color: var(--muted);
1975
+ font-family: var(--font-label);
1976
+ font-size: .72rem;
1977
+ font-weight: 950;
1978
+ text-transform: uppercase;
1979
+ }
1980
+
1981
+ .sample-label::before {
1982
+ content: "";
1983
+ width: 16px;
1984
+ height: 16px;
1985
+ border: 2px solid var(--line);
1986
+ border-radius: 4px;
1987
+ background: var(--yellow);
1988
+ transform: rotate(-8deg);
1989
+ }
1990
+
1991
+ .sample {
1992
+ border: 2px solid var(--line);
1993
+ border-radius: 999px;
1994
+ background: var(--paper);
1995
+ padding: 10px 14px;
1996
+ text-align: left;
1997
+ font-size: .92rem;
1998
+ line-height: 1.25;
1999
+ white-space: nowrap;
2000
+ overflow: hidden;
2001
+ text-overflow: ellipsis;
2002
+ box-shadow: 3px 3px 0 rgba(36,22,14,.9);
2003
+ }
2004
+
2005
+ .history-card {
2006
+ border: 3px solid var(--line);
2007
+ border-radius: 14px;
2008
+ background: var(--paper);
2009
+ box-shadow: 5px 5px 0 var(--line);
2010
+ padding: 18px;
2011
+ transform: rotate(.8deg);
2012
+ }
2013
+
2014
+ .trusted-card {
2015
+ border: 3px solid var(--line);
2016
+ border-radius: 14px;
2017
+ background:
2018
+ linear-gradient(90deg, rgba(37,89,217,.13) 0 8px, transparent 8px 20px),
2019
+ var(--blue-soft);
2020
+ box-shadow: 5px 5px 0 var(--line);
2021
+ padding: 17px;
2022
+ transform: rotate(-.8deg);
2023
+ }
2024
+
2025
+ .trusted-card h3 {
2026
+ margin: 0 0 8px;
2027
+ font-family: var(--font-display);
2028
+ font-size: 1.35rem;
2029
+ line-height: 1;
2030
+ }
2031
+
2032
+ .trusted-card p {
2033
+ margin: 0;
2034
+ color: var(--muted);
2035
+ font-size: .94rem;
2036
+ line-height: 1.4;
2037
+ }
2038
+
2039
+ .history-list {
2040
+ display: grid;
2041
+ gap: 10px;
2042
+ }
2043
+
2044
+ .history-empty {
2045
+ color: var(--muted);
2046
+ font-size: .92rem;
2047
+ }
2048
+
2049
+ .history-row {
2050
+ display: grid;
2051
+ grid-template-columns: minmax(0, 1fr) auto;
2052
+ gap: 12px;
2053
+ align-items: start;
2054
+ border: 2px solid var(--line);
2055
+ border-radius: 8px;
2056
+ background: var(--red-soft);
2057
+ padding: 12px;
2058
+ font-size: .82rem;
2059
+ line-height: 1.3;
2060
+ }
2061
+
2062
+ .history-row.safe { background: var(--green-soft); }
2063
+ .history-row.suspicious,
2064
+ .history-row.needs_check { background: var(--amber-soft); }
2065
+
2066
+ .history-badge {
2067
+ border: 2px solid var(--line);
2068
+ border-radius: 999px;
2069
+ padding: 5px 8px;
2070
+ background: var(--red);
2071
+ color: #fff;
2072
+ font-family: var(--font-label);
2073
+ font-size: .62rem;
2074
+ font-weight: 950;
2075
+ text-transform: uppercase;
2076
+ }
2077
+
2078
+ .history-badge.safe { background: var(--green); }
2079
+ .history-badge.suspicious,
2080
+ .history-badge.needs_check { background: var(--amber); }
2081
+
2082
+ .memo,
2083
+ .result-card {
2084
+ position: relative;
2085
+ border: 3px solid var(--line);
2086
+ border-radius: 16px;
2087
+ background: var(--paper);
2088
+ box-shadow: var(--shadow);
2089
+ overflow: hidden;
2090
+ }
2091
+
2092
+ .memo {
2093
+ transform: rotate(.6deg);
2094
+ }
2095
+
2096
+ .memo::before,
2097
+ .result-card::before,
2098
+ .safe-action::before {
2099
+ content: "";
2100
+ position: absolute;
2101
+ left: 50%;
2102
+ top: -13px;
2103
+ width: 86px;
2104
+ height: 25px;
2105
+ border: 2px solid rgba(36,22,14,.3);
2106
+ background: var(--tape);
2107
+ transform: translateX(-50%) rotate(-2deg);
2108
+ z-index: 2;
2109
+ }
2110
+
2111
+ .memo-header,
2112
+ .result-header {
2113
+ display: flex;
2114
+ align-items: center;
2115
+ justify-content: space-between;
2116
+ gap: 16px;
2117
+ min-height: 48px;
2118
+ padding: 12px 18px;
2119
+ border-bottom: 3px solid var(--line);
2120
+ background: var(--paper-warm);
2121
+ font-family: var(--font-label);
2122
+ font-weight: 950;
2123
+ font-size: .82rem;
2124
+ text-transform: uppercase;
2125
+ }
2126
+
2127
+ .memo-body,
2128
+ .result-body {
2129
+ padding: clamp(20px, 3vw, 34px);
2130
+ }
2131
+
2132
+ .standby {
2133
+ display: grid;
2134
+ grid-template-columns: minmax(0, 1fr) 260px;
2135
+ gap: clamp(20px, 3vw, 42px);
2136
+ align-items: center;
2137
+ min-height: 430px;
2138
+ }
2139
+
2140
+ .standby h3 {
2141
+ margin: 0 0 12px;
2142
+ font-family: var(--font-display);
2143
+ font-size: clamp(2.2rem, 4vw, 4rem);
2144
+ line-height: .9;
2145
+ letter-spacing: 0;
2146
+ }
2147
+
2148
+ .standby p {
2149
+ max-width: 44rem;
2150
+ margin: 0;
2151
+ color: var(--muted);
2152
+ font-size: 1.05rem;
2153
+ line-height: 1.55;
2154
+ }
2155
+
2156
+ .standby-steps {
2157
+ display: grid;
2158
+ gap: 12px;
2159
+ margin: 24px 0 0;
2160
+ padding: 0;
2161
+ list-style: none;
2162
+ }
2163
+
2164
+ .standby-steps li {
2165
+ display: grid;
2166
+ grid-template-columns: auto minmax(0, 1fr);
2167
+ gap: 12px;
2168
+ align-items: start;
2169
+ max-width: 46rem;
2170
+ font-size: 1rem;
2171
+ line-height: 1.35;
2172
+ }
2173
+
2174
+ .standby-steps strong {
2175
+ display: inline-grid;
2176
+ place-items: center;
2177
+ width: 1.7rem;
2178
+ height: 1.7rem;
2179
+ border: 2px solid var(--line);
2180
+ border-radius: 999px;
2181
+ background: var(--yellow);
2182
+ font-family: var(--font-label);
2183
+ font-size: .86rem;
2184
+ }
2185
+
2186
+ .coaster-stack {
2187
+ display: grid;
2188
+ gap: 14px;
2189
+ }
2190
+
2191
+ .coaster {
2192
+ min-height: 104px;
2193
+ display: grid;
2194
+ align-content: center;
2195
+ gap: 7px;
2196
+ border: 3px solid var(--line);
2197
+ border-radius: 12px;
2198
+ background: var(--cream-2);
2199
+ padding: 16px;
2200
+ box-shadow: 4px 4px 0 var(--line);
2201
+ }
2202
+
2203
+ .coaster strong {
2204
+ font-family: var(--font-label);
2205
+ font-size: .76rem;
2206
+ text-transform: uppercase;
2207
+ }
2208
+
2209
+ .coaster span {
2210
+ color: var(--muted);
2211
+ line-height: 1.35;
2212
+ }
2213
+
2214
+ .fridge-note {
2215
+ position: relative;
2216
+ border: 3px solid var(--line);
2217
+ border-radius: 18px;
2218
+ background:
2219
+ linear-gradient(var(--paper) 31px, rgba(36,22,14,.08) 32px),
2220
+ var(--paper);
2221
+ background-size: 100% 32px;
2222
+ padding: 18px;
2223
+ box-shadow: 5px 5px 0 var(--line);
2224
+ transform: rotate(1.8deg);
2225
+ }
2226
+
2227
+ .fridge-note::before {
2228
+ content: "";
2229
+ position: absolute;
2230
+ left: 50%;
2231
+ top: -16px;
2232
+ width: 30px;
2233
+ height: 30px;
2234
+ border: 3px solid var(--line);
2235
+ border-radius: 50%;
2236
+ background: var(--red);
2237
+ transform: translateX(-50%);
2238
+ box-shadow: inset 0 0 0 6px rgba(255,255,255,.28);
2239
+ }
2240
+
2241
+ .fridge-note h4 {
2242
+ margin: 10px 0 10px;
2243
+ font-family: var(--font-display);
2244
+ font-size: 1.6rem;
2245
+ line-height: .95;
2246
+ }
2247
+
2248
+ .fridge-note ul {
2249
+ display: grid;
2250
+ gap: 9px;
2251
+ margin: 0;
2252
+ padding: 0;
2253
+ list-style: none;
2254
+ color: var(--muted);
2255
+ line-height: 1.28;
2256
+ }
2257
+
2258
+ .fridge-note li::before {
2259
+ content: "STOP";
2260
+ display: inline-block;
2261
+ margin-right: 8px;
2262
+ border: 2px solid var(--line);
2263
+ border-radius: 999px;
2264
+ background: var(--red);
2265
+ color: #fff;
2266
+ padding: 2px 5px;
2267
+ font-family: var(--font-label);
2268
+ font-size: .58rem;
2269
+ font-weight: 950;
2270
+ }
2271
+
2272
+ .loading .result-body {
2273
+ min-height: 410px;
2274
+ display: grid;
2275
+ align-content: center;
2276
+ gap: 20px;
2277
+ background:
2278
+ radial-gradient(circle at 80% 20%, rgba(245,196,0,.16), transparent 16rem),
2279
+ var(--paper);
2280
+ }
2281
+
2282
+ .reader-light {
2283
+ height: 22px;
2284
+ border: 3px solid var(--line);
2285
+ border-radius: 999px;
2286
+ background:
2287
+ linear-gradient(90deg, var(--yellow) var(--progress, 12%), transparent 0),
2288
+ repeating-linear-gradient(90deg, rgba(36,22,14,.16) 0 8px, transparent 8px 16px),
2289
+ var(--paper);
2290
+ box-shadow: 5px 5px 0 var(--line);
2291
+ }
2292
+
2293
+ .scan-note {
2294
+ border: 3px solid var(--line);
2295
+ border-radius: 14px;
2296
+ background: var(--cream-2);
2297
+ padding: 22px;
2298
+ transform: rotate(-.7deg);
2299
+ box-shadow: 5px 5px 0 var(--line);
2300
+ }
2301
+
2302
+ .scan-note::before {
2303
+ content: "";
2304
+ display: block;
2305
+ width: 64px;
2306
+ height: 64px;
2307
+ margin: 0 auto 14px;
2308
+ border: 3px solid var(--line);
2309
+ border-radius: 50%;
2310
+ background:
2311
+ radial-gradient(circle at center, var(--red) 0 25%, transparent 26%),
2312
+ radial-gradient(circle at center, transparent 0 42%, var(--yellow) 43% 60%, transparent 61%),
2313
+ var(--paper);
2314
+ animation: carefulPulse 1.2s ease-in-out infinite alternate;
2315
+ }
2316
+
2317
+ @keyframes carefulPulse {
2318
+ from { transform: scale(.95) rotate(-2deg); }
2319
+ to { transform: scale(1.05) rotate(2deg); }
2320
+ }
2321
+
2322
+ .scan-note h3 {
2323
+ margin: 0 0 14px;
2324
+ font-family: var(--font-display);
2325
+ font-size: clamp(1.8rem, 3vw, 2.8rem);
2326
+ line-height: .95;
2327
+ }
2328
+
2329
+ .scan-lines {
2330
+ display: grid;
2331
+ gap: 10px;
2332
+ margin: 18px 0 0;
2333
+ padding: 0;
2334
+ list-style: none;
2335
+ font-family: var(--font-label);
2336
+ font-weight: 900;
2337
+ color: var(--muted);
2338
+ }
2339
+
2340
+ .scan-lines li.active {
2341
+ color: var(--ink);
2342
+ }
2343
+
2344
+ .result-grid {
2345
+ display: grid;
2346
+ grid-template-columns: minmax(255px, .62fr) minmax(0, 1fr);
2347
+ gap: 18px;
2348
+ align-items: stretch;
2349
+ }
2350
+
2351
+ .decision-strip {
2352
+ display: flex;
2353
+ justify-content: center;
2354
+ flex-wrap: wrap;
2355
+ gap: 8px;
2356
+ margin-bottom: 4px;
2357
+ }
2358
+
2359
+ .decision-light {
2360
+ display: inline-flex;
2361
+ align-items: center;
2362
+ gap: 7px;
2363
+ padding: 7px 10px;
2364
+ border: 2px solid var(--line);
2365
+ border-radius: 999px;
2366
+ background: var(--paper);
2367
+ color: var(--muted);
2368
+ font-family: var(--font-label);
2369
+ font-size: .66rem;
2370
+ font-weight: 950;
2371
+ text-transform: uppercase;
2372
+ }
2373
+
2374
+ .decision-light::before {
2375
+ content: "";
2376
+ width: 10px;
2377
+ height: 10px;
2378
+ border: 2px solid var(--line);
2379
+ border-radius: 50%;
2380
+ background: #d7c7a6;
2381
+ }
2382
+
2383
+ .decision-light.on.dangerous::before { background: var(--red); }
2384
+ .decision-light.on.needs_check::before,
2385
+ .decision-light.on.suspicious::before { background: var(--amber); }
2386
+ .decision-light.on.safe::before { background: var(--green); }
2387
+
2388
+ .verdict-panel {
2389
+ position: relative;
2390
+ min-height: 285px;
2391
+ display: grid;
2392
+ align-content: center;
2393
+ justify-items: center;
2394
+ gap: 18px;
2395
+ border: 3px solid var(--line);
2396
+ border-radius: 14px;
2397
+ background: var(--red-soft);
2398
+ padding: 26px;
2399
+ text-align: center;
2400
+ }
2401
+
2402
+ .verdict-panel::before {
2403
+ content: "";
2404
+ position: absolute;
2405
+ left: -3px;
2406
+ top: 24px;
2407
+ bottom: 24px;
2408
+ width: 16px;
2409
+ border: 3px solid var(--line);
2410
+ border-left: 0;
2411
+ border-radius: 0 999px 999px 0;
2412
+ background: var(--red);
2413
+ }
2414
+
2415
+ .verdict-panel.safe::before { background: var(--green); }
2416
+ .verdict-panel.suspicious::before,
2417
+ .verdict-panel.needs_check::before { background: var(--amber); }
2418
+
2419
+ .verdict-panel.safe { background: var(--green-soft); }
2420
+ .verdict-panel.suspicious,
2421
+ .verdict-panel.needs_check { background: var(--amber-soft); }
2422
+
2423
+ .rubber-stamp {
2424
+ width: min(180px, 52vw);
2425
+ aspect-ratio: 1;
2426
+ display: grid;
2427
+ place-items: center;
2428
+ border: 7px double currentColor;
2429
+ border-radius: 999px;
2430
+ color: var(--red);
2431
+ font-family: var(--font-label);
2432
+ font-weight: 950;
2433
+ font-size: clamp(.84rem, 1.4vw, 1.15rem);
2434
+ text-align: center;
2435
+ text-transform: uppercase;
2436
+ transform: rotate(-11deg);
2437
+ animation: stampIn .34s cubic-bezier(.2, .95, .3, 1.2);
2438
+ }
2439
+
2440
+ .safe .rubber-stamp { color: var(--green); }
2441
+ .suspicious .rubber-stamp,
2442
+ .needs_check .rubber-stamp { color: var(--amber); }
2443
+
2444
+ @keyframes stampIn {
2445
+ 0% { transform: scale(1.55) rotate(-11deg); opacity: 0; filter: blur(3px); }
2446
+ 70% { transform: scale(.92) rotate(-11deg); opacity: 1; filter: blur(0); }
2447
+ 100% { transform: scale(1) rotate(-11deg); }
2448
+ }
2449
+
2450
+ .verdict-panel h3 {
2451
+ margin: 0;
2452
+ font-family: var(--font-display);
2453
+ font-size: clamp(1.75rem, 3vw, 3rem);
2454
+ line-height: .94;
2455
+ }
2456
+
2457
+ .verdict-panel p {
2458
+ margin: 0;
2459
+ color: var(--muted);
2460
+ line-height: 1.45;
2461
+ }
2462
+
2463
+ .dna-board {
2464
+ display: grid;
2465
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2466
+ gap: 12px;
2467
+ }
2468
+
2469
+ .dna-card {
2470
+ min-height: 122px;
2471
+ border: 3px solid var(--line);
2472
+ border-radius: 12px;
2473
+ background:
2474
+ linear-gradient(135deg, var(--paper) 0 78%, #eedbb7 79%),
2475
+ var(--paper);
2476
+ padding: 14px;
2477
+ box-shadow: 3px 3px 0 var(--line);
2478
+ }
2479
+
2480
+ .dna-card small {
2481
+ display: block;
2482
+ margin-bottom: 12px;
2483
+ font-family: var(--font-label);
2484
+ font-size: .68rem;
2485
+ font-weight: 950;
2486
+ text-transform: uppercase;
2487
+ }
2488
+
2489
+ .dna-card strong {
2490
+ display: block;
2491
+ font-family: var(--font-display);
2492
+ font-size: clamp(1rem, 1.5vw, 1.35rem);
2493
+ line-height: 1.05;
2494
+ overflow-wrap: anywhere;
2495
+ }
2496
+
2497
+ .tactic-row {
2498
+ display: flex;
2499
+ flex-wrap: wrap;
2500
+ gap: 8px;
2501
+ margin-top: 14px;
2502
+ }
2503
+
2504
+ .dna-caption {
2505
+ margin: 0 0 12px;
2506
+ font-family: var(--font-label);
2507
+ font-size: .72rem;
2508
+ font-weight: 950;
2509
+ text-transform: uppercase;
2510
+ color: var(--muted);
2511
+ }
2512
+
2513
+ .tag {
2514
+ border: 2px solid var(--line);
2515
+ border-radius: 999px;
2516
+ background: var(--red);
2517
+ color: #fff;
2518
+ padding: 6px 9px;
2519
+ font-family: var(--font-label);
2520
+ font-size: .68rem;
2521
+ font-weight: 950;
2522
+ text-transform: uppercase;
2523
+ }
2524
+
2525
+ .safe-action {
2526
+ position: relative;
2527
+ margin-top: 18px;
2528
+ border: 3px solid var(--line);
2529
+ border-radius: 16px;
2530
+ background: var(--green-soft);
2531
+ box-shadow: var(--shadow);
2532
+ overflow: hidden;
2533
+ }
2534
+
2535
+ .safe-action::after {
2536
+ content: "";
2537
+ position: absolute;
2538
+ right: 18px;
2539
+ top: 56px;
2540
+ width: 90px;
2541
+ height: 90px;
2542
+ border: 3px solid rgba(36,22,14,.18);
2543
+ border-radius: 50%;
2544
+ background:
2545
+ radial-gradient(circle at center, rgba(35,121,77,.3) 0 30%, transparent 31%),
2546
+ transparent;
2547
+ transform: rotate(12deg);
2548
+ pointer-events: none;
2549
+ }
2550
+
2551
+ .safe-action-header {
2552
+ display: flex;
2553
+ align-items: center;
2554
+ justify-content: space-between;
2555
+ gap: 14px;
2556
+ padding: 12px 18px;
2557
+ border-bottom: 3px solid var(--line);
2558
+ background: var(--paper-warm);
2559
+ font-family: var(--font-label);
2560
+ font-size: .82rem;
2561
+ font-weight: 950;
2562
+ text-transform: uppercase;
2563
+ }
2564
+
2565
+ .safe-action-body {
2566
+ padding: clamp(18px, 3vw, 28px);
2567
+ }
2568
+
2569
+ .safe-action-body h3 {
2570
+ margin: 0 0 12px;
2571
+ font-family: var(--font-label);
2572
+ font-size: .78rem;
2573
+ text-transform: uppercase;
2574
+ }
2575
+
2576
+ .safe-action-body .action-text {
2577
+ margin: 0 0 20px;
2578
+ font-family: var(--font-display);
2579
+ font-size: clamp(1.3rem, 2.4vw, 2.35rem);
2580
+ font-weight: 850;
2581
+ line-height: 1.02;
2582
+ }
2583
+
2584
+ .copy-grid {
2585
+ display: grid;
2586
+ grid-template-columns: minmax(0, 1fr) 170px;
2587
+ gap: 14px;
2588
+ align-items: stretch;
2589
+ }
2590
+
2591
+ .copy-note {
2592
+ max-height: 170px;
2593
+ overflow: auto;
2594
+ white-space: pre-wrap;
2595
+ border: 3px solid var(--line);
2596
+ border-radius: 10px;
2597
+ background: var(--paper);
2598
+ padding: 14px;
2599
+ font-size: .88rem;
2600
+ line-height: 1.4;
2601
+ }
2602
+
2603
+ .copy-button {
2604
+ border: 3px solid var(--line);
2605
+ border-radius: 10px;
2606
+ background: var(--paper);
2607
+ font-family: var(--font-label);
2608
+ font-size: .86rem;
2609
+ font-weight: 950;
2610
+ text-transform: uppercase;
2611
+ box-shadow: 5px 5px 0 var(--line);
2612
+ transition: transform .16s ease, box-shadow .16s ease;
2613
+ }
2614
+
2615
+ .copy-button::before {
2616
+ content: "Shareable note";
2617
+ display: block;
2618
+ margin-bottom: 8px;
2619
+ color: var(--muted);
2620
+ font-size: .62rem;
2621
+ font-weight: 950;
2622
+ }
2623
+
2624
+ .copy-button:active {
2625
+ transform: translateY(4px);
2626
+ box-shadow: 1px 1px 0 var(--line);
2627
+ }
2628
+
2629
+ .footer-strip {
2630
+ display: flex;
2631
+ flex-wrap: wrap;
2632
+ justify-content: center;
2633
+ gap: 8px 12px;
2634
+ margin-top: 26px;
2635
+ color: rgba(255,255,255,.92);
2636
+ font-family: var(--font-label);
2637
+ font-size: .78rem;
2638
+ font-weight: 850;
2639
+ text-align: center;
2640
+ text-shadow: 0 1px 0 rgba(0,0,0,.35);
2641
+ }
2642
+
2643
+ .footer-strip strong { color: #fff; }
2644
+
2645
+ .error {
2646
+ border: 3px solid var(--line);
2647
+ border-radius: 12px;
2648
+ background: var(--red-soft);
2649
+ padding: 16px;
2650
+ font-weight: 850;
2651
+ }
2652
+
2653
+ @media (max-width: 1120px) {
2654
+ .masthead,
2655
+ .hero-copy,
2656
+ .tabletop,
2657
+ .standby,
2658
+ .result-grid {
2659
+ grid-template-columns: 1fr;
2660
+ }
2661
+
2662
+ .tech-badges {
2663
+ justify-content: flex-start;
2664
+ }
2665
+ }
2666
+
2667
+ @media (max-width: 720px) {
2668
+ .table-shell {
2669
+ width: min(100% - 20px, 620px);
2670
+ padding-top: 16px;
2671
+ }
2672
+
2673
+ .brand img {
2674
+ width: 44px;
2675
+ height: 44px;
2676
+ }
2677
+
2678
+ .hero-copy h2 {
2679
+ max-width: none;
2680
+ font-size: clamp(2.25rem, 13vw, 3.65rem);
2681
+ }
2682
+
2683
+ .tabletop {
2684
+ padding: 14px;
2685
+ border-radius: 14px;
2686
+ }
2687
+
2688
+ .clipboard,
2689
+ .history-card,
2690
+ .memo,
2691
+ .trusted-card,
2692
+ .fridge-note {
2693
+ transform: none;
2694
+ }
2695
+
2696
+ .kitchen-prop {
2697
+ display: none;
2698
+ }
2699
+
2700
+ .paper-card,
2701
+ .memo-body,
2702
+ .result-body {
2703
+ padding: 16px;
2704
+ }
2705
+
2706
+ textarea {
2707
+ min-height: 190px;
2708
+ font-size: .96rem;
2709
+ }
2710
+
2711
+ .dna-board,
2712
+ .copy-grid {
2713
+ grid-template-columns: 1fr;
2714
+ }
2715
+
2716
+ .copy-button {
2717
+ min-height: 66px;
2718
+ }
2719
+
2720
+ .family-rule {
2721
+ padding-left: 16px;
2722
+ }
2723
+
2724
+ .family-rule::before {
2725
+ display: none;
2726
+ }
2727
+ }
2728
+ </style>
2729
+ </head>
2730
+ <body>
2731
+ <main class="table-shell">
2732
+ <header class="masthead">
2733
+ <div class="brand">
2734
+ <img src="__LOGO_URI__" alt="" />
2735
+ <div>
2736
+ <h1>Jawbreaker</h1>
2737
+ <p>Private scam defense for someone you love.</p>
2738
+ </div>
2739
+ </div>
2740
+ <div class="tech-badges" aria-label="Build details">
2741
+ <span>__MODEL_LABEL__</span>
2742
+ <span id="modelState">Ready</span>
2743
+ </div>
2744
+ </header>
2745
+
2746
+ <section class="hero-copy" aria-labelledby="heroTitle">
2747
+ <div>
2748
+ <h2 id="heroTitle">Pull up a chair. Let's read it together.</h2>
2749
+ </div>
2750
+ <div>
2751
+ <p>Paste a text, email, or DM that feels off. Jawbreaker turns it into a simple safety note: the risk, the warning signs, and the safest next step before you click, reply, or pay.</p>
2752
+ <div class="family-rule">
2753
+ <strong>The kitchen-table rule</strong>
2754
+ <span>If a message creates panic, asks for money, or sends you to a link, bring it here before you act.</span>
2755
+ </div>
2756
+ <div class="notice-row">
2757
+ <div class="notice">Safety aid only. Always verify through official channels.</div>
2758
+ <div class="notice">First check may take up to 30s while the secure system wakes up.</div>
2759
+ </div>
2760
+ </div>
2761
+ </section>
2762
+
2763
+ <section class="tabletop">
2764
+ <div class="kitchen-prop recipe-box" aria-hidden="true"></div>
2765
+ <div class="kitchen-prop tea-cup" aria-hidden="true"></div>
2766
+ <div class="kitchen-prop spoon" aria-hidden="true"></div>
2767
+ <div class="kitchen-prop quilt-runner" aria-hidden="true"></div>
2768
+ <aside class="left-stack">
2769
+ <form id="scanForm" class="clipboard">
2770
+ <div class="clip" aria-hidden="true"></div>
2771
+ <div class="pause-note">If it asks for money, pause.</div>
2772
+ <div class="paper-card">
2773
+ <h3>Message to check</h3>
2774
+ <p class="privacy-note">For your privacy: remove passwords, account numbers, ID numbers, addresses, and personal codes before pasting.</p>
2775
+ <textarea id="messageInput" placeholder="Paste the message here exactly as you received it."></textarea>
2776
+ <button id="scanButton" class="check-button" type="submit">Check this message</button>
2777
+ <div class="sample-label">Try one from the recipe box</div>
2778
+ <div class="samples" id="samples" aria-label="Sample messages"></div>
2779
+ </div>
2780
+ </form>
2781
+
2782
+ <section class="trusted-card">
2783
+ <h3>Call the number you already trust.</h3>
2784
+ <p>If Jawbreaker says stop or check, do not use the number or link in the message. Open the official app, website, or a saved contact.</p>
2785
+ </section>
2786
+
2787
+ <section class="history-card" aria-live="polite">
2788
+ <h3>Earlier checks</h3>
2789
+ <div class="history-list" id="memoryList"><span class="history-empty">No messages checked yet this session.</span></div>
2790
+ </section>
2791
+ </aside>
2792
+
2793
+ <section class="right-stack" id="result" aria-live="polite"></section>
2794
+
2795
+ <footer class="footer-strip" aria-label="Runtime details">
2796
+ <span>Built for <strong>Build Small Hackathon</strong></span>
2797
+ <span>Powered by <strong id="modelLabel">__MODEL_LABEL__</strong></span>
2798
+ <span><strong>Gradio Server</strong></span>
2799
+ <span><strong>__BACKEND_LABEL__</strong> runtime</span>
2800
+ </footer>
2801
+ </section>
2802
+ </main>
2803
+
2804
+ <script type="application/json" id="examplesData">__EXAMPLES_JSON__</script>
2805
+ <script type="module">
2806
+ import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
2807
+
2808
+ const examples = JSON.parse(document.getElementById("examplesData").textContent);
2809
+ const state = { memory: [] };
2810
+ const clientPromise = Client.connect(window.location.origin);
2811
+ const form = document.getElementById("scanForm");
2812
+ const messageInput = document.getElementById("messageInput");
2813
+ const scanButton = document.getElementById("scanButton");
2814
+ const result = document.getElementById("result");
2815
+ const samples = document.getElementById("samples");
2816
+ const memoryList = document.getElementById("memoryList");
2817
+ const modelLabel = document.getElementById("modelLabel");
2818
+ const modelState = document.getElementById("modelState");
2819
+
2820
+ const labels = {
2821
+ dangerous: { stamp: "Do not click", title: "This looks dangerous", className: "dangerous" },
2822
+ suspicious: { stamp: "Check first", title: "This needs a closer look", className: "suspicious" },
2823
+ needs_check: { stamp: "Check first", title: "Verify before acting", className: "needs_check" },
2824
+ safe: { stamp: "Looks ok", title: "No strong scam pattern", className: "safe" }
2825
+ };
2826
+
2827
+ const dnaLabels = {
2828
+ Impersonates: "Who they pretend to be",
2829
+ Pressure: "How they pressure you",
2830
+ Ask: "What they want",
2831
+ Risk: "What could happen"
2832
+ };
2833
+
2834
+ function escapeHtml(value) {
2835
+ return String(value || "")
2836
+ .replaceAll("&", "&amp;")
2837
+ .replaceAll("<", "&lt;")
2838
+ .replaceAll(">", "&gt;")
2839
+ .replaceAll('"', "&quot;")
2840
+ .replaceAll("'", "&#39;");
2841
+ }
2842
+
2843
+ function humanize(value) {
2844
+ return String(value || "Unknown").replaceAll("_", " ").replaceAll("-", " ");
2845
+ }
2846
+
2847
+ function elapsedText(value) {
2848
+ const numeric = Number(value);
2849
+ if (!Number.isFinite(numeric) || numeric <= 0) return "checked";
2850
+ return `${numeric.toFixed(numeric >= 10 ? 0 : 1)}s`;
2851
+ }
2852
+
2853
+ function showStandby() {
2854
+ result.innerHTML = `
2855
+ <article class="memo">
2856
+ <div class="memo-header"><span>Kitchen table check</span><span>private safety check</span></div>
2857
+ <div class="memo-body standby">
2858
+ <div>
2859
+ <h3>Paste first. Act after.</h3>
2860
+ <p>Scammers count on panic. This table is the pause: one message, one check, one safe next step you can share with someone you trust.</p>
2861
+ <ol class="standby-steps">
2862
+ <li><strong>1</strong><span>Paste the message exactly as you received it.</span></li>
2863
+ <li><strong>2</strong><span>Read the stamped verdict and warning signs.</span></li>
2864
+ <li><strong>3</strong><span>Copy the plan if you want a trusted person to check it with you.</span></li>
2865
+ </ol>
2866
+ </div>
2867
+ <div class="coaster-stack" aria-label="What Jawbreaker returns">
2868
+ <div class="fridge-note">
2869
+ <h4>Before you tap</h4>
2870
+ <ul>
2871
+ <li>Do not rush.</li>
2872
+ <li>Do not send codes.</li>
2873
+ <li>Do not pay inside a text.</li>
2874
+ </ul>
2875
+ </div>
2876
+ </div>
2877
+ </div>
2878
+ </article>
2879
+ `;
2880
+ }
2881
+
2882
+ function showLoading() {
2883
+ result.innerHTML = `
2884
+ <article class="result-card loading">
2885
+ <div class="result-header"><span>Checking the message</span><span id="progressText">Starting</span></div>
2886
+ <div class="result-body">
2887
+ <div class="scan-note">
2888
+ <h3>Reading this carefully.</h3>
2889
+ <div class="reader-light" id="readerLight" style="--progress: 12%"></div>
2890
+ <ul class="scan-lines">
2891
+ <li class="active">pulling the message closer...</li>
2892
+ <li>checking who they pretend to be...</li>
2893
+ <li>looking for pressure or payment asks...</li>
2894
+ <li>writing the safest next step...</li>
2895
+ </ul>
2896
+ </div>
2897
+ </div>
2898
+ </article>
2899
+ `;
2900
+ let progress = 12;
2901
+ const timer = window.setInterval(() => {
2902
+ progress = Math.min(88, progress + 8);
2903
+ const light = document.getElementById("readerLight");
2904
+ const text = document.getElementById("progressText");
2905
+ const lines = [...document.querySelectorAll(".scan-lines li")];
2906
+ if (!light || !text) {
2907
+ window.clearInterval(timer);
2908
+ return;
2909
+ }
2910
+ light.style.setProperty("--progress", `${progress}%`);
2911
+ text.textContent = progress < 88 ? `${progress}%` : "Almost ready";
2912
+ const activeIndex = Math.min(lines.length - 1, Math.floor(progress / 24));
2913
+ lines.forEach((line, index) => line.classList.toggle("active", index <= activeIndex));
2914
+ }, 700);
2915
+ return timer;
2916
+ }
2917
+
2918
+ function renderMemory(items) {
2919
+ if (!items || !items.length) {
2920
+ memoryList.innerHTML = `<span class="history-empty">No messages checked yet this session.</span>`;
2921
+ return;
2922
+ }
2923
+ memoryList.innerHTML = items.slice(-4).reverse().map((item) => {
2924
+ const risk = item.risk_level || "needs_check";
2925
+ return `
2926
+ <div class="history-row ${escapeHtml(risk)}">
2927
+ <span>${escapeHtml(item.summary)}</span>
2928
+ <strong class="history-badge ${escapeHtml(risk)}">${escapeHtml(humanize(risk))}</strong>
2929
+ </div>
2930
+ `;
2931
+ }).join("");
2932
+ }
2933
+
2934
+ function renderVerdict(payload) {
2935
+ const analysis = payload.analysis || {};
2936
+ const risk = analysis.risk_level || "needs_check";
2937
+ const label = labels[risk] || labels.needs_check;
2938
+ const dna = analysis.scam_dna || {};
2939
+ const tactics = analysis.tactics || [];
2940
+ modelLabel.textContent = payload.model_label || modelLabel.textContent;
2941
+ state.memory = payload.memory || state.memory;
2942
+ renderMemory(state.memory);
2943
+
2944
+ const dnaHtml = ["Impersonates", "Pressure", "Ask", "Risk"].map((key) => `
2945
+ <div class="dna-card">
2946
+ <small>${dnaLabels[key]}</small>
2947
+ <strong>${escapeHtml(humanize(dna[key]))}</strong>
2948
+ </div>
2949
+ `).join("");
2950
+
2951
+ const tacticsHtml = tactics.length
2952
+ ? tactics.map((tag) => `<span class="tag">${escapeHtml(humanize(tag))}</span>`).join("")
2953
+ : `<span class="tag">none found</span>`;
2954
+
2955
+ result.innerHTML = `
2956
+ <article class="result-card">
2957
+ <div class="result-header"><span>Jawbreaker verdict</span><span>${escapeHtml(elapsedText(payload.elapsed_seconds))}</span></div>
2958
+ <div class="result-body">
2959
+ <div class="result-grid">
2960
+ <section class="verdict-panel ${escapeHtml(label.className)}">
2961
+ <div class="decision-strip" aria-label="Safety signal">
2962
+ <span class="decision-light dangerous ${risk === "dangerous" ? "on" : ""}">Stop</span>
2963
+ <span class="decision-light needs_check ${risk === "needs_check" || risk === "suspicious" ? "on" : ""}">Check</span>
2964
+ <span class="decision-light safe ${risk === "safe" ? "on" : ""}">Safe</span>
2965
+ </div>
2966
+ <div class="rubber-stamp">${escapeHtml(label.stamp)}</div>
2967
+ <h3>${escapeHtml(label.title)}</h3>
2968
+ <p>${escapeHtml(analysis.summary)}</p>
2969
+ </section>
2970
+ <section>
2971
+ <p class="dna-caption">Why Jawbreaker thinks so</p>
2972
+ <div class="dna-board">${dnaHtml}</div>
2973
+ <div class="tactic-row">${tacticsHtml}</div>
2974
+ </section>
2975
+ </div>
2976
+ </div>
2977
+ </article>
2978
+
2979
+ <article class="safe-action">
2980
+ <div class="safe-action-header"><span>What to do before you act</span><span>send to someone you trust</span></div>
2981
+ <div class="safe-action-body">
2982
+ <h3>Safest next step</h3>
2983
+ <p class="action-text">${escapeHtml(analysis.safest_action)}</p>
2984
+ <div class="copy-grid">
2985
+ <div class="copy-note">${escapeHtml(payload.copy_plan)}</div>
2986
+ <button class="copy-button" id="copyButton" type="button">Copy plan</button>
2987
+ </div>
2988
+ </div>
2989
+ </article>
2990
+ `;
2991
+
2992
+ document.getElementById("copyButton").addEventListener("click", async () => {
2993
+ await navigator.clipboard.writeText(payload.copy_plan || "");
2994
+ const button = document.getElementById("copyButton");
2995
+ button.textContent = "Copied";
2996
+ button.style.transform = "rotate(-4deg) scale(1.02)";
2997
+ window.setTimeout(() => {
2998
+ button.textContent = "Copy plan";
2999
+ button.style.transform = "";
3000
+ }, 1200);
3001
+ });
3002
+ }
3003
+
3004
+ function showError(error) {
3005
+ result.innerHTML = `
3006
+ <article class="result-card">
3007
+ <div class="result-header"><span>Could not finish</span><span>try again</span></div>
3008
+ <div class="result-body"><div class="error">${escapeHtml(error.message || error)}</div></div>
3009
+ </article>
3010
+ `;
3011
+ }
3012
+
3013
+ examples.forEach((example, index) => {
3014
+ const button = document.createElement("button");
3015
+ button.type = "button";
3016
+ button.className = "sample";
3017
+ button.textContent = example;
3018
+ button.addEventListener("click", () => {
3019
+ messageInput.value = example;
3020
+ if (index === 0) {
3021
+ scanButton.focus();
3022
+ } else {
3023
+ messageInput.focus();
3024
+ }
3025
+ });
3026
+ samples.appendChild(button);
3027
+ });
3028
+
3029
+ form.addEventListener("submit", async (event) => {
3030
+ event.preventDefault();
3031
+ const message = messageInput.value.trim();
3032
+ if (!message) {
3033
+ messageInput.focus();
3034
+ return;
3035
+ }
3036
+ scanButton.disabled = true;
3037
+ scanButton.textContent = "Checking";
3038
+ modelState.textContent = "Checking";
3039
+ const timer = showLoading();
3040
+ try {
3041
+ const client = await clientPromise;
3042
+ const response = await client.predict("/analyze", { message, memory: state.memory });
3043
+ window.clearInterval(timer);
3044
+ renderVerdict(response.data?.[0] || response);
3045
+ } catch (error) {
3046
+ window.clearInterval(timer);
3047
+ showError(error);
3048
+ } finally {
3049
+ scanButton.disabled = false;
3050
+ scanButton.textContent = "Check this message";
3051
+ modelState.textContent = "Ready";
3052
+ }
3053
+ });
3054
+
3055
+ showStandby();
3056
+ </script>
3057
+ </body>
3058
+ </html>"""
3059
+ return (
3060
+ template.replace("__LOGO_URI__", logo_uri)
3061
+ .replace("__MODEL_LABEL__", model_label)
3062
+ .replace("__BACKEND_LABEL__", backend_label)
3063
+ .replace("__EXAMPLES_JSON__", examples_json)
3064
+ )
3065
+
3066
+
3067
  def build_server() -> gr.Server:
3068
  app = gr.Server()
3069
 
 
3080
 
3081
  @app.get("/", response_class=HTMLResponse)
3082
  async def homepage() -> HTMLResponse:
3083
+ return HTMLResponse(kitchen_table_html())
3084
+
3085
+ @app.get("/classic", response_class=HTMLResponse)
3086
+ async def classic_homepage() -> HTMLResponse:
3087
  return HTMLResponse(paper_shield_html())
3088
 
3089
  @app.get("/health")
eval/run_eval.py CHANGED
@@ -20,6 +20,7 @@ from jawbreaker.analyzers import ( # noqa: E402
20
  prediction_file_analyzer,
21
  prediction_to_analysis,
22
  repair_prediction,
 
23
  validate_prediction,
24
  write_predictions,
25
  )
@@ -158,19 +159,7 @@ def build_analyzer(args: argparse.Namespace):
158
  def apply_safety_guard(message: str, prediction: dict[str, Any]) -> tuple[dict[str, Any], bool]:
159
  model_analysis = prediction_to_analysis(prediction)
160
  heuristic = ScamAnalysis.from_heuristics(message)
161
-
162
- risk_rank = {"safe": 0, "needs_check": 1, "suspicious": 2, "dangerous": 3}
163
- if risk_rank[model_analysis.risk_level] < risk_rank[heuristic.risk_level]:
164
- if heuristic.risk_level != "safe":
165
- return analysis_to_prediction(heuristic), True
166
-
167
- if heuristic.risk_level == "safe":
168
- return prediction, False
169
-
170
- dna_values = [value.strip() for value in model_analysis.scam_dna.values()]
171
- has_dna = any(dna_values)
172
- has_tactics = bool(model_analysis.tactics)
173
- if not has_dna and not has_tactics:
174
  return analysis_to_prediction(heuristic), True
175
 
176
  return prediction, False
 
20
  prediction_file_analyzer,
21
  prediction_to_analysis,
22
  repair_prediction,
23
+ should_apply_heuristic_guard,
24
  validate_prediction,
25
  write_predictions,
26
  )
 
159
  def apply_safety_guard(message: str, prediction: dict[str, Any]) -> tuple[dict[str, Any], bool]:
160
  model_analysis = prediction_to_analysis(prediction)
161
  heuristic = ScamAnalysis.from_heuristics(message)
162
+ if should_apply_heuristic_guard(message, model_analysis, heuristic, validation_errors=[]):
 
 
 
 
 
 
 
 
 
 
 
 
163
  return analysis_to_prediction(heuristic), True
164
 
165
  return prediction, False
tests/test_app_guard.py CHANGED
@@ -8,7 +8,7 @@ from app import (
8
  run_analysis,
9
  should_use_heuristic_guard,
10
  )
11
- from jawbreaker.analyzers import repair_prediction
12
  from jawbreaker.schema import ScamAnalysis
13
 
14
 
@@ -24,7 +24,12 @@ def test_guard_uses_heuristic_when_model_undercalls_dangerous_message() -> None:
24
  "Hi Grandma, I lost my phone. This is my new number. Can you send $800 today?"
25
  )
26
 
27
- assert should_use_heuristic_guard(model, heuristic, validation_errors=[])
 
 
 
 
 
28
 
29
 
30
  def test_guard_keeps_informative_model_result() -> None:
@@ -47,6 +52,70 @@ def test_guard_keeps_informative_model_result() -> None:
47
  assert not should_use_heuristic_guard(model, heuristic, validation_errors=[])
48
 
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  def test_remember_current_saves_last_scan_without_reanalysis(monkeypatch) -> None:
51
  def fail_if_called(*args, **kwargs):
52
  raise AssertionError("remember_current should not run analysis")
@@ -139,6 +208,28 @@ def test_repair_prediction_sanitizes_unsafe_action() -> None:
139
  assert "do not send money" in repaired["safest_action"]
140
 
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  def test_default_adapter_only_attaches_to_minicpm(monkeypatch) -> None:
143
  monkeypatch.delenv("JAWBREAKER_ADAPTER_ID", raising=False)
144
 
 
8
  run_analysis,
9
  should_use_heuristic_guard,
10
  )
11
+ from jawbreaker.analyzers import load_json_prediction, repair_prediction
12
  from jawbreaker.schema import ScamAnalysis
13
 
14
 
 
24
  "Hi Grandma, I lost my phone. This is my new number. Can you send $800 today?"
25
  )
26
 
27
+ assert should_use_heuristic_guard(
28
+ model,
29
+ heuristic,
30
+ validation_errors=[],
31
+ message="Hi Grandma, I lost my phone. This is my new number. Can you send $800 today?",
32
+ )
33
 
34
 
35
  def test_guard_keeps_informative_model_result() -> None:
 
52
  assert not should_use_heuristic_guard(model, heuristic, validation_errors=[])
53
 
54
 
55
+ def test_guard_does_not_escalate_official_route_notice_to_dangerous() -> None:
56
+ message = "Credit union alert: we noticed a new login. If this was not you, open the official app."
57
+ model = ScamAnalysis(
58
+ risk_level="needs_check",
59
+ scam_type="possible_legitimate_alert",
60
+ summary="Verify this through the official app.",
61
+ tactics=["verification needed"],
62
+ scam_dna={
63
+ "Impersonates": "credit union",
64
+ "Pressure": "routine security notice",
65
+ "Ask": "open official app",
66
+ "Risk": "uncertain legitimacy",
67
+ },
68
+ )
69
+ heuristic = ScamAnalysis.from_heuristics(message)
70
+
71
+ assert not should_use_heuristic_guard(model, heuristic, validation_errors=[], message=message)
72
+
73
+
74
+ def test_guard_does_not_escalate_password_changed_official_app_notice() -> None:
75
+ message = "Coinbase notice: your password was changed. Open the official app yourself to review account security."
76
+ model = ScamAnalysis(
77
+ risk_level="needs_check",
78
+ scam_type="possible_legitimate_alert",
79
+ summary="Verify this through the official app.",
80
+ tactics=["verification needed"],
81
+ scam_dna={
82
+ "Impersonates": "Coinbase",
83
+ "Pressure": "routine security notice",
84
+ "Ask": "open official app",
85
+ "Risk": "uncertain legitimacy",
86
+ },
87
+ )
88
+ heuristic = ScamAnalysis.from_heuristics(message)
89
+
90
+ assert heuristic.risk_level == "dangerous"
91
+ assert not should_use_heuristic_guard(model, heuristic, validation_errors=[], message=message)
92
+
93
+
94
+ def test_eval_guard_keeps_official_route_notice_calibrated() -> None:
95
+ from eval.run_eval import apply_safety_guard
96
+
97
+ message = "Hospital portal: new test results are available. Sign in through your usual patient portal."
98
+ prediction = {
99
+ "risk_level": "needs_check",
100
+ "scam_type": "possible_legitimate_alert",
101
+ "summary": "Verify this through the normal patient portal.",
102
+ "tactics": ["verification needed"],
103
+ "safest_action": "Verify directly through the official app, official website, or a known phone number.",
104
+ "trusted_person_message": "Can you check this with me?",
105
+ "scam_dna": {
106
+ "impersonates": "hospital portal",
107
+ "pressure": "routine notice",
108
+ "ask": "use normal patient portal",
109
+ "risk": "uncertain legitimacy",
110
+ },
111
+ }
112
+
113
+ guarded, promoted = apply_safety_guard(message, prediction)
114
+
115
+ assert not promoted
116
+ assert guarded["risk_level"] == "needs_check"
117
+
118
+
119
  def test_remember_current_saves_last_scan_without_reanalysis(monkeypatch) -> None:
120
  def fail_if_called(*args, **kwargs):
121
  raise AssertionError("remember_current should not run analysis")
 
208
  assert "do not send money" in repaired["safest_action"]
209
 
210
 
211
+ def test_load_json_prediction_recovers_near_json_model_output() -> None:
212
+ prediction = load_json_prediction(
213
+ """
214
+ {"risk_level": "dangerous", "scam_type": "tech_support",
215
+ "tactics": ["fake authority", "urgency"]
216
+ "scam_dna": {"impersonates": "device support", "pressure": "avoid data loss",
217
+ "ask": "call the alert number", "risk": "remote access theft"},
218
+ "safest_action": "Do not call the number. Contact official support directly.",
219
+ "trusted_person_message": "Can you check this with me?",
220
+ "summary": "Fake device warning pushing a support call."}
221
+ """
222
+ )
223
+
224
+ repaired = repair_prediction(prediction)
225
+
226
+ assert repaired["risk_level"] == "dangerous"
227
+ assert repaired["scam_type"] == "tech_support"
228
+ assert repaired["tactics"] == ["fake authority", "urgency"]
229
+ assert repaired["scam_dna"]["impersonates"] == "device support"
230
+ assert repaired["summary"] == "Fake device warning pushing a support call."
231
+
232
+
233
  def test_default_adapter_only_attaches_to_minicpm(monkeypatch) -> None:
234
  monkeypatch.delenv("JAWBREAKER_ADAPTER_ID", raising=False)
235
 
tests/test_schema.py CHANGED
@@ -31,6 +31,15 @@ def test_device_protection_subscription_alert_is_tech_support() -> None:
31
  assert analysis.scam_type == "tech_support"
32
 
33
 
 
 
 
 
 
 
 
 
 
34
  def test_prediction_to_analysis_normalizes_model_json() -> None:
35
  analysis = prediction_to_analysis(
36
  {
 
31
  assert analysis.scam_type == "tech_support"
32
 
33
 
34
+ def test_billing_link_with_account_closure_is_dangerous() -> None:
35
+ analysis = ScamAnalysis.from_heuristics(
36
+ "Streaming billing: payment failed and your account closes tonight. Update through this message link: http://billing-review.example"
37
+ )
38
+
39
+ assert analysis.risk_level == "dangerous"
40
+ assert analysis.scam_type == "credential_theft"
41
+
42
+
43
  def test_prediction_to_analysis_normalizes_model_json() -> None:
44
  analysis = prediction_to_analysis(
45
  {