also looking at this
Promote main to production: signed-out chrome and the subscriber tables
#17
54 files
+4252
-1353
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -746,6 +746,419 @@ select.app-panel { | ||
| 746 | 746 | } |
| 747 | 747 | ` |
| 748 | 748 | |
| 749 | /* | |
| 750 | * The marketing surface, scoped to its layout group. | |
| 751 | * | |
| 752 | * From public/marketing.css, linked by resources/layouts/marketing.stx alone | |
| 753 | * -- never site-wide, because this sheet gives `.btn`, `.panel` and `.shell` | |
| 754 | * meanings the app pages contradict. That is also why it cannot be an | |
| 755 | * unscoped preflight the way TOKENS and APP_CHROME could: a preflight reaches | |
| 756 | * every page. It carries the layout group instead, which stx emits into <head> | |
| 757 | * from the layout's filename and stx-router keeps fresh across navigations. | |
| 758 | * Measured on the served app: marketing pages report `marketing`, and every | |
| 759 | * other route including /login reports `default`. | |
| 760 | * | |
| 761 | * WHAT WAS DROPPED, AND WHY IT IS SAFE | |
| 762 | * | |
| 763 | * Six colour tokens -- --accent, --bg, --border, --text, --text-2, --text-3 -- | |
| 764 | * were declared here AND in TOKENS, byte-identical in all four theme blocks, | |
| 765 | * so marketing pages inherit them unchanged. --mono went with them: it | |
| 766 | * differed from the TOKENS copy only by double quotes against single, which | |
| 767 | * CSS does not distinguish. | |
| 768 | * | |
| 769 | * --sans STAYS, and is the one token here that really differs: this copy has | |
| 770 | * `-apple-system` in the stack and the TOKENS copy does not. It only shows if | |
| 771 | * Space Grotesk fails to load, but keeping it means this move changes nothing. | |
| 772 | * | |
| 773 | * The marketing-only tokens stay by definition: --surface, --surface-2, | |
| 774 | * --border-strong, --accent-line, --code-bg, --code-ink, --code-cap, --r, | |
| 775 | * --r-sm. | |
| 776 | * | |
| 777 | * WHAT HAD TO BE SPELLED OUT | |
| 778 | * | |
| 779 | * Nesting puts the prefix in front of a descendant, so a rule rooted AT or | |
| 780 | * ABOVE <html> cannot nest -- `html:has(...) html` matches nothing, and :root | |
| 781 | * IS html. The four token blocks, the `html` background rule and `*` are | |
| 782 | * written out with the prefix; `*` becomes `P, P *` because it matched html | |
| 783 | * itself. `body` nests perfectly well, being below html, and is written out | |
| 784 | * only to keep the document-level rules together. | |
| 785 | * | |
| 786 | * THE CASCADE | |
| 787 | * | |
| 788 | * Unlike APP_CHROME, nothing here needed !important. Every rule above | |
| 789 | * single-class specificity was walked against the class lists in the marketing | |
| 790 | * views and checked against the generated sheet: of 31 candidates, not one | |
| 791 | * sits on an element whose other classes emit a utility setting the same | |
| 792 | * property. The pairs that look like collisions -- `.btn.primary`, `.cell.tint` | |
| 793 | * -- are two marketing classes, not a marketing class and a utility. | |
| 794 | */ | |
| 795 | const MARKETING = ` | |
| 796 | html:has(meta[name="stx-layout-group"][content="marketing"]) { | |
| 797 | --surface: #ffffff; | |
| 798 | --surface-2: #f4f5f8; | |
| 799 | --border-strong: rgba(15, 23, 42, 0.16); | |
| 800 | --accent-line: rgba(4, 120, 87, 0.34); | |
| 801 | --code-bg: #0b0f19; | |
| 802 | --code-ink: #d6deeb; | |
| 803 | /* Caption ink for the code surface. Not --text-3: the code block is dark; | |
| 804 | * in BOTH themes, so the light --text-3 (#6b7280) lands on it at 3.96:1 — | |
| 805 | * the one WCAG AA failure left after the token pass. This clears 6.37:1 on | |
| 806 | * the light --code-bg and 6.70:1 on the dark one, so it needs no variant. */ | |
| 807 | --code-cap: #8b95ab; | |
| 808 | --r: 14px; | |
| 809 | --r-sm: 9px; | |
| 810 | --sans: "Space Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif; | |
| 811 | } | |
| 812 | ||
| 813 | @media (prefers-color-scheme: dark) { | |
| 814 | html:has(meta[name="stx-layout-group"][content="marketing"]) { | |
| 815 | --surface: #10131c; --surface-2: #161a24; | |
| 816 | --border-strong: rgba(148, 163, 184, 0.2); | |
| 817 | --accent-line: rgba(52, 211, 153, 0.32); | |
| 818 | --code-bg: #05070c; | |
| 819 | } | |
| 820 | } | |
| 821 | html:has(meta[name="stx-layout-group"][content="marketing"])[data-theme="dark"] { | |
| 822 | --surface: #10131c; --surface-2: #161a24; | |
| 823 | --border-strong: rgba(148, 163, 184, 0.2); | |
| 824 | --accent-line: rgba(52, 211, 153, 0.32); | |
| 825 | --code-bg: #05070c; | |
| 826 | } | |
| 827 | html:has(meta[name="stx-layout-group"][content="marketing"])[data-theme="light"] { | |
| 828 | --surface: #ffffff; --surface-2: #f4f5f8; | |
| 829 | --border-strong: rgba(15, 23, 42, 0.16); | |
| 830 | --accent-line: rgba(4, 120, 87, 0.34); | |
| 831 | --code-bg: #0b0f19; | |
| 832 | } | |
| 833 | ||
| 834 | html:has(meta[name="stx-layout-group"][content="marketing"]), html:has(meta[name="stx-layout-group"][content="marketing"]) * { box-sizing: border-box; } | |
| 835 | html:has(meta[name="stx-layout-group"][content="marketing"]) { background: var(--bg); scroll-behavior: smooth; -webkit-text-size-adjust: 100%; } | |
| 836 | html:has(meta[name="stx-layout-group"][content="marketing"]) body { | |
| 837 | margin: 0; min-height: 100vh; overflow-x: hidden; | |
| 838 | color: var(--text); background: var(--bg); | |
| 839 | font-family: var(--sans); font-size: 16px; line-height: 1.5; | |
| 840 | -webkit-font-smoothing: antialiased; | |
| 841 | } | |
| 842 | ||
| 843 | html:has(meta[name="stx-layout-group"][content="marketing"]) { | |
| 844 | a { color: inherit; text-decoration: none; } | |
| 845 | ||
| 846 | .shell { width: min(1180px, calc(100% - 48px)); margin: 0 auto; } | |
| 847 | .shell-narrow { width: min(920px, calc(100% - 48px)); margin: 0 auto; } | |
| 848 | .mono { font-family: var(--mono); } | |
| 849 | ||
| 850 | /* nav */ | |
| 851 | .nav { | |
| 852 | position: sticky; top: 0; z-index: 30; | |
| 853 | border-bottom: 1px solid var(--border); | |
| 854 | background: color-mix(in srgb, var(--bg) 82%, transparent); | |
| 855 | backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); | |
| 856 | } | |
| 857 | .nav-inner { display: flex; align-items: center; justify-content: space-between; gap: 1.25rem; min-height: 68px; } | |
| 858 | .brand { display: inline-flex; align-items: center; gap: 0.6rem; } | |
| 859 | .brand-mark { | |
| 860 | /* Mono for the glyph: Space Grotesk draws lowercase "l" as a bare vertical | |
| 861 | stroke, which reads as a capital "I" at 30px. */ | |
| 862 | font-family: var(--mono); | |
| 863 | display: grid; place-items: center; width: 30px; height: 30px; | |
| 864 | border: 1px solid var(--accent-line); border-radius: var(--r-sm); | |
| 865 | color: var(--accent); font-weight: 700; font-size: 1rem; line-height: 1; | |
| 866 | } | |
| 867 | .brand-name { font-weight: 700; font-size: 1.12rem; letter-spacing: -0.02em; } | |
| 868 | .nav-links { display: flex; align-items: center; gap: 1.6rem; margin-left: auto; color: var(--text-2); font-size: 0.9rem; } | |
| 869 | .nav-links a { transition: color 0.16s ease; } | |
| 870 | .nav-links a:hover { color: var(--text); } | |
| 871 | .nav-cta { margin-left: 1.6rem; } | |
| 872 | ||
| 873 | .nav-menu { display: none; margin-left: auto; position: relative; } | |
| 874 | .nav-menu > summary { | |
| 875 | list-style: none; display: grid; place-items: center; width: 40px; height: 40px; | |
| 876 | border: 1px solid var(--border-strong); border-radius: var(--r-sm); cursor: pointer; | |
| 877 | } | |
| 878 | .nav-menu > summary::-webkit-details-marker { display: none; } | |
| 879 | .nav-menu > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } | |
| 880 | .burger, .burger::before, .burger::after { | |
| 881 | display: block; width: 18px; height: 1.5px; background: var(--text); | |
| 882 | transition: transform 0.2s ease, opacity 0.2s ease; | |
| 883 | } | |
| 884 | .burger { position: relative; } | |
| 885 | .burger::before { content: ""; position: absolute; top: -5px; } | |
| 886 | .burger::after { content: ""; position: absolute; top: 5px; } | |
| 887 | .nav-menu[open] .burger { background: transparent; } | |
| 888 | .nav-menu[open] .burger::before { transform: translateY(5px) rotate(45deg); } | |
| 889 | .nav-menu[open] .burger::after { transform: translateY(-5px) rotate(-45deg); } | |
| 890 | .nav-menu-panel { | |
| 891 | position: absolute; right: 0; top: calc(100% + 12px); z-index: 50; | |
| 892 | display: flex; flex-direction: column; gap: 0.25rem; min-width: 232px; padding: 0.6rem; | |
| 893 | max-height: calc(100dvh - 92px); overflow-y: auto; overscroll-behavior: contain; | |
| 894 | background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--r); | |
| 895 | box-shadow: 0 24px 60px rgba(0, 0, 0, 0.22); | |
| 896 | } | |
| 897 | .nav-menu-panel a { padding: 0.6rem 0.7rem; border-radius: var(--r-sm); color: var(--text-2); font-size: 0.95rem; } | |
| 898 | .nav-menu-panel a:hover { color: var(--text); background: var(--surface-2); } | |
| 899 | .nav-menu-panel .btn { margin-top: 0.35rem; justify-content: center; } | |
| 900 | ||
| 901 | /* buttons */ | |
| 902 | .btn { | |
| 903 | display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; | |
| 904 | min-height: 46px; padding: 0 1.15rem; border: 1px solid var(--border-strong); border-radius: var(--r-sm); | |
| 905 | color: var(--text); font-family: var(--sans); font-weight: 600; font-size: 0.92rem; | |
| 906 | white-space: nowrap; background: transparent; cursor: pointer; | |
| 907 | transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease; | |
| 908 | } | |
| 909 | .btn.sm { min-height: 38px; padding: 0 0.85rem; font-size: 0.86rem; } | |
| 910 | .btn.primary { border-color: var(--accent); color: var(--accent-ink); background: var(--accent); } | |
| 911 | .btn.primary:hover { background: color-mix(in srgb, var(--accent) 88%, #000); } | |
| 912 | .btn.ghost:hover { border-color: var(--accent-line); } | |
| 913 | .btn:active { transform: translateY(1px); } | |
| 914 | ||
| 915 | /* section scaffolding */ | |
| 916 | .section { padding: 3.5rem 0; } | |
| 917 | .section-head { max-width: 62ch; margin-bottom: 1.75rem; } | |
| 918 | .section-title { margin: 0; font-weight: 700; font-size: clamp(1.6rem, 3.5vw, 2.2rem); letter-spacing: -0.03em; line-height: 1.1; } | |
| 919 | .section-intro { margin: 0.9rem 0 0; color: var(--text-2); font-size: 1.02rem; line-height: 1.6; } | |
| 920 | .eyebrow { font-family: var(--mono); font-size: 0.72rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--accent); } | |
| 921 | .textlink { color: var(--accent); font-weight: 500; } | |
| 922 | .textlink:hover { text-decoration: underline; } | |
| 923 | ||
| 924 | /* hero */ | |
| 925 | .hero { display: grid; grid-template-columns: 1.05fr 1fr; gap: 3.5rem; align-items: center; padding: 3.5rem 0 4rem; } | |
| 926 | .hero-title { margin: 0; font-weight: 700; font-size: clamp(2.3rem, 5.4vw, 3.4rem); letter-spacing: -0.035em; line-height: 1.04; } | |
| 927 | .hero-sub { margin: 1.25rem 0 0; color: var(--text-2); font-size: 1.08rem; line-height: 1.6; max-width: 54ch; } | |
| 928 | .hero-actions { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 2rem; } | |
| 929 | ||
| 930 | /* single-column feature/use-case hero */ | |
| 931 | .feat-hero { padding: 2.5rem 0 1.5rem; max-width: 62ch; } | |
| 932 | .feat-hero .kicker { display: inline-flex; align-items: center; gap: 0.5rem; font-family: var(--mono); font-size: 0.72rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--accent); } | |
| 933 | .feat-hero h1 { margin: 1rem 0 0; font-weight: 700; font-size: clamp(2.1rem, 5vw, 3.1rem); line-height: 1.05; letter-spacing: -0.03em; } | |
| 934 | .feat-hero p { margin: 1.1rem 0 0; color: var(--text-2); font-size: 1.05rem; line-height: 1.6; } | |
| 935 | .feat-hero .hero-actions { margin-top: 1.75rem; } | |
| 936 | ||
| 937 | .subnav { display: flex; align-items: center; gap: 0.5rem; padding-top: 1.75rem; color: var(--text-3); font-size: 0.88rem; } | |
| 938 | .subnav a { color: var(--text-2); transition: color 0.16s ease; } | |
| 939 | .subnav a:hover { color: var(--text); } | |
| 940 | .subnav .sep { color: var(--text-3); } | |
| 941 | /* The trail is an <ol> so its order is exposed to assistive tech. The list and | |
| 942 | its items carry the flex layout the nav used to hold directly, so the visual | |
| 943 | result is unchanged. \`display: contents\` would be shorter and is avoided | |
| 944 | deliberately — several engines drop list items from the accessibility tree | |
| 945 | when it is applied, which would undo the reason for using a list. */ | |
| 946 | .subnav ol { display: flex; align-items: center; gap: 0.5rem; margin: 0; padding: 0; list-style: none; } | |
| 947 | .subnav li { display: flex; align-items: center; gap: 0.5rem; } | |
| 948 | ||
| 949 | /* code panel */ | |
| 950 | .code { background: var(--code-bg); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; } | |
| 951 | .code-bar { display: flex; align-items: center; gap: 0.6rem; padding: 0.75rem 1rem; border-bottom: 1px solid rgba(148,163,184,0.14); } | |
| 952 | .code-body { margin: 0; padding: 1.1rem 1.25rem; overflow-x: auto; font-family: var(--mono); font-size: 12.5px; line-height: 1.65; color: var(--code-ink); } | |
| 953 | .chart-cap { font-family: var(--mono); font-size: 0.72rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-3); } | |
| 954 | /* Only on the code surface. \`.chart-cap\` is also used inside \`.panel-head\`, | |
| 955 | which sits on the normal page surface where --text-3 is the correct token. */ | |
| 956 | .code-bar .chart-cap { color: var(--code-cap); } | |
| 957 | ||
| 958 | /* panel (data viz container) */ | |
| 959 | .panel { border: 1px solid var(--border); border-radius: var(--r); background: var(--surface); padding: 1.4rem 1.5rem; } | |
| 960 | .panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; } | |
| 961 | .panel-tag { font-family: var(--mono); font-size: 0.7rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-3); } | |
| 962 | .live { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-2); } | |
| 963 | .live-dot { width: 8px; height: 8px; border-radius: 999px; background: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); } | |
| 964 | .stat-big { margin: 0; font-weight: 700; font-size: 2.6rem; letter-spacing: -0.03em; } | |
| 965 | .stat-label { margin: 0.15rem 0 0; color: var(--text-3); font-size: 0.9rem; } | |
| 966 | ||
| 967 | /* trackless bars */ | |
| 968 | .bar-row { display: grid; grid-template-columns: 148px 1fr 52px; align-items: center; gap: 0.9rem; padding: 0.5rem 0; } | |
| 969 | .bar-row + .bar-row { border-top: 1px solid var(--border); } | |
| 970 | .bar-label { color: var(--text-2); font-size: 0.88rem; } | |
| 971 | .bar-track { height: 8px; border-radius: 999px; background: transparent; } | |
| 972 | .bar-fill { display: block; height: 100%; border-radius: 999px; background: var(--accent); opacity: 0.82; min-width: 4px; } | |
| 973 | .bar-value { font-family: var(--mono); font-size: 0.82rem; color: var(--text); text-align: right; } | |
| 974 | ||
| 975 | /* stat/severity tiles */ | |
| 976 | .vitals { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; } | |
| 977 | .vital { padding: 1.4rem 1.5rem; background: var(--surface); } | |
| 978 | .vital-k { font-family: var(--mono); font-size: 0.78rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-3); } | |
| 979 | .vital-v { margin-top: 0.5rem; font-weight: 700; font-size: 1.9rem; letter-spacing: -0.02em; color: var(--text); } | |
| 980 | .vital-tag { margin-top: 0.4rem; display: inline-flex; font-size: 0.78rem; color: var(--accent); } | |
| 981 | ||
| 982 | /* numbered flow */ | |
| 983 | .flow { display: grid; gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; } | |
| 984 | .flow-step { display: grid; grid-template-columns: auto 1fr; gap: 1.1rem; padding: 1.4rem 1.5rem; background: var(--surface); } | |
| 985 | .flow-num { font-family: var(--mono); font-size: 0.82rem; color: var(--accent); padding-top: 0.15rem; } | |
| 986 | .flow-step h3 { margin: 0; font-weight: 600; font-size: 1.05rem; letter-spacing: -0.01em; } | |
| 987 | .flow-step p { margin: 0.4rem 0 0; color: var(--text-2); font-size: 0.95rem; line-height: 1.55; } | |
| 988 | ||
| 989 | /* metric tiles */ | |
| 990 | .metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem 2.5rem; } | |
| 991 | .metric .m-value { font-weight: 700; font-size: clamp(1.8rem, 4vw, 2.4rem); letter-spacing: -0.02em; color: var(--text); } | |
| 992 | .metric .m-label { margin-top: 0.35rem; color: var(--text-2); font-size: 0.9rem; line-height: 1.5; } | |
| 993 | ||
| 994 | /* chips grid */ | |
| 995 | .chips { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1.5rem; } | |
| 996 | .chip { border: 1px solid var(--border); border-radius: var(--r); padding: 1.4rem 1.5rem; background: var(--surface); } | |
| 997 | .chip h3 { margin: 0; font-weight: 600; font-size: 1.05rem; letter-spacing: -0.01em; } | |
| 998 | .chip p { margin: 0.5rem 0 0; color: var(--text-2); font-size: 0.94rem; line-height: 1.6; } | |
| 999 | ||
| 1000 | /* feature bento (landing) */ | |
| 1001 | .bento { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; } | |
| 1002 | .cell { display: block; padding: 1.5rem 1.6rem; background: var(--surface); transition: background 0.16s ease; } | |
| 1003 | a.cell { cursor: pointer; } | |
| 1004 | a.cell:hover { background: var(--surface-2); } | |
| 1005 | .cell h3 { margin: 0; font-weight: 600; font-size: 1.08rem; letter-spacing: -0.015em; } | |
| 1006 | .cell p { margin: 0.5rem 0 0; color: var(--text-2); font-size: 0.93rem; line-height: 1.55; } | |
| 1007 | .cell.lead { grid-column: span 1; } | |
| 1008 | .cell.tint { background: var(--accent-soft); } | |
| 1009 | a.cell.tint:hover { background: color-mix(in srgb, var(--accent) 14%, var(--surface)); } | |
| 1010 | /* Featured bento cell: a soft rose wash (bughq accent) tinted into a corner, so | |
| 1011 | the grid has visual variation rather than six identical text cards. */ | |
| 1012 | .cell.feature { background: radial-gradient(120% 140% at 100% 0%, var(--accent-soft), transparent 58%), var(--surface); } | |
| 1013 | a.cell.feature:hover { background: radial-gradient(120% 140% at 100% 0%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 60%), var(--surface-2); } | |
| 1014 | .cell.feature h3 { color: var(--accent); } | |
| 1015 | .cell-more { display: inline-flex; align-items: center; gap: 0.35rem; margin-top: 1rem; font-family: var(--mono); font-size: 0.74rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-3); transition: color 0.16s ease, gap 0.16s ease; } | |
| 1016 | .cell-more::after { content: "\\2192"; } | |
| 1017 | a.cell:hover .cell-more { color: var(--accent); gap: 0.55rem; } | |
| 1018 | ||
| 1019 | /* comparison table */ | |
| 1020 | .cmp { display: grid; grid-template-columns: 1.1fr 1fr 1fr; border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; } | |
| 1021 | .cmp-cell { padding: 1rem 1.2rem; border-top: 1px solid var(--border); background: var(--surface); font-size: 0.92rem; } | |
| 1022 | .cmp-cell:nth-child(-n+3) { border-top: none; font-weight: 600; } | |
| 1023 | .cmp-dim { color: var(--text-3); } | |
| 1024 | .cmp-ghost { color: var(--text); } | |
| 1025 | ||
| 1026 | /* related features */ | |
| 1027 | .related { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; } | |
| 1028 | .related a { display: block; padding: 1.3rem 1.4rem; background: var(--surface); transition: background 0.16s ease; } | |
| 1029 | .related a:hover { background: var(--surface-2); } | |
| 1030 | .related .r-name { font-weight: 600; font-size: 1rem; color: var(--text); letter-spacing: -0.01em; } | |
| 1031 | .related .r-desc { margin-top: 0.3rem; color: var(--text-3); font-size: 0.86rem; line-height: 1.45; } | |
| 1032 | ||
| 1033 | /* use-case cards */ | |
| 1034 | .usecases { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1.5rem; } | |
| 1035 | .usecase { border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; background: var(--surface); } | |
| 1036 | .usecase img { display: block; width: 100%; height: 168px; object-fit: cover; } | |
| 1037 | .usecase-body { padding: 1.4rem 1.5rem 1.6rem; } | |
| 1038 | .usecase-body h2 { margin: 0; font-weight: 700; font-size: 1.15rem; letter-spacing: -0.02em; } | |
| 1039 | .usecase-body p { margin: 0.55rem 0 0; color: var(--text-2); font-size: 0.94rem; line-height: 1.6; } | |
| 1040 | .usecase-body .textlink { margin-top: 0.9rem; display: inline-block; } | |
| 1041 | ||
| 1042 | /* cta band */ | |
| 1043 | .cta-band { border: 1px solid var(--border); border-radius: var(--r); padding: 2.5rem; background: var(--surface); text-align: center; } | |
| 1044 | .cta-band h2 { margin: 0 auto; max-width: 30ch; font-weight: 700; font-size: clamp(1.5rem, 3.5vw, 2rem); letter-spacing: -0.03em; } | |
| 1045 | .cta-actions { display: flex; flex-wrap: wrap; gap: 0.75rem; justify-content: center; margin-top: 1.5rem; } | |
| 1046 | ||
| 1047 | /* subscribe form | |
| 1048 | Sized to the band's own rhythm: the input borrows the .btn min-height so the | |
| 1049 | field and its button line up without a magic number, and the row collapses to | |
| 1050 | a stack under 30rem rather than squeezing a 40-character field. */ | |
| 1051 | .sub-form { display: flex; flex-wrap: wrap; gap: 0.6rem; justify-content: center; margin: 1.5rem auto 0; max-width: 27rem; } | |
| 1052 | .sub-field { flex: 1 1 15rem; min-width: 0; min-height: 44px; padding: 0 0.9rem; border: 1px solid var(--border-strong, var(--border)); border-radius: calc(var(--r) - 4px); background: var(--bg); color: var(--text); font: inherit; font-size: 0.95rem; transition: border-color 0.16s ease, box-shadow 0.16s ease; } | |
| 1053 | .sub-field::placeholder { color: var(--text-3); } | |
| 1054 | .sub-field:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); } | |
| 1055 | /* Defined here rather than assumed from the utility layer: this stylesheet is | |
| 1056 | the page's own contract, and a visually-hidden helper that silently is not | |
| 1057 | emitted shows up as a stray label sitting above the field. */ | |
| 1058 | .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } | |
| 1059 | .sub-note { margin: 0.85rem auto 0; max-width: 40ch; color: var(--text-3); font-size: 0.84rem; line-height: 1.5; } | |
| 1060 | .sub-note a { color: var(--text-2); text-decoration: underline; text-underline-offset: 2px; } | |
| 1061 | /* One line, reserved by min-height so a reply does not shift the section. */ | |
| 1062 | .sub-status { margin: 0.85rem auto 0; min-height: 1.25rem; color: var(--text-2); font-size: 0.88rem; } | |
| 1063 | .sub-status[data-state="error"] { color: var(--accent); } | |
| 1064 | @media (max-width: 30rem) { | |
| 1065 | .sub-form { flex-direction: column; } | |
| 1066 | /* A row's 15rem width basis becomes height when the form stacks. */ | |
| 1067 | .sub-field { flex: none; } | |
| 1068 | .sub-form .btn { width: 100%; justify-content: center; } | |
| 1069 | } | |
| 1070 | ||
| 1071 | /* footer */ | |
| 1072 | .footer { padding: 2.5rem 0; } | |
| 1073 | .footer-bottom { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding-top: 1.5rem; border-top: 1px solid var(--border); color: var(--text-3); font-size: 0.88rem; } | |
| 1074 | ||
| 1075 | /* motion — \`.reveal\` is NOT declared here. | |
| 1076 | * | |
| 1077 | * It used to be, and app-chrome.css declared it too: two \`.reveal\` rules and | |
| 1078 | * two identically-named \`@keyframes reveal\` in two sheets that are both loaded | |
| 1079 | * on every marketing page. This file wins on order, so marketing pages animated | |
| 1080 | * over 0.6s/12px and the dashboard — the one app page that uses .reveal, and | |
| 1081 | * which never loads this file — animated over 0.5s/10px. Nothing intended that | |
| 1082 | * split; it was drift between two copies. | |
| 1083 | * | |
| 1084 | * The single definition lives in app-chrome.css, which is the site-wide sheet, | |
| 1085 | * and it carries these values because 23 marketing pages used them against the | |
| 1086 | * dashboard's one. A same-named @keyframes in a later sheet REPLACES rather | |
| 1087 | * than merges, so the duplicate was also a live footgun: editing the copy here | |
| 1088 | * would have silently changed marketing pages only. | |
| 1089 | */ | |
| 1090 | ||
| 1091 | /* responsive */ | |
| 1092 | @media (max-width: 900px) { | |
| 1093 | body:has(.nav-menu[open]) { overflow: hidden; } | |
| 1094 | .hero { grid-template-columns: 1fr; gap: 2rem; padding-top: 2.5rem; } | |
| 1095 | .bento, .related, .cmp { grid-template-columns: 1fr; } | |
| 1096 | .metrics { grid-template-columns: 1fr 1fr; } | |
| 1097 | .chips, .usecases { grid-template-columns: 1fr; } | |
| 1098 | .nav-links { display: none; } | |
| 1099 | .nav-cta { display: none; } | |
| 1100 | .nav-menu { display: block; } | |
| 1101 | } | |
| 1102 | @media (max-width: 560px) { | |
| 1103 | .metrics, .vitals { grid-template-columns: 1fr; } | |
| 1104 | .flow-step { grid-template-columns: 1fr; gap: 0.4rem; } | |
| 1105 | .bar-row { grid-template-columns: 120px 1fr 44px; } | |
| 1106 | } | |
| 1107 | ||
| 1108 | /* login link beside the Sign up nav button */ | |
| 1109 | .nav-login { color: var(--text-2); font-size: 0.9rem; transition: color 0.16s ease; } | |
| 1110 | .nav-login:hover { color: var(--text); } | |
| 1111 | .nav-cta { margin-left: 0.95rem; } | |
| 1112 | ||
| 1113 | /* CSS-only mega menu, from SiteNav.stx's <style> block. It lives here rather | |
| 1114 | * than in the partial because none of what follows is expressible as a | |
| 1115 | * utility: two pseudo-elements (the caret and the invisible hover bridge that | |
| 1116 | * lets the pointer cross the 14px gap), ancestor-state descendants | |
| 1117 | * (\`.mega:hover .mega-panel\`), a \`:not(:first-child)\`, and a max-width media | |
| 1118 | * query — Crosswind emits nothing for any of them. The leaf styling that IS | |
| 1119 | * expressible (.mega-t, .mega-d, the panel links, the menu group's own box) | |
| 1120 | * became utilities in the markup and is deliberately absent here. | |
| 1121 | * | |
| 1122 | * A linked stylesheet is also the router-safe home: the SPA router destroys | |
| 1123 | * every <head> style that is not the Crosswind tag on navigation. | |
| 1124 | */ | |
| 1125 | .mega { position: relative; display: inline-flex; align-items: center; } | |
| 1126 | .mega-trigger { display: inline-flex; align-items: center; gap: 0.32rem; cursor: pointer; } | |
| 1127 | .mega-trigger::after { | |
| 1128 | content: "\\25BE"; font-size: 0.62em; line-height: 1; color: var(--text-3); | |
| 1129 | transition: transform 0.18s ease, color 0.18s ease; | |
| 1130 | } | |
| 1131 | .mega:hover .mega-trigger, .mega:focus-within .mega-trigger { color: var(--text); } | |
| 1132 | .mega:hover .mega-trigger::after, .mega:focus-within .mega-trigger::after { transform: rotate(180deg); color: var(--accent); } | |
| 1133 | ||
| 1134 | .mega-panel { | |
| 1135 | position: absolute; top: 100%; left: 50%; z-index: 50; | |
| 1136 | margin-top: 14px; | |
| 1137 | display: grid; grid-template-columns: repeat(2, minmax(216px, 1fr)); gap: 0.12rem; | |
| 1138 | min-width: 484px; padding: 0.55rem; | |
| 1139 | background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--r); | |
| 1140 | box-shadow: 0 24px 60px rgba(0, 0, 0, 0.22); | |
| 1141 | opacity: 0; visibility: hidden; pointer-events: none; | |
| 1142 | transform: translateX(-50%) translateY(6px); | |
| 1143 | transition: opacity 0.18s ease, transform 0.18s ease, visibility 0.18s ease; | |
| 1144 | } | |
| 1145 | .mega-panel::before { content: ""; position: absolute; top: -14px; left: 0; right: 0; height: 14px; } | |
| 1146 | .mega:hover .mega-panel, .mega:focus-within .mega-panel { | |
| 1147 | opacity: 1; visibility: visible; pointer-events: auto; | |
| 1148 | transform: translateX(-50%) translateY(0); | |
| 1149 | } | |
| 1150 | .nav-menu-group:not(:first-child) { margin-top: 0.35rem; border-top: 1px solid var(--border); } | |
| 1151 | ||
| 1152 | @media (prefers-reduced-motion: reduce) { | |
| 1153 | .mega-trigger::after, .mega-panel { transition: none; } | |
| 1154 | } | |
| 1155 | @media (max-width: 900px) { | |
| 1156 | /* desktop mega hidden with .nav-links; the <details> menu takes over */ | |
| 1157 | .mega-panel { display: none; } | |
| 1158 | } | |
| 1159 | } | |
| 1160 | ` | |
| 1161 | ||
| 749 | 1162 | /* |
| 750 | 1163 | * The date-range picker's stylesheet, lifted out of a <style> block in |
| 751 | 1164 | * resources/views/analytics.stx (stx-standards rule 3). |
| @@ -930,6 +1343,9 @@ export default { | ||
| 930 | 1343 | // tokens, then the app chrome that reads them, then page CSS. |
| 931 | 1344 | { getCSS: () => TOKENS }, |
| 932 | 1345 | { getCSS: () => APP_CHROME }, |
| 1346 | // Scoped to the marketing layout group; everything above is site-wide. | |
| 1347 | // After TOKENS so its own --sans wins where the two differ. | |
| 1348 | { getCSS: () => MARKETING }, | |
| 933 | 1349 | { getCSS: () => DATE_RANGE_PICKER_CSS }, |
| 934 | 1350 | ], |
| 935 | 1351 | theme: { |
| @@ -1,378 +0,0 @@ | ||
| 1 | /* loghq marketing surface — shared design tokens + components. | |
| 2 | Every marketing page (landing, feature pages, use-cases) links this so the | |
| 3 | whole surface stays visually identical. Light + dark, Space Grotesk, emerald accent. */ | |
| 4 | ||
| 5 | :root { | |
| 6 | --bg: #fbfbfd; | |
| 7 | --surface: #ffffff; | |
| 8 | --surface-2: #f4f5f8; | |
| 9 | --border: rgba(15, 23, 42, 0.09); | |
| 10 | --border-strong: rgba(15, 23, 42, 0.16); | |
| 11 | --text: #0b0f19; | |
| 12 | --text-2: #4b5565; | |
| 13 | --text-3: #6b7280; | |
| 14 | --accent: #047857; | |
| 15 | --accent-line: rgba(4, 120, 87, 0.34); | |
| 16 | --code-bg: #0b0f19; | |
| 17 | --code-ink: #d6deeb; | |
| 18 | /* Caption ink for the code surface. Not --text-3: the code block is dark | |
| 19 | * in BOTH themes, so the light --text-3 (#6b7280) lands on it at 3.96:1 — | |
| 20 | * the one WCAG AA failure left after the token pass. This clears 6.37:1 on | |
| 21 | * the light --code-bg and 6.70:1 on the dark one, so it needs no variant. */ | |
| 22 | --code-cap: #8b95ab; | |
| 23 | --r: 14px; | |
| 24 | --r-sm: 9px; | |
| 25 | --sans: "Space Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif; | |
| 26 | --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; | |
| 27 | } | |
| 28 | ||
| 29 | @media (prefers-color-scheme: dark) { | |
| 30 | :root { | |
| 31 | --bg: #090b11; --surface: #10131c; --surface-2: #161a24; | |
| 32 | --border: rgba(148, 163, 184, 0.12); --border-strong: rgba(148, 163, 184, 0.2); | |
| 33 | --text: #f3f5f9; --text-2: #b3bccb; --text-3: #79839a; | |
| 34 | --accent: #34d399; | |
| 35 | --accent-line: rgba(52, 211, 153, 0.32); | |
| 36 | --code-bg: #05070c; | |
| 37 | } | |
| 38 | } | |
| 39 | :root[data-theme="dark"] { | |
| 40 | --bg: #090b11; --surface: #10131c; --surface-2: #161a24; | |
| 41 | --border: rgba(148, 163, 184, 0.12); --border-strong: rgba(148, 163, 184, 0.2); | |
| 42 | --text: #f3f5f9; --text-2: #b3bccb; --text-3: #79839a; | |
| 43 | --accent: #34d399; | |
| 44 | --accent-line: rgba(52, 211, 153, 0.32); | |
| 45 | --code-bg: #05070c; | |
| 46 | } | |
| 47 | :root[data-theme="light"] { | |
| 48 | --bg: #fbfbfd; --surface: #ffffff; --surface-2: #f4f5f8; | |
| 49 | --border: rgba(15, 23, 42, 0.09); --border-strong: rgba(15, 23, 42, 0.16); | |
| 50 | --text: #0b0f19; --text-2: #4b5565; --text-3: #6b7280; | |
| 51 | --accent: #047857; | |
| 52 | --accent-line: rgba(4, 120, 87, 0.34); | |
| 53 | --code-bg: #0b0f19; | |
| 54 | } | |
| 55 | ||
| 56 | * { box-sizing: border-box; } | |
| 57 | html { background: var(--bg); scroll-behavior: smooth; -webkit-text-size-adjust: 100%; } | |
| 58 | body { | |
| 59 | margin: 0; min-height: 100vh; overflow-x: hidden; | |
| 60 | color: var(--text); background: var(--bg); | |
| 61 | font-family: var(--sans); font-size: 16px; line-height: 1.5; | |
| 62 | -webkit-font-smoothing: antialiased; | |
| 63 | } | |
| 64 | a { color: inherit; text-decoration: none; } | |
| 65 | ||
| 66 | .shell { width: min(1180px, calc(100% - 48px)); margin: 0 auto; } | |
| 67 | .shell-narrow { width: min(920px, calc(100% - 48px)); margin: 0 auto; } | |
| 68 | .mono { font-family: var(--mono); } | |
| 69 | ||
| 70 | /* nav */ | |
| 71 | .nav { | |
| 72 | position: sticky; top: 0; z-index: 30; | |
| 73 | border-bottom: 1px solid var(--border); | |
| 74 | background: color-mix(in srgb, var(--bg) 82%, transparent); | |
| 75 | backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); | |
| 76 | } | |
| 77 | .nav-inner { display: flex; align-items: center; justify-content: space-between; gap: 1.25rem; min-height: 68px; } | |
| 78 | .brand { display: inline-flex; align-items: center; gap: 0.6rem; } | |
| 79 | .brand-mark { | |
| 80 | /* Mono for the glyph: Space Grotesk draws lowercase "l" as a bare vertical | |
| 81 | stroke, which reads as a capital "I" at 30px. */ | |
| 82 | font-family: var(--mono); | |
| 83 | display: grid; place-items: center; width: 30px; height: 30px; | |
| 84 | border: 1px solid var(--accent-line); border-radius: var(--r-sm); | |
| 85 | color: var(--accent); font-weight: 700; font-size: 1rem; line-height: 1; | |
| 86 | } | |
| 87 | .brand-name { font-weight: 700; font-size: 1.12rem; letter-spacing: -0.02em; } | |
| 88 | .nav-links { display: flex; align-items: center; gap: 1.6rem; margin-left: auto; color: var(--text-2); font-size: 0.9rem; } | |
| 89 | .nav-links a { transition: color 0.16s ease; } | |
| 90 | .nav-links a:hover { color: var(--text); } | |
| 91 | .nav-cta { margin-left: 1.6rem; } | |
| 92 | ||
| 93 | .nav-menu { display: none; margin-left: auto; position: relative; } | |
| 94 | .nav-menu > summary { | |
| 95 | list-style: none; display: grid; place-items: center; width: 40px; height: 40px; | |
| 96 | border: 1px solid var(--border-strong); border-radius: var(--r-sm); cursor: pointer; | |
| 97 | } | |
| 98 | .nav-menu > summary::-webkit-details-marker { display: none; } | |
| 99 | .nav-menu > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } | |
| 100 | .burger, .burger::before, .burger::after { | |
| 101 | display: block; width: 18px; height: 1.5px; background: var(--text); | |
| 102 | transition: transform 0.2s ease, opacity 0.2s ease; | |
| 103 | } | |
| 104 | .burger { position: relative; } | |
| 105 | .burger::before { content: ""; position: absolute; top: -5px; } | |
| 106 | .burger::after { content: ""; position: absolute; top: 5px; } | |
| 107 | .nav-menu[open] .burger { background: transparent; } | |
| 108 | .nav-menu[open] .burger::before { transform: translateY(5px) rotate(45deg); } | |
| 109 | .nav-menu[open] .burger::after { transform: translateY(-5px) rotate(-45deg); } | |
| 110 | .nav-menu-panel { | |
| 111 | position: absolute; right: 0; top: calc(100% + 12px); z-index: 50; | |
| 112 | display: flex; flex-direction: column; gap: 0.25rem; min-width: 232px; padding: 0.6rem; | |
| 113 | max-height: calc(100dvh - 92px); overflow-y: auto; overscroll-behavior: contain; | |
| 114 | background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--r); | |
| 115 | box-shadow: 0 24px 60px rgba(0, 0, 0, 0.22); | |
| 116 | } | |
| 117 | .nav-menu-panel a { padding: 0.6rem 0.7rem; border-radius: var(--r-sm); color: var(--text-2); font-size: 0.95rem; } | |
| 118 | .nav-menu-panel a:hover { color: var(--text); background: var(--surface-2); } | |
| 119 | .nav-menu-panel .btn { margin-top: 0.35rem; justify-content: center; } | |
| 120 | ||
| 121 | /* buttons */ | |
| 122 | .btn { | |
| 123 | display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; | |
| 124 | min-height: 46px; padding: 0 1.15rem; border: 1px solid var(--border-strong); border-radius: var(--r-sm); | |
| 125 | color: var(--text); font-family: var(--sans); font-weight: 600; font-size: 0.92rem; | |
| 126 | white-space: nowrap; background: transparent; cursor: pointer; | |
| 127 | transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease; | |
| 128 | } | |
| 129 | .btn.sm { min-height: 38px; padding: 0 0.85rem; font-size: 0.86rem; } | |
| 130 | .btn.primary { border-color: var(--accent); color: var(--accent-ink); background: var(--accent); } | |
| 131 | .btn.primary:hover { background: color-mix(in srgb, var(--accent) 88%, #000); } | |
| 132 | .btn.ghost:hover { border-color: var(--accent-line); } | |
| 133 | .btn:active { transform: translateY(1px); } | |
| 134 | ||
| 135 | /* section scaffolding */ | |
| 136 | .section { padding: 3.5rem 0; } | |
| 137 | .section-head { max-width: 62ch; margin-bottom: 1.75rem; } | |
| 138 | .section-title { margin: 0; font-weight: 700; font-size: clamp(1.6rem, 3.5vw, 2.2rem); letter-spacing: -0.03em; line-height: 1.1; } | |
| 139 | .section-intro { margin: 0.9rem 0 0; color: var(--text-2); font-size: 1.02rem; line-height: 1.6; } | |
| 140 | .eyebrow { font-family: var(--mono); font-size: 0.72rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--accent); } | |
| 141 | .textlink { color: var(--accent); font-weight: 500; } | |
| 142 | .textlink:hover { text-decoration: underline; } | |
| 143 | ||
| 144 | /* hero */ | |
| 145 | .hero { display: grid; grid-template-columns: 1.05fr 1fr; gap: 3.5rem; align-items: center; padding: 3.5rem 0 4rem; } | |
| 146 | .hero-title { margin: 0; font-weight: 700; font-size: clamp(2.3rem, 5.4vw, 3.4rem); letter-spacing: -0.035em; line-height: 1.04; } | |
| 147 | .hero-sub { margin: 1.25rem 0 0; color: var(--text-2); font-size: 1.08rem; line-height: 1.6; max-width: 54ch; } | |
| 148 | .hero-actions { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 2rem; } | |
| 149 | ||
| 150 | /* single-column feature/use-case hero */ | |
| 151 | .feat-hero { padding: 2.5rem 0 1.5rem; max-width: 62ch; } | |
| 152 | .feat-hero .kicker { display: inline-flex; align-items: center; gap: 0.5rem; font-family: var(--mono); font-size: 0.72rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--accent); } | |
| 153 | .feat-hero h1 { margin: 1rem 0 0; font-weight: 700; font-size: clamp(2.1rem, 5vw, 3.1rem); line-height: 1.05; letter-spacing: -0.03em; } | |
| 154 | .feat-hero p { margin: 1.1rem 0 0; color: var(--text-2); font-size: 1.05rem; line-height: 1.6; } | |
| 155 | .feat-hero .hero-actions { margin-top: 1.75rem; } | |
| 156 | ||
| 157 | .subnav { display: flex; align-items: center; gap: 0.5rem; padding-top: 1.75rem; color: var(--text-3); font-size: 0.88rem; } | |
| 158 | .subnav a { color: var(--text-2); transition: color 0.16s ease; } | |
| 159 | .subnav a:hover { color: var(--text); } | |
| 160 | .subnav .sep { color: var(--text-3); } | |
| 161 | /* The trail is an <ol> so its order is exposed to assistive tech. The list and | |
| 162 | its items carry the flex layout the nav used to hold directly, so the visual | |
| 163 | result is unchanged. `display: contents` would be shorter and is avoided | |
| 164 | deliberately — several engines drop list items from the accessibility tree | |
| 165 | when it is applied, which would undo the reason for using a list. */ | |
| 166 | .subnav ol { display: flex; align-items: center; gap: 0.5rem; margin: 0; padding: 0; list-style: none; } | |
| 167 | .subnav li { display: flex; align-items: center; gap: 0.5rem; } | |
| 168 | ||
| 169 | /* code panel */ | |
| 170 | .code { background: var(--code-bg); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; } | |
| 171 | .code-bar { display: flex; align-items: center; gap: 0.6rem; padding: 0.75rem 1rem; border-bottom: 1px solid rgba(148,163,184,0.14); } | |
| 172 | .code-body { margin: 0; padding: 1.1rem 1.25rem; overflow-x: auto; font-family: var(--mono); font-size: 12.5px; line-height: 1.65; color: var(--code-ink); } | |
| 173 | .chart-cap { font-family: var(--mono); font-size: 0.72rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-3); } | |
| 174 | /* Only on the code surface. `.chart-cap` is also used inside `.panel-head`, | |
| 175 | which sits on the normal page surface where --text-3 is the correct token. */ | |
| 176 | .code-bar .chart-cap { color: var(--code-cap); } | |
| 177 | ||
| 178 | /* panel (data viz container) */ | |
| 179 | .panel { border: 1px solid var(--border); border-radius: var(--r); background: var(--surface); padding: 1.4rem 1.5rem; } | |
| 180 | .panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; } | |
| 181 | .panel-tag { font-family: var(--mono); font-size: 0.7rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-3); } | |
| 182 | .live { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-2); } | |
| 183 | .live-dot { width: 8px; height: 8px; border-radius: 999px; background: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); } | |
| 184 | .stat-big { margin: 0; font-weight: 700; font-size: 2.6rem; letter-spacing: -0.03em; } | |
| 185 | .stat-label { margin: 0.15rem 0 0; color: var(--text-3); font-size: 0.9rem; } | |
| 186 | ||
| 187 | /* trackless bars */ | |
| 188 | .bar-row { display: grid; grid-template-columns: 148px 1fr 52px; align-items: center; gap: 0.9rem; padding: 0.5rem 0; } | |
| 189 | .bar-row + .bar-row { border-top: 1px solid var(--border); } | |
| 190 | .bar-label { color: var(--text-2); font-size: 0.88rem; } | |
| 191 | .bar-track { height: 8px; border-radius: 999px; background: transparent; } | |
| 192 | .bar-fill { display: block; height: 100%; border-radius: 999px; background: var(--accent); opacity: 0.82; min-width: 4px; } | |
| 193 | .bar-value { font-family: var(--mono); font-size: 0.82rem; color: var(--text); text-align: right; } | |
| 194 | ||
| 195 | /* stat/severity tiles */ | |
| 196 | .vitals { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; } | |
| 197 | .vital { padding: 1.4rem 1.5rem; background: var(--surface); } | |
| 198 | .vital-k { font-family: var(--mono); font-size: 0.78rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-3); } | |
| 199 | .vital-v { margin-top: 0.5rem; font-weight: 700; font-size: 1.9rem; letter-spacing: -0.02em; color: var(--text); } | |
| 200 | .vital-tag { margin-top: 0.4rem; display: inline-flex; font-size: 0.78rem; color: var(--accent); } | |
| 201 | ||
| 202 | /* numbered flow */ | |
| 203 | .flow { display: grid; gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; } | |
| 204 | .flow-step { display: grid; grid-template-columns: auto 1fr; gap: 1.1rem; padding: 1.4rem 1.5rem; background: var(--surface); } | |
| 205 | .flow-num { font-family: var(--mono); font-size: 0.82rem; color: var(--accent); padding-top: 0.15rem; } | |
| 206 | .flow-step h3 { margin: 0; font-weight: 600; font-size: 1.05rem; letter-spacing: -0.01em; } | |
| 207 | .flow-step p { margin: 0.4rem 0 0; color: var(--text-2); font-size: 0.95rem; line-height: 1.55; } | |
| 208 | ||
| 209 | /* metric tiles */ | |
| 210 | .metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem 2.5rem; } | |
| 211 | .metric .m-value { font-weight: 700; font-size: clamp(1.8rem, 4vw, 2.4rem); letter-spacing: -0.02em; color: var(--text); } | |
| 212 | .metric .m-label { margin-top: 0.35rem; color: var(--text-2); font-size: 0.9rem; line-height: 1.5; } | |
| 213 | ||
| 214 | /* chips grid */ | |
| 215 | .chips { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1.5rem; } | |
| 216 | .chip { border: 1px solid var(--border); border-radius: var(--r); padding: 1.4rem 1.5rem; background: var(--surface); } | |
| 217 | .chip h3 { margin: 0; font-weight: 600; font-size: 1.05rem; letter-spacing: -0.01em; } | |
| 218 | .chip p { margin: 0.5rem 0 0; color: var(--text-2); font-size: 0.94rem; line-height: 1.6; } | |
| 219 | ||
| 220 | /* feature bento (landing) */ | |
| 221 | .bento { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; } | |
| 222 | .cell { display: block; padding: 1.5rem 1.6rem; background: var(--surface); transition: background 0.16s ease; } | |
| 223 | a.cell { cursor: pointer; } | |
| 224 | a.cell:hover { background: var(--surface-2); } | |
| 225 | .cell h3 { margin: 0; font-weight: 600; font-size: 1.08rem; letter-spacing: -0.015em; } | |
| 226 | .cell p { margin: 0.5rem 0 0; color: var(--text-2); font-size: 0.93rem; line-height: 1.55; } | |
| 227 | .cell.lead { grid-column: span 1; } | |
| 228 | .cell.tint { background: var(--accent-soft); } | |
| 229 | a.cell.tint:hover { background: color-mix(in srgb, var(--accent) 14%, var(--surface)); } | |
| 230 | /* Featured bento cell: a soft rose wash (bughq accent) tinted into a corner, so | |
| 231 | the grid has visual variation rather than six identical text cards. */ | |
| 232 | .cell.feature { background: radial-gradient(120% 140% at 100% 0%, var(--accent-soft), transparent 58%), var(--surface); } | |
| 233 | a.cell.feature:hover { background: radial-gradient(120% 140% at 100% 0%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 60%), var(--surface-2); } | |
| 234 | .cell.feature h3 { color: var(--accent); } | |
| 235 | .cell-more { display: inline-flex; align-items: center; gap: 0.35rem; margin-top: 1rem; font-family: var(--mono); font-size: 0.74rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-3); transition: color 0.16s ease, gap 0.16s ease; } | |
| 236 | .cell-more::after { content: "\2192"; } | |
| 237 | a.cell:hover .cell-more { color: var(--accent); gap: 0.55rem; } | |
| 238 | ||
| 239 | /* comparison table */ | |
| 240 | .cmp { display: grid; grid-template-columns: 1.1fr 1fr 1fr; border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; } | |
| 241 | .cmp-cell { padding: 1rem 1.2rem; border-top: 1px solid var(--border); background: var(--surface); font-size: 0.92rem; } | |
| 242 | .cmp-cell:nth-child(-n+3) { border-top: none; font-weight: 600; } | |
| 243 | .cmp-dim { color: var(--text-3); } | |
| 244 | .cmp-ghost { color: var(--text); } | |
| 245 | ||
| 246 | /* related features */ | |
| 247 | .related { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; } | |
| 248 | .related a { display: block; padding: 1.3rem 1.4rem; background: var(--surface); transition: background 0.16s ease; } | |
| 249 | .related a:hover { background: var(--surface-2); } | |
| 250 | .related .r-name { font-weight: 600; font-size: 1rem; color: var(--text); letter-spacing: -0.01em; } | |
| 251 | .related .r-desc { margin-top: 0.3rem; color: var(--text-3); font-size: 0.86rem; line-height: 1.45; } | |
| 252 | ||
| 253 | /* use-case cards */ | |
| 254 | .usecases { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1.5rem; } | |
| 255 | .usecase { border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; background: var(--surface); } | |
| 256 | .usecase img { display: block; width: 100%; height: 168px; object-fit: cover; } | |
| 257 | .usecase-body { padding: 1.4rem 1.5rem 1.6rem; } | |
| 258 | .usecase-body h2 { margin: 0; font-weight: 700; font-size: 1.15rem; letter-spacing: -0.02em; } | |
| 259 | .usecase-body p { margin: 0.55rem 0 0; color: var(--text-2); font-size: 0.94rem; line-height: 1.6; } | |
| 260 | .usecase-body .textlink { margin-top: 0.9rem; display: inline-block; } | |
| 261 | ||
| 262 | /* cta band */ | |
| 263 | .cta-band { border: 1px solid var(--border); border-radius: var(--r); padding: 2.5rem; background: var(--surface); text-align: center; } | |
| 264 | .cta-band h2 { margin: 0 auto; max-width: 30ch; font-weight: 700; font-size: clamp(1.5rem, 3.5vw, 2rem); letter-spacing: -0.03em; } | |
| 265 | .cta-actions { display: flex; flex-wrap: wrap; gap: 0.75rem; justify-content: center; margin-top: 1.5rem; } | |
| 266 | ||
| 267 | /* subscribe form | |
| 268 | Sized to the band's own rhythm: the input borrows the .btn min-height so the | |
| 269 | field and its button line up without a magic number, and the row collapses to | |
| 270 | a stack under 30rem rather than squeezing a 40-character field. */ | |
| 271 | .sub-form { display: flex; flex-wrap: wrap; gap: 0.6rem; justify-content: center; margin: 1.5rem auto 0; max-width: 27rem; } | |
| 272 | .sub-field { flex: 1 1 15rem; min-width: 0; min-height: 44px; padding: 0 0.9rem; border: 1px solid var(--border-strong, var(--border)); border-radius: calc(var(--r) - 4px); background: var(--bg); color: var(--text); font: inherit; font-size: 0.95rem; transition: border-color 0.16s ease, box-shadow 0.16s ease; } | |
| 273 | .sub-field::placeholder { color: var(--text-3); } | |
| 274 | .sub-field:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); } | |
| 275 | /* Defined here rather than assumed from the utility layer: this stylesheet is | |
| 276 | the page's own contract, and a visually-hidden helper that silently is not | |
| 277 | emitted shows up as a stray label sitting above the field. */ | |
| 278 | .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } | |
| 279 | .sub-note { margin: 0.85rem auto 0; max-width: 40ch; color: var(--text-3); font-size: 0.84rem; line-height: 1.5; } | |
| 280 | .sub-note a { color: var(--text-2); text-decoration: underline; text-underline-offset: 2px; } | |
| 281 | /* One line, reserved by min-height so a reply does not shift the section. */ | |
| 282 | .sub-status { margin: 0.85rem auto 0; min-height: 1.25rem; color: var(--text-2); font-size: 0.88rem; } | |
| 283 | .sub-status[data-state="error"] { color: var(--accent); } | |
| 284 | @media (max-width: 30rem) { | |
| 285 | .sub-form { flex-direction: column; } | |
| 286 | /* A row's 15rem width basis becomes height when the form stacks. */ | |
| 287 | .sub-field { flex: none; } | |
| 288 | .sub-form .btn { width: 100%; justify-content: center; } | |
| 289 | } | |
| 290 | ||
| 291 | /* footer */ | |
| 292 | .footer { padding: 2.5rem 0; } | |
| 293 | .footer-bottom { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding-top: 1.5rem; border-top: 1px solid var(--border); color: var(--text-3); font-size: 0.88rem; } | |
| 294 | ||
| 295 | /* motion — `.reveal` is NOT declared here. | |
| 296 | * | |
| 297 | * It used to be, and app-chrome.css declared it too: two `.reveal` rules and | |
| 298 | * two identically-named `@keyframes reveal` in two sheets that are both loaded | |
| 299 | * on every marketing page. This file wins on order, so marketing pages animated | |
| 300 | * over 0.6s/12px and the dashboard — the one app page that uses .reveal, and | |
| 301 | * which never loads this file — animated over 0.5s/10px. Nothing intended that | |
| 302 | * split; it was drift between two copies. | |
| 303 | * | |
| 304 | * The single definition lives in app-chrome.css, which is the site-wide sheet, | |
| 305 | * and it carries these values because 23 marketing pages used them against the | |
| 306 | * dashboard's one. A same-named @keyframes in a later sheet REPLACES rather | |
| 307 | * than merges, so the duplicate was also a live footgun: editing the copy here | |
| 308 | * would have silently changed marketing pages only. | |
| 309 | */ | |
| 310 | ||
| 311 | /* responsive */ | |
| 312 | @media (max-width: 900px) { | |
| 313 | body:has(.nav-menu[open]) { overflow: hidden; } | |
| 314 | .hero { grid-template-columns: 1fr; gap: 2rem; padding-top: 2.5rem; } | |
| 315 | .bento, .related, .cmp { grid-template-columns: 1fr; } | |
| 316 | .metrics { grid-template-columns: 1fr 1fr; } | |
| 317 | .chips, .usecases { grid-template-columns: 1fr; } | |
| 318 | .nav-links { display: none; } | |
| 319 | .nav-cta { display: none; } | |
| 320 | .nav-menu { display: block; } | |
| 321 | } | |
| 322 | @media (max-width: 560px) { | |
| 323 | .metrics, .vitals { grid-template-columns: 1fr; } | |
| 324 | .flow-step { grid-template-columns: 1fr; gap: 0.4rem; } | |
| 325 | .bar-row { grid-template-columns: 120px 1fr 44px; } | |
| 326 | } | |
| 327 | ||
| 328 | /* login link beside the Sign up nav button */ | |
| 329 | .nav-login { color: var(--text-2); font-size: 0.9rem; transition: color 0.16s ease; } | |
| 330 | .nav-login:hover { color: var(--text); } | |
| 331 | .nav-cta { margin-left: 0.95rem; } | |
| 332 | ||
| 333 | /* CSS-only mega menu, from SiteNav.stx's <style> block. It lives here rather | |
| 334 | * than in the partial because none of what follows is expressible as a | |
| 335 | * utility: two pseudo-elements (the caret and the invisible hover bridge that | |
| 336 | * lets the pointer cross the 14px gap), ancestor-state descendants | |
| 337 | * (`.mega:hover .mega-panel`), a `:not(:first-child)`, and a max-width media | |
| 338 | * query — Crosswind emits nothing for any of them. The leaf styling that IS | |
| 339 | * expressible (.mega-t, .mega-d, the panel links, the menu group's own box) | |
| 340 | * became utilities in the markup and is deliberately absent here. | |
| 341 | * | |
| 342 | * A linked stylesheet is also the router-safe home: the SPA router destroys | |
| 343 | * every <head> style that is not the Crosswind tag on navigation. | |
| 344 | */ | |
| 345 | .mega { position: relative; display: inline-flex; align-items: center; } | |
| 346 | .mega-trigger { display: inline-flex; align-items: center; gap: 0.32rem; cursor: pointer; } | |
| 347 | .mega-trigger::after { | |
| 348 | content: "\25BE"; font-size: 0.62em; line-height: 1; color: var(--text-3); | |
| 349 | transition: transform 0.18s ease, color 0.18s ease; | |
| 350 | } | |
| 351 | .mega:hover .mega-trigger, .mega:focus-within .mega-trigger { color: var(--text); } | |
| 352 | .mega:hover .mega-trigger::after, .mega:focus-within .mega-trigger::after { transform: rotate(180deg); color: var(--accent); } | |
| 353 | ||
| 354 | .mega-panel { | |
| 355 | position: absolute; top: 100%; left: 50%; z-index: 50; | |
| 356 | margin-top: 14px; | |
| 357 | display: grid; grid-template-columns: repeat(2, minmax(216px, 1fr)); gap: 0.12rem; | |
| 358 | min-width: 484px; padding: 0.55rem; | |
| 359 | background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--r); | |
| 360 | box-shadow: 0 24px 60px rgba(0, 0, 0, 0.22); | |
| 361 | opacity: 0; visibility: hidden; pointer-events: none; | |
| 362 | transform: translateX(-50%) translateY(6px); | |
| 363 | transition: opacity 0.18s ease, transform 0.18s ease, visibility 0.18s ease; | |
| 364 | } | |
| 365 | .mega-panel::before { content: ""; position: absolute; top: -14px; left: 0; right: 0; height: 14px; } | |
| 366 | .mega:hover .mega-panel, .mega:focus-within .mega-panel { | |
| 367 | opacity: 1; visibility: visible; pointer-events: auto; | |
| 368 | transform: translateX(-50%) translateY(0); | |
| 369 | } | |
| 370 | .nav-menu-group:not(:first-child) { margin-top: 0.35rem; border-top: 1px solid var(--border); } | |
| 371 | ||
| 372 | @media (prefers-reduced-motion: reduce) { | |
| 373 | .mega-trigger::after, .mega-panel { transition: none; } | |
| 374 | } | |
| 375 | @media (max-width: 900px) { | |
| 376 | /* desktop mega hidden with .nav-links; the <details> menu takes over */ | |
| 377 | .mega-panel { display: none; } | |
| 378 | } | |
| @@ -62,7 +62,10 @@ | ||
| 62 | 62 | rules. The shared fonts, charset, viewport and favicon DO live in app.head — |
| 63 | 63 | they are genuinely site-wide, and injectConfigHeadTags dedupes by exact href |
| 64 | 64 | so nothing doubles up. --}} |
| 65 | <link rel="stylesheet" href="/marketing.css"> | |
| 65 | {{-- /marketing.css is gone: it is the MARKETING preflight in | |
| 66 | config/crosswind.ts now, scoped to this layout's group | |
| 67 | (stx-standards rule 3, §11.2). Nothing replaces it here -- | |
| 68 | the generated sheet is injected by the renderer. --}} | |
| 66 | 69 | {{-- No @stack('styles') here, following the same rule default.stx states: add a |
| 67 | 70 | slot when a page pushes to it, not before. Nine pages used to carry an empty |
| 68 | 71 | `` pair — index and all eight compare pages — |
| @@ -28,8 +28,9 @@ const appPreflights = (theme as any).preflights as { getCSS: () => string }[] | ||
| 28 | 28 | /* |
| 29 | 29 | * Addressed by content, not by index. This was `appPreflights[0]` while the |
| 30 | 30 | * picker was the config's only entry; TOKENS and APP_CHROME joined it when |
| 31 | * public/tokens.css and public/app-chrome.css moved in, and an index would | |
| 32 | * have silently started asserting about the palette instead. | |
| 31 | * public/tokens.css, public/app-chrome.css and public/marketing.css moved in, | |
| 32 | * and an index would have silently started asserting about the palette | |
| 33 | * instead. | |
| 33 | 34 | */ |
| 34 | 35 | const picker = appPreflights.find(entry => entry.getCSS().includes('.drp-day'))! |
| 35 | 36 | const flat = sheet.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, '').replace(/;\}/g, '}') |
| @@ -47,8 +48,8 @@ describe('date range picker CSS reaches the generated sheet', () => { | ||
| 47 | 48 | // the dev-server merge dropped preflights outright (stacksjs/stx#1867). |
| 48 | 49 | // If that ever regresses, every rule in this file goes with it. |
| 49 | 50 | expect(merged.includePreflight).toBe(true) |
| 50 | // The picker, the design tokens and the app chrome. | |
| 51 | expect(appPreflights.length).toBe(3) | |
| 51 | // The picker, the design tokens, the app chrome, and the marketing sheet. | |
| 52 | expect(appPreflights.length).toBe(4) | |
| 52 | 53 | expect(picker).toBeDefined() |
| 53 | 54 | // Plus the engine's own reset, which is a preflight too. |
| 54 | 55 | expect(merged.config.preflights.length).toBeGreaterThan(appPreflights.length) |
| @@ -35,7 +35,7 @@ | ||
| 35 | 35 | * They carry !important now, per declaration rather than per rule. |
| 36 | 36 | */ |
| 37 | 37 | import { describe, expect, test } from 'bun:test' |
| 38 | import { readdirSync, readFileSync } from 'node:fs' | |
| 38 | import { existsSync, readdirSync, readFileSync } from 'node:fs' | |
| 39 | 39 | import { join } from 'node:path' |
| 40 | 40 | import theme from '../../config/crosswind' |
| 41 | 41 | // The installed stx package does not export these subpaths by bare specifier. |
| @@ -49,6 +49,7 @@ const ENTRIES = ((theme as { preflights?: { getCSS: () => string }[] }).prefligh | ||
| 49 | 49 | .map(entry => entry.getCSS()) |
| 50 | 50 | const TOKENS = ENTRIES.find(css => css.includes('--accent-soft:')) ?? '' |
| 51 | 51 | const CHROME = ENTRIES.find(css => css.includes('.dz-overlay')) ?? '' |
| 52 | const MARKETING = ENTRIES.find(css => css.includes('content="marketing"')) ?? '' | |
| 52 | 53 | |
| 53 | 54 | async function generate(): Promise<string> { |
| 54 | 55 | const engine = await loadCssEngine() |
| @@ -79,7 +80,8 @@ describe('the claim that kept them out of the config', () => { | ||
| 79 | 80 | // started asserting about the palette when these two joined it. |
| 80 | 81 | expect(TOKENS).not.toBe('') |
| 81 | 82 | expect(CHROME).not.toBe('') |
| 82 | expect(ENTRIES).toHaveLength(3) | |
| 83 | expect(MARKETING).not.toBe('') | |
| 84 | expect(ENTRIES).toHaveLength(4) | |
| 83 | 85 | }) |
| 84 | 86 | }) |
| 85 | 87 | |
| @@ -104,20 +106,64 @@ describe('one declaration site', () => { | ||
| 104 | 106 | expect({ href, onDisk: stylesheets.includes(href.slice(1)) }).toEqual({ href, onDisk: true }) |
| 105 | 107 | }) |
| 106 | 108 | |
| 107 | test('public/marketing.css is the one stylesheet left, and it is not site-wide', () => { | |
| 109 | test('public/ holds no stylesheet at all now', () => { | |
| 108 | 110 | /* |
| 109 | * Not part of this move and not the same problem. It is linked from | |
| 110 | * resources/layouts/marketing.stx rather than config/ui.ts, because app | |
| 111 | * pages define their own conflicting `.btn` and `.panel`, so it cannot | |
| 112 | * become an unscoped preflight the way these two could. Its 32 token | |
| 113 | * declarations are the remaining §11.2 debt in this app, recorded here so | |
| 114 | * the number is explicit rather than implied by the absence of a check. | |
| 111 | * marketing.css was the last one. It could not become an unscoped | |
| 112 | * preflight the way TOKENS and APP_CHROME did -- a preflight reaches every | |
| 113 | * page, and this sheet gives `.btn`, `.panel` and `.shell` meanings the | |
| 114 | * app pages contradict, which is exactly why it was linked from | |
| 115 | * resources/layouts/marketing.stx rather than config/ui.ts. It carries the | |
| 116 | * layout group instead. | |
| 115 | 117 | */ |
| 116 | const marketing = readFileSync(join(PUBLIC, 'marketing.css'), 'utf8') | |
| 117 | const declarations = [...marketing.matchAll(/^\s*(--[a-z][\w-]*)\s*:/gm)].length | |
| 118 | expect({ declarations }).toEqual({ declarations: 35 }) | |
| 119 | expect(readFileSync(join(ROOT, 'resources/layouts/marketing.stx'), 'utf8')).toContain('href="/marketing.css"') | |
| 120 | expect(readFileSync(join(ROOT, 'config/ui.ts'), 'utf8')).not.toContain('href: \'/marketing.css\'') | |
| 118 | const stylesheets = existsSync(PUBLIC) ? readdirSync(PUBLIC).filter(name => name.endsWith('.css')) : [] | |
| 119 | expect(stylesheets).toEqual([]) | |
| 120 | ||
| 121 | const layout = readFileSync(join(ROOT, 'resources/layouts/marketing.stx'), 'utf8') | |
| 122 | expect(layout).not.toContain('<link rel="stylesheet" href="/marketing.css">') | |
| 123 | }) | |
| 124 | ||
| 125 | test('the marketing sheet is scoped, and the site-wide ones are not', () => { | |
| 126 | // The whole arrangement in one assertion: TOKENS and APP_CHROME reach | |
| 127 | // every page, MARKETING reaches only its own layout group. | |
| 128 | expect(TOKENS).not.toContain('stx-layout-group') | |
| 129 | expect(CHROME).not.toContain('stx-layout-group') | |
| 130 | expect(MARKETING).toContain('[content="marketing"]') | |
| 131 | ||
| 132 | /* | |
| 133 | * Written once with nesting rather than pasted onto every selector. The | |
| 134 | * exceptions are the rules that CANNOT nest, because nesting puts the | |
| 135 | * prefix in front of a descendant and these are rooted at or above <html>: | |
| 136 | * the four token blocks, the `html` background rule, and `*`, which | |
| 137 | * matched html itself and so becomes `P, P *`. | |
| 138 | */ | |
| 139 | const prefixes = [...MARKETING.matchAll(/html:has\(meta/g)].length | |
| 140 | const braces = [...MARKETING.matchAll(/\{/g)].length | |
| 141 | expect({ prefixes: prefixes <= 10, rules: braces > 150 }).toEqual({ prefixes: true, rules: true }) | |
| 142 | expect(MARKETING).toMatch(/\[content="marketing"\]\), html:has\([^)]*\) \* \{ box-sizing: border-box; \}/) | |
| 143 | expect(MARKETING).not.toMatch(/(?:^|\n):root/) | |
| 144 | expect(MARKETING).not.toMatch(/(?:^|\n)html \{/) | |
| 145 | }) | |
| 146 | ||
| 147 | test('the six tokens it shared with TOKENS are declared once, not twice', () => { | |
| 148 | /* | |
| 149 | * --accent, --bg, --border, --text, --text-2 and --text-3 were declared in | |
| 150 | * both, byte-identical in all four theme blocks, so marketing pages | |
| 151 | * inherit them. --mono went too: it differed only by double quotes against | |
| 152 | * single, which CSS does not distinguish. | |
| 153 | * | |
| 154 | * --sans is the exception and stays: this copy carries `-apple-system` and | |
| 155 | * the TOKENS copy does not. Keeping it is what makes the move a no-op. | |
| 156 | */ | |
| 157 | /* | |
| 158 | * Comments stripped first. One of them reads "Not --text-3: the code block | |
| 159 | * is dark", which a naive match reads as a declaration of --text-3. | |
| 160 | */ | |
| 161 | const declarations = MARKETING.replace(/\/\*[\s\S]*?\*\//g, '') | |
| 162 | for (const token of ['--accent', '--bg', '--border', '--text', '--text-2', '--text-3', '--mono']) | |
| 163 | expect({ token, redeclared: new RegExp(`${token}\\s*:`).test(declarations) }).toEqual({ token, redeclared: false }) | |
| 164 | expect(MARKETING).toContain('--sans: "Space Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif') | |
| 165 | for (const token of ['--surface', '--surface-2', '--border-strong', '--accent-line', '--code-bg', '--code-ink', '--code-cap', '--r', '--r-sm']) | |
| 166 | expect({ token, present: MARKETING.includes(`${token}:`) }).toEqual({ token, present: true }) | |
| 121 | 167 | }) |
| 122 | 168 | |
| 123 | 169 | test('all four theme blocks, and the chrome, reach the generated sheet', async () => { |
| @@ -1,9 +1,19 @@ | ||
| 1 | 1 | import { describe, expect, test } from 'bun:test' |
| 2 | import { readFileSync } from 'node:fs' | |
| 3 | import { join } from 'node:path' | |
| 2 | import theme from '../../config/crosswind' | |
| 4 | 3 | |
| 5 | const ROOT = join(import.meta.dir, '../..') | |
| 6 | const marketing = readFileSync(join(ROOT, 'public/marketing.css'), 'utf8') | |
| 4 | /* | |
| 5 | * The marketing sheet, read out of the MARKETING preflight. It was | |
| 6 | * public/marketing.css until it moved into config/crosswind.ts, scoped to the | |
| 7 | * marketing layout group (stx-standards rule 3, §11.2). | |
| 8 | * | |
| 9 | * Everything below still reads the same, because the move nested the rules | |
| 10 | * under one prefix rather than rewriting them: `body:has(.nav-menu[open])` is | |
| 11 | * still spelled exactly that way in the source, and the prefix is prepended at | |
| 12 | * the top of the block. | |
| 13 | */ | |
| 14 | const marketing = ((theme as { preflights?: { getCSS: () => string }[] }).preflights ?? []) | |
| 15 | .map(entry => entry.getCSS()) | |
| 16 | .find(css => css.includes('content="marketing"')) ?? '' | |
| 7 | 17 | |
| 8 | 18 | // The phone menu lists every marketing destination in one column, which is |
| 9 | 19 | // taller than an 812px viewport. The panel has to scroll inside the viewport |