9b19186bb949
lighten the front end: the timeline's words come from /:repo/timeline after first paint and its canvas rests off screen; the signed-in navbar comes from the cookie with no flash and no /admin fetch; fonts are self-hosted, versioned assets immutable, and pages prefetch on hover (v3.0.197)
- time
- 2026-10-03 02:16 UTC · 1 week ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- lightning quick kepr
- parent
- 92240bfbe802
↓ kepr-9b19186b.tar.gzatlas at this save
files changed42 files
package.json+1 −1public/css/kepr.css+259 −13public/fonts/bellota-OFL.txt+93 −0public/fonts/bellota-cyrillic-700-normal.woff2binarypublic/fonts/bellota-latin-700-normal.woff2binarypublic/fonts/bellota-latin-ext-700-normal.woff2binarypublic/fonts/bellota-vietnamese-700-normal.woff2binarypublic/fonts/comfortaa-OFL.txt+93 −0public/fonts/comfortaa-cyrillic-700-normal.woff2binarypublic/fonts/comfortaa-cyrillic-ext-700-normal.woff2binarypublic/fonts/comfortaa-greek-700-normal.woff2binarypublic/fonts/comfortaa-latin-700-normal.woff2binarypublic/fonts/comfortaa-latin-ext-700-normal.woff2binarypublic/fonts/comfortaa-vietnamese-700-normal.woff2binarypublic/fonts/martian-mono-cyrillic-400-normal.woff2binarypublic/fonts/martian-mono-cyrillic-ext-400-normal.woff2binarypublic/fonts/martian-mono-latin-400-normal.woff2binarypublic/fonts/martian-mono-latin-ext-400-normal.woff2binarypublic/fonts/martianmono-OFL.txt+93 −0public/fonts/quicksand-OFL.txt+93 −0public/fonts/quicksand-latin-400-normal.woff2binarypublic/fonts/quicksand-latin-600-normal.woff2binarypublic/fonts/quicksand-latin-ext-400-normal.woff2binarypublic/fonts/quicksand-latin-ext-600-normal.woff2binarypublic/fonts/quicksand-vietnamese-400-normal.woff2binarypublic/fonts/quicksand-vietnamese-600-normal.woff2binarypublic/fonts/sacramento-OFL.txt+93 −0public/fonts/sacramento-latin-400-normal.woff2binarypublic/fonts/sacramento-latin-ext-400-normal.woff2binarypublic/js/kepr.js+24 −49public/js/nav.js+26 −0src/pages/housekeeping.ts+46 −0src/pages/layout.ts+1 −4src/pages/repo.ts+8 −1src/pages/timeline.ts+44 −23src/server/app.ts+12 −4src/server/login.ts+18 −2src/server/reserved.ts+1 −0tests/navbar.test.ts+62 −52tests/pages.test.ts+50 −7tests/repotext.test.ts+5 −2tests/script.test.ts+16 −0package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "kepr",3- "version": "3.0.196",3+ "version": "3.0.197",44 "private": true,55 "type": "module",66 "scripts": {public/css/kepr.css+259 −13
@@ -434,31 +434,23 @@434434 stroke-width: 1;435435 stroke-dasharray: 4 4;436436 }437-.timeline .dot circle {437+.timeline .dot {438438 fill: var(--accent);439- transition:440- fill-opacity 120ms,441- r 120ms;442439 }443-.timeline .d0 circle {440+.timeline .d0 {444441 fill: var(--accent-dim);445442 fill-opacity: 0.45;446443 }447-.timeline .d1 circle {444+.timeline .d1 {448445 fill-opacity: 0.55;449446 }450-.timeline .d2 circle {447+.timeline .d2 {451448 fill-opacity: 0.8;452449 }453-.timeline .d3 circle {450+.timeline .d3 {454451 fill: var(--accent-bright);455452 fill-opacity: 1;456453 }457-.timeline .dot:hover circle,458-.timeline .dot:focus circle {459- fill: var(--accent-bright);460- fill-opacity: 1;461-}462454 .timeline-canvas {463455 display: block;464456 margin: 18px 0 4px;@@ -2780,4 +2772,258 @@27802772 .hex-col {27812773 opacity: 0.12;27822774 }2775+}2776+2777+/* ----- fonts: served by Kepr itself; licences beside them in /static/fonts ----- */2778+2779+/* latin */2780+@font-face {2781+ font-family: "Bellota";2782+ font-style: normal;2783+ font-weight: 700;2784+ font-display: swap;2785+ src: url(/static/fonts/bellota-latin-700-normal.woff2) format("woff2");2786+ unicode-range:2787+ U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;2788+}2789+2790+/* cyrillic */2791+@font-face {2792+ font-family: "Bellota";2793+ font-style: normal;2794+ font-weight: 700;2795+ font-display: swap;2796+ src: url(/static/fonts/bellota-cyrillic-700-normal.woff2) format("woff2");2797+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;2798+}2799+2800+/* latin-ext */2801+@font-face {2802+ font-family: "Bellota";2803+ font-style: normal;2804+ font-weight: 700;2805+ font-display: swap;2806+ src: url(/static/fonts/bellota-latin-ext-700-normal.woff2) format("woff2");2807+ unicode-range:2808+ U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;2809+}2810+2811+/* vietnamese */2812+@font-face {2813+ font-family: "Bellota";2814+ font-style: normal;2815+ font-weight: 700;2816+ font-display: swap;2817+ src: url(/static/fonts/bellota-vietnamese-700-normal.woff2) format("woff2");2818+ unicode-range:2819+ U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;2820+}2821+2822+/* greek */2823+@font-face {2824+ font-family: "Comfortaa";2825+ font-style: normal;2826+ font-weight: 700;2827+ font-stretch: 100%;2828+ font-display: swap;2829+ src: url(/static/fonts/comfortaa-greek-700-normal.woff2) format("woff2");2830+ unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;2831+}2832+2833+/* latin */2834+@font-face {2835+ font-family: "Comfortaa";2836+ font-style: normal;2837+ font-weight: 700;2838+ font-stretch: 100%;2839+ font-display: swap;2840+ src: url(/static/fonts/comfortaa-latin-700-normal.woff2) format("woff2");2841+ unicode-range:2842+ U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;2843+}2844+2845+/* cyrillic */2846+@font-face {2847+ font-family: "Comfortaa";2848+ font-style: normal;2849+ font-weight: 700;2850+ font-stretch: 100%;2851+ font-display: swap;2852+ src: url(/static/fonts/comfortaa-cyrillic-700-normal.woff2) format("woff2");2853+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;2854+}2855+2856+/* latin-ext */2857+@font-face {2858+ font-family: "Comfortaa";2859+ font-style: normal;2860+ font-weight: 700;2861+ font-stretch: 100%;2862+ font-display: swap;2863+ src: url(/static/fonts/comfortaa-latin-ext-700-normal.woff2) format("woff2");2864+ unicode-range:2865+ U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;2866+}2867+2868+/* vietnamese */2869+@font-face {2870+ font-family: "Comfortaa";2871+ font-style: normal;2872+ font-weight: 700;2873+ font-stretch: 100%;2874+ font-display: swap;2875+ src: url(/static/fonts/comfortaa-vietnamese-700-normal.woff2) format("woff2");2876+ unicode-range:2877+ U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;2878+}2879+2880+/* cyrillic-ext */2881+@font-face {2882+ font-family: "Comfortaa";2883+ font-style: normal;2884+ font-weight: 700;2885+ font-stretch: 100%;2886+ font-display: swap;2887+ src: url(/static/fonts/comfortaa-cyrillic-ext-700-normal.woff2) format("woff2");2888+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;2889+}2890+2891+/* latin */2892+@font-face {2893+ font-family: "Martian Mono";2894+ font-style: normal;2895+ font-weight: 400;2896+ font-stretch: 100%;2897+ font-display: swap;2898+ src: url(/static/fonts/martian-mono-latin-400-normal.woff2) format("woff2");2899+ unicode-range:2900+ U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;2901+}2902+2903+/* cyrillic */2904+@font-face {2905+ font-family: "Martian Mono";2906+ font-style: normal;2907+ font-weight: 400;2908+ font-stretch: 100%;2909+ font-display: swap;2910+ src: url(/static/fonts/martian-mono-cyrillic-400-normal.woff2) format("woff2");2911+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;2912+}2913+2914+/* latin-ext */2915+@font-face {2916+ font-family: "Martian Mono";2917+ font-style: normal;2918+ font-weight: 400;2919+ font-stretch: 100%;2920+ font-display: swap;2921+ src: url(/static/fonts/martian-mono-latin-ext-400-normal.woff2) format("woff2");2922+ unicode-range:2923+ U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;2924+}2925+2926+/* cyrillic-ext */2927+@font-face {2928+ font-family: "Martian Mono";2929+ font-style: normal;2930+ font-weight: 400;2931+ font-stretch: 100%;2932+ font-display: swap;2933+ src: url(/static/fonts/martian-mono-cyrillic-ext-400-normal.woff2) format("woff2");2934+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;2935+}2936+2937+/* latin */2938+@font-face {2939+ font-family: "Quicksand";2940+ font-style: normal;2941+ font-weight: 400;2942+ font-stretch: 100%;2943+ font-display: swap;2944+ src: url(/static/fonts/quicksand-latin-400-normal.woff2) format("woff2");2945+ unicode-range:2946+ U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;2947+}2948+2949+/* latin-ext */2950+@font-face {2951+ font-family: "Quicksand";2952+ font-style: normal;2953+ font-weight: 400;2954+ font-stretch: 100%;2955+ font-display: swap;2956+ src: url(/static/fonts/quicksand-latin-ext-400-normal.woff2) format("woff2");2957+ unicode-range:2958+ U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;2959+}2960+2961+/* vietnamese */2962+@font-face {2963+ font-family: "Quicksand";2964+ font-style: normal;2965+ font-weight: 400;2966+ font-stretch: 100%;2967+ font-display: swap;2968+ src: url(/static/fonts/quicksand-vietnamese-400-normal.woff2) format("woff2");2969+ unicode-range:2970+ U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;2971+}2972+2973+/* latin */2974+@font-face {2975+ font-family: "Quicksand";2976+ font-style: normal;2977+ font-weight: 600;2978+ font-stretch: 100%;2979+ font-display: swap;2980+ src: url(/static/fonts/quicksand-latin-600-normal.woff2) format("woff2");2981+ unicode-range:2982+ U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;2983+}2984+2985+/* latin-ext */2986+@font-face {2987+ font-family: "Quicksand";2988+ font-style: normal;2989+ font-weight: 600;2990+ font-stretch: 100%;2991+ font-display: swap;2992+ src: url(/static/fonts/quicksand-latin-ext-600-normal.woff2) format("woff2");2993+ unicode-range:2994+ U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;2995+}2996+2997+/* vietnamese */2998+@font-face {2999+ font-family: "Quicksand";3000+ font-style: normal;3001+ font-weight: 600;3002+ font-stretch: 100%;3003+ font-display: swap;3004+ src: url(/static/fonts/quicksand-vietnamese-600-normal.woff2) format("woff2");3005+ unicode-range:3006+ U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;3007+}3008+3009+/* latin */3010+@font-face {3011+ font-family: "Sacramento";3012+ font-style: normal;3013+ font-weight: 400;3014+ font-display: swap;3015+ src: url(/static/fonts/sacramento-latin-400-normal.woff2) format("woff2");3016+ unicode-range:3017+ U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;3018+}3019+3020+/* latin-ext */3021+@font-face {3022+ font-family: "Sacramento";3023+ font-style: normal;3024+ font-weight: 400;3025+ font-display: swap;3026+ src: url(/static/fonts/sacramento-latin-ext-400-normal.woff2) format("woff2");3027+ unicode-range:3028+ U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;27833029 }public/fonts/bellota-OFL.txt+93 −0
@@ -0,0 +1,93 @@1+Copyright 2019 The Bellota Project Authors (https://github.com/kemie/Bellota-Font)2+3+This Font Software is licensed under the SIL Open Font License, Version 1.1.4+This license is copied below, and is also available with a FAQ at:5+http://scripts.sil.org/OFL6+7+8+-----------------------------------------------------------9+SIL OPEN FONT LICENSE Version 1.1 - 26 February 200710+-----------------------------------------------------------11+12+PREAMBLE13+The goals of the Open Font License (OFL) are to stimulate worldwide14+development of collaborative font projects, to support the font creation15+efforts of academic and linguistic communities, and to provide a free and16+open framework in which fonts may be shared and improved in partnership17+with others.18+19+The OFL allows the licensed fonts to be used, studied, modified and20+redistributed freely as long as they are not sold by themselves. The21+fonts, including any derivative works, can be bundled, embedded, 22+redistributed and/or sold with any software provided that any reserved23+names are not used by derivative works. The fonts and derivatives,24+however, cannot be released under any other type of license. The25+requirement for fonts to remain under this license does not apply26+to any document created using the fonts or their derivatives.27+28+DEFINITIONS29+"Font Software" refers to the set of files released by the Copyright30+Holder(s) under this license and clearly marked as such. This may31+include source files, build scripts and documentation.32+33+"Reserved Font Name" refers to any names specified as such after the34+copyright statement(s).35+36+"Original Version" refers to the collection of Font Software components as37+distributed by the Copyright Holder(s).38+39+"Modified Version" refers to any derivative made by adding to, deleting,40+or substituting -- in part or in whole -- any of the components of the41+Original Version, by changing formats or by porting the Font Software to a42+new environment.43+44+"Author" refers to any designer, engineer, programmer, technical45+writer or other person who contributed to the Font Software.46+47+PERMISSION & CONDITIONS48+Permission is hereby granted, free of charge, to any person obtaining49+a copy of the Font Software, to use, study, copy, merge, embed, modify,50+redistribute, and sell modified and unmodified copies of the Font51+Software, subject to the following conditions:52+53+1) Neither the Font Software nor any of its individual components,54+in Original or Modified Versions, may be sold by itself.55+56+2) Original or Modified Versions of the Font Software may be bundled,57+redistributed and/or sold with any software, provided that each copy58+contains the above copyright notice and this license. These can be59+included either as stand-alone text files, human-readable headers or60+in the appropriate machine-readable metadata fields within text or61+binary files as long as those fields can be easily viewed by the user.62+63+3) No Modified Version of the Font Software may use the Reserved Font64+Name(s) unless explicit written permission is granted by the corresponding65+Copyright Holder. This restriction only applies to the primary font name as66+presented to the users.67+68+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font69+Software shall not be used to promote, endorse or advertise any70+Modified Version, except to acknowledge the contribution(s) of the71+Copyright Holder(s) and the Author(s) or with their explicit written72+permission.73+74+5) The Font Software, modified or unmodified, in part or in whole,75+must be distributed entirely under this license, and must not be76+distributed under any other license. The requirement for fonts to77+remain under this license does not apply to any document created78+using the Font Software.79+80+TERMINATION81+This license becomes null and void if any of the above conditions are82+not met.83+84+DISCLAIMER85+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,86+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF87+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT88+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE89+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,90+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL91+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING92+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM93+OTHER DEALINGS IN THE FONT SOFTWARE.public/fonts/comfortaa-OFL.txt+93 −0
@@ -0,0 +1,93 @@1+Copyright 2011 The Comfortaa Project Authors (https://github.com/alexeiva/comfortaa), with Reserved Font Name "Comfortaa".
2+
3+This Font Software is licensed under the SIL Open Font License, Version 1.1.
4+This license is copied below, and is also available with a FAQ at:
5+http://scripts.sil.org/OFL
6+
7+
8+-----------------------------------------------------------
9+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10+-----------------------------------------------------------
11+
12+PREAMBLE
13+The goals of the Open Font License (OFL) are to stimulate worldwide
14+development of collaborative font projects, to support the font creation
15+efforts of academic and linguistic communities, and to provide a free and
16+open framework in which fonts may be shared and improved in partnership
17+with others.
18+
19+The OFL allows the licensed fonts to be used, studied, modified and
20+redistributed freely as long as they are not sold by themselves. The
21+fonts, including any derivative works, can be bundled, embedded,
22+redistributed and/or sold with any software provided that any reserved
23+names are not used by derivative works. The fonts and derivatives,
24+however, cannot be released under any other type of license. The
25+requirement for fonts to remain under this license does not apply
26+to any document created using the fonts or their derivatives.
27+
28+DEFINITIONS
29+"Font Software" refers to the set of files released by the Copyright
30+Holder(s) under this license and clearly marked as such. This may
31+include source files, build scripts and documentation.
32+
33+"Reserved Font Name" refers to any names specified as such after the
34+copyright statement(s).
35+
36+"Original Version" refers to the collection of Font Software components as
37+distributed by the Copyright Holder(s).
38+
39+"Modified Version" refers to any derivative made by adding to, deleting,
40+or substituting -- in part or in whole -- any of the components of the
41+Original Version, by changing formats or by porting the Font Software to a
42+new environment.
43+
44+"Author" refers to any designer, engineer, programmer, technical
45+writer or other person who contributed to the Font Software.
46+
47+PERMISSION & CONDITIONS
48+Permission is hereby granted, free of charge, to any person obtaining
49+a copy of the Font Software, to use, study, copy, merge, embed, modify,
50+redistribute, and sell modified and unmodified copies of the Font
51+Software, subject to the following conditions:
52+
53+1) Neither the Font Software nor any of its individual components,
54+in Original or Modified Versions, may be sold by itself.
55+
56+2) Original or Modified Versions of the Font Software may be bundled,
57+redistributed and/or sold with any software, provided that each copy
58+contains the above copyright notice and this license. These can be
59+included either as stand-alone text files, human-readable headers or
60+in the appropriate machine-readable metadata fields within text or
61+binary files as long as those fields can be easily viewed by the user.
62+
63+3) No Modified Version of the Font Software may use the Reserved Font
64+Name(s) unless explicit written permission is granted by the corresponding
65+Copyright Holder. This restriction only applies to the primary font name as
66+presented to the users.
67+
68+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69+Software shall not be used to promote, endorse or advertise any
70+Modified Version, except to acknowledge the contribution(s) of the
71+Copyright Holder(s) and the Author(s) or with their explicit written
72+permission.
73+
74+5) The Font Software, modified or unmodified, in part or in whole,
75+must be distributed entirely under this license, and must not be
76+distributed under any other license. The requirement for fonts to
77+remain under this license does not apply to any document created
78+using the Font Software.
79+
80+TERMINATION
81+This license becomes null and void if any of the above conditions are
82+not met.
83+
84+DISCLAIMER
85+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93+OTHER DEALINGS IN THE FONT SOFTWARE.
public/fonts/martianmono-OFL.txt+93 −0
@@ -0,0 +1,93 @@1+Copyright 2021 The Martian Mono Project Authors (https://github.com/evilmartians/mono)2+3+This Font Software is licensed under the SIL Open Font License, Version 1.1.4+This license is copied below, and is also available with a FAQ at:5+http://scripts.sil.org/OFL6+7+8+-----------------------------------------------------------9+SIL OPEN FONT LICENSE Version 1.1 - 26 February 200710+-----------------------------------------------------------11+12+PREAMBLE13+The goals of the Open Font License (OFL) are to stimulate worldwide14+development of collaborative font projects, to support the font creation15+efforts of academic and linguistic communities, and to provide a free and16+open framework in which fonts may be shared and improved in partnership17+with others.18+19+The OFL allows the licensed fonts to be used, studied, modified and20+redistributed freely as long as they are not sold by themselves. The21+fonts, including any derivative works, can be bundled, embedded, 22+redistributed and/or sold with any software provided that any reserved23+names are not used by derivative works. The fonts and derivatives,24+however, cannot be released under any other type of license. The25+requirement for fonts to remain under this license does not apply26+to any document created using the fonts or their derivatives.27+28+DEFINITIONS29+"Font Software" refers to the set of files released by the Copyright30+Holder(s) under this license and clearly marked as such. This may31+include source files, build scripts and documentation.32+33+"Reserved Font Name" refers to any names specified as such after the34+copyright statement(s).35+36+"Original Version" refers to the collection of Font Software components as37+distributed by the Copyright Holder(s).38+39+"Modified Version" refers to any derivative made by adding to, deleting,40+or substituting -- in part or in whole -- any of the components of the41+Original Version, by changing formats or by porting the Font Software to a42+new environment.43+44+"Author" refers to any designer, engineer, programmer, technical45+writer or other person who contributed to the Font Software.46+47+PERMISSION & CONDITIONS48+Permission is hereby granted, free of charge, to any person obtaining49+a copy of the Font Software, to use, study, copy, merge, embed, modify,50+redistribute, and sell modified and unmodified copies of the Font51+Software, subject to the following conditions:52+53+1) Neither the Font Software nor any of its individual components,54+in Original or Modified Versions, may be sold by itself.55+56+2) Original or Modified Versions of the Font Software may be bundled,57+redistributed and/or sold with any software, provided that each copy58+contains the above copyright notice and this license. These can be59+included either as stand-alone text files, human-readable headers or60+in the appropriate machine-readable metadata fields within text or61+binary files as long as those fields can be easily viewed by the user.62+63+3) No Modified Version of the Font Software may use the Reserved Font64+Name(s) unless explicit written permission is granted by the corresponding65+Copyright Holder. This restriction only applies to the primary font name as66+presented to the users.67+68+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font69+Software shall not be used to promote, endorse or advertise any70+Modified Version, except to acknowledge the contribution(s) of the71+Copyright Holder(s) and the Author(s) or with their explicit written72+permission.73+74+5) The Font Software, modified or unmodified, in part or in whole,75+must be distributed entirely under this license, and must not be76+distributed under any other license. The requirement for fonts to77+remain under this license does not apply to any document created78+using the Font Software.79+80+TERMINATION81+This license becomes null and void if any of the above conditions are82+not met.83+84+DISCLAIMER85+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,86+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF87+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT88+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE89+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,90+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL91+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING92+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM93+OTHER DEALINGS IN THE FONT SOFTWARE.public/fonts/quicksand-OFL.txt+93 −0
@@ -0,0 +1,93 @@1+Copyright 2011 The Quicksand Project Authors (https://github.com/andrew-paglinawan/QuicksandFamily), with Reserved Font Name “Quicksand”.2+3+This Font Software is licensed under the SIL Open Font License, Version 1.1.4+This license is copied below, and is also available with a FAQ at:5+http://scripts.sil.org/OFL6+7+8+-----------------------------------------------------------9+SIL OPEN FONT LICENSE Version 1.1 - 26 February 200710+-----------------------------------------------------------11+12+PREAMBLE13+The goals of the Open Font License (OFL) are to stimulate worldwide14+development of collaborative font projects, to support the font creation15+efforts of academic and linguistic communities, and to provide a free and16+open framework in which fonts may be shared and improved in partnership17+with others.18+19+The OFL allows the licensed fonts to be used, studied, modified and20+redistributed freely as long as they are not sold by themselves. The21+fonts, including any derivative works, can be bundled, embedded, 22+redistributed and/or sold with any software provided that any reserved23+names are not used by derivative works. The fonts and derivatives,24+however, cannot be released under any other type of license. The25+requirement for fonts to remain under this license does not apply26+to any document created using the fonts or their derivatives.27+28+DEFINITIONS29+"Font Software" refers to the set of files released by the Copyright30+Holder(s) under this license and clearly marked as such. This may31+include source files, build scripts and documentation.32+33+"Reserved Font Name" refers to any names specified as such after the34+copyright statement(s).35+36+"Original Version" refers to the collection of Font Software components as37+distributed by the Copyright Holder(s).38+39+"Modified Version" refers to any derivative made by adding to, deleting,40+or substituting -- in part or in whole -- any of the components of the41+Original Version, by changing formats or by porting the Font Software to a42+new environment.43+44+"Author" refers to any designer, engineer, programmer, technical45+writer or other person who contributed to the Font Software.46+47+PERMISSION & CONDITIONS48+Permission is hereby granted, free of charge, to any person obtaining49+a copy of the Font Software, to use, study, copy, merge, embed, modify,50+redistribute, and sell modified and unmodified copies of the Font51+Software, subject to the following conditions:52+53+1) Neither the Font Software nor any of its individual components,54+in Original or Modified Versions, may be sold by itself.55+56+2) Original or Modified Versions of the Font Software may be bundled,57+redistributed and/or sold with any software, provided that each copy58+contains the above copyright notice and this license. These can be59+included either as stand-alone text files, human-readable headers or60+in the appropriate machine-readable metadata fields within text or61+binary files as long as those fields can be easily viewed by the user.62+63+3) No Modified Version of the Font Software may use the Reserved Font64+Name(s) unless explicit written permission is granted by the corresponding65+Copyright Holder. This restriction only applies to the primary font name as66+presented to the users.67+68+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font69+Software shall not be used to promote, endorse or advertise any70+Modified Version, except to acknowledge the contribution(s) of the71+Copyright Holder(s) and the Author(s) or with their explicit written72+permission.73+74+5) The Font Software, modified or unmodified, in part or in whole,75+must be distributed entirely under this license, and must not be76+distributed under any other license. The requirement for fonts to77+remain under this license does not apply to any document created78+using the Font Software.79+80+TERMINATION81+This license becomes null and void if any of the above conditions are82+not met.83+84+DISCLAIMER85+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,86+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF87+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT88+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE89+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,90+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL91+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING92+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM93+OTHER DEALINGS IN THE FONT SOFTWARE.public/fonts/sacramento-OFL.txt+93 −0
@@ -0,0 +1,93 @@1+Copyright (c) 2012, Brian J. Bonislawsky DBA Astigmatic (AOETI) ([email protected]), with Reserved Font Names 'Sacramento'2+3+This Font Software is licensed under the SIL Open Font License, Version 1.1.4+This license is copied below, and is also available with a FAQ at:5+http://scripts.sil.org/OFL6+7+8+-----------------------------------------------------------9+SIL OPEN FONT LICENSE Version 1.1 - 26 February 200710+-----------------------------------------------------------11+12+PREAMBLE13+The goals of the Open Font License (OFL) are to stimulate worldwide14+development of collaborative font projects, to support the font creation15+efforts of academic and linguistic communities, and to provide a free and16+open framework in which fonts may be shared and improved in partnership17+with others.18+19+The OFL allows the licensed fonts to be used, studied, modified and20+redistributed freely as long as they are not sold by themselves. The21+fonts, including any derivative works, can be bundled, embedded, 22+redistributed and/or sold with any software provided that any reserved23+names are not used by derivative works. The fonts and derivatives,24+however, cannot be released under any other type of license. The25+requirement for fonts to remain under this license does not apply26+to any document created using the fonts or their derivatives.27+28+DEFINITIONS29+"Font Software" refers to the set of files released by the Copyright30+Holder(s) under this license and clearly marked as such. This may31+include source files, build scripts and documentation.32+33+"Reserved Font Name" refers to any names specified as such after the34+copyright statement(s).35+36+"Original Version" refers to the collection of Font Software components as37+distributed by the Copyright Holder(s).38+39+"Modified Version" refers to any derivative made by adding to, deleting,40+or substituting -- in part or in whole -- any of the components of the41+Original Version, by changing formats or by porting the Font Software to a42+new environment.43+44+"Author" refers to any designer, engineer, programmer, technical45+writer or other person who contributed to the Font Software.46+47+PERMISSION & CONDITIONS48+Permission is hereby granted, free of charge, to any person obtaining49+a copy of the Font Software, to use, study, copy, merge, embed, modify,50+redistribute, and sell modified and unmodified copies of the Font51+Software, subject to the following conditions:52+53+1) Neither the Font Software nor any of its individual components,54+in Original or Modified Versions, may be sold by itself.55+56+2) Original or Modified Versions of the Font Software may be bundled,57+redistributed and/or sold with any software, provided that each copy58+contains the above copyright notice and this license. These can be59+included either as stand-alone text files, human-readable headers or60+in the appropriate machine-readable metadata fields within text or61+binary files as long as those fields can be easily viewed by the user.62+63+3) No Modified Version of the Font Software may use the Reserved Font64+Name(s) unless explicit written permission is granted by the corresponding65+Copyright Holder. This restriction only applies to the primary font name as66+presented to the users.67+68+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font69+Software shall not be used to promote, endorse or advertise any70+Modified Version, except to acknowledge the contribution(s) of the71+Copyright Holder(s) and the Author(s) or with their explicit written72+permission.73+74+5) The Font Software, modified or unmodified, in part or in whole,75+must be distributed entirely under this license, and must not be76+distributed under any other license. The requirement for fonts to77+remain under this license does not apply to any document created78+using the Font Software.79+80+TERMINATION81+This license becomes null and void if any of the above conditions are82+not met.83+84+DISCLAIMER85+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,86+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF87+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT88+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE89+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,90+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL91+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING92+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM93+OTHER DEALINGS IN THE FONT SOFTWARE.public/js/kepr.js+24 −49
@@ -21,21 +21,24 @@2121 ];2222 // Kepr 2's timeline (canvas.js keprThread): the saves on a strip that drifts slowly and loops.2323 // Hovering pauses it and shows the save, dragging scrubs, a click opens the save. Under reduced24-// motion it moves only when dragged.25-function timeline() {24+// motion it moves only when dragged, and it draws only while on screen. The saves are fetched25+// once the page has drawn; the SVG's plain dots stand in until then.26+async function timeline() {2627 const svg = document.querySelector("svg.timeline");2728 if (!svg) return;29+ const response = await fetch(svg.dataset.src);30+ if (!response.ok) return;31+ const repo = svg.dataset.src.split("/")[1];2832 const css = getComputedStyle(document.documentElement);29- const saves = [...svg.querySelectorAll(".dot")]30- .map((dot) => {31- const [id, message, meta] = (dot.querySelector("title")?.textContent ?? "").split("\n");33+ const saves = (await response.json())34+ .map(({ id, t, message, meta }) => {3235 const [, color, alpha] = KINDS.find(([kind]) => kind.test(message)) ?? [3336 null,3437 "text-secondary",3538 0.8,3639 ];3740 const fill = css.getPropertyValue(`--${color}`).trim();38- return { href: dot.getAttribute("href"), id, message, meta, fill, alpha, t: +dot.dataset.t };41+ return { href: `/${repo}/saves/${id}`, id, message, meta, fill, alpha, t };3942 })4043 .sort((a, b) => a.t - b.t);4144 if (saves.length === 0) return;@@ -155,7 +158,8 @@155158 }156159 }157160 ctx.globalAlpha = 1;158- requestAnimationFrame(draw);161+ if (visible) requestAnimationFrame(draw);162+ else drawing = false;159163 };160164 canvas.addEventListener("pointerdown", (event) => {161165 [drag, dragFrom, downX, lastX, velocity] = [@@ -190,7 +194,16 @@190194 });191195 addEventListener("resize", resize);192196 resize();193- requestAnimationFrame(draw);197+ // Off screen, the loop stops; it starts again, without a jump, when the strip scrolls back.198+ let [visible, drawing] = [true, false];199+ new IntersectionObserver(([entry]) => {200+ visible = entry.isIntersecting;201+ if (visible && !drawing) {202+ drawing = true;203+ lastFrame = 0;204+ requestAnimationFrame(draw);205+ }206+ }).observe(canvas);194207 }195208 196209 function line(className, text) {@@ -649,42 +662,6 @@649662 return open;650663 }651664 652-async function navbarLogin() {653- const link = document.querySelector('.nav-links a[href^="/sign-in"]');654- if (!link || !/(^|;\s*)kepr_editor=1(?:;|$)/.test(document.cookie)) return;655- try {656- const response = await fetch("/admin", { cache: "no-store" });657- if (!response.ok) return;658- const doc = new DOMParser().parseFromString(await response.text(), "text/html");659- const token = doc.querySelector('form[action="/admin/sign-out"] input[name="form"]');660- if (!token) return;661- const form = element("form", "nav-logout");662- form.method = "post";663- form.action = "/admin/sign-out";664- const button = element("button", "", "log out");665- button.type = "submit";666- form.append(token, button);667- form.addEventListener("submit", async (event) => {668- event.preventDefault();669- button.disabled = true;670- try {671- const result = await fetch(form.action, { method: "POST", body: new FormData(form) });672- if (!result.ok) throw new Error("Log out failed. Reload and try again.");673- location.href = "/";674- } catch (error) {675- button.disabled = false;676- button.textContent = "try log out again";677- console.error(error);678- }679- });680- const admin = element("a", "", "admin");681- admin.href = "/admin";682- link.replaceWith(admin, form);683- } catch (error) {684- console.error("Could not check navbar sign-in state", error);685- }686-}687-688665 // On a phone the nav links fold behind a menu button (2026-10-01). The button only exists with689666 // JavaScript, so without it the links stay in the bar; the CSS hides them only on `.nav--menu`.690667 function navMenu() {@@ -716,7 +693,6 @@716693 // Before the links, so tabbing from the open button reaches them next.717694 links.before(button);718695 }719-720696 function element(tag, className, text) {721697 const node = document.createElement(tag);722698 node.className = className;@@ -727,10 +703,10 @@727703 // Signed in, a repo page's description and details are edited in place: Markdown with a toolbar728704 // and a preview as the page will show it, pictures and videos picked, dropped or pasted in, and729705 // the one under the caret sized and placed from a row of controls. The page's HTML is the same730-// for everyone; only this cookie, which says nothing but "signed in here", brings the controls.706+// for everyone; only the cookie signing in sets beside the session brings the controls.731707 async function repoEditor() {732708 const head = document.querySelector(".repo-head");733- if (!head || !/(^|;\s*)kepr_editor=1/.test(document.cookie)) return;709+ if (!head || !/(^|;\s*)kepr_editor=[0-9a-f]{64}/.test(document.cookie)) return;734710 const base = `/admin/repos/${location.pathname.split("/")[1]}`;735711 const response = await fetch(`${base}/text`).catch(() => null);736712 if (!response?.ok) return;@@ -1014,13 +990,12 @@1014990 1015991 // Widths are only final once Martian Mono has loaded.1016992 document.fonts.ready.then(tickerPace);1017-timeline();993+timeline().catch((error) => console.error("kepr: the timeline could not load", error));1018994 copyButtons(document);1019995 panels();1020996 saveLists();1021997 atlasPicker();1022998 repoEditor();1023-navbarLogin();1024999 navMenu();10251000 lineRanges();10261001 atlas();public/js/nav.js+26 −0
@@ -0,0 +1,26 @@1+// The navbar when signed in: "admin" and "log out" in place of "log in". Loaded right after the2+// nav, before the rest of the page, so the signed-out links never show first. Pages are the same3+// for everyone; the readable kepr_editor cookie, set beside the session, carries the form token4+// that log out posts back, so this needs no request.5+{6+ const token = /(?:^|;\s*)kepr_editor=([0-9a-f]{64})(?:;|$)/.exec(document.cookie)?.[1];7+ const link = document.querySelector('.nav-links a[href^="/sign-in"]');8+ if (token && link) {9+ const admin = document.createElement("a");10+ admin.href = "/admin";11+ admin.textContent = "admin";12+ const form = document.createElement("form");13+ form.className = "nav-logout";14+ form.method = "post";15+ form.action = "/admin/sign-out";16+ const field = document.createElement("input");17+ field.type = "hidden";18+ field.name = "form";19+ field.value = token;20+ const button = document.createElement("button");21+ button.type = "submit";22+ button.textContent = "log out";23+ form.append(field, button);24+ link.replaceWith(admin, form);25+ }26+}src/pages/housekeeping.ts+46 −0
@@ -7,9 +7,55 @@77 // Kepr 2's mark, in Kepr's gold.88 const FAVICON = readFileSync(join(import.meta.dir, "../../public/favicon.svg"));99 10+// Chromium fetches a link's page while the pointer rests on it (2026-10-02), so a click opens at11+// once. Never a link whose GET does something: a download, an archive or raw file, a counted feed12+// or update check, Koh's routes, a backup, opening a notification (marks it read), or a form whose13+// page sets a cookie or starts the issue form's spam timer.14+const SPECULATION = JSON.stringify({15+ prefetch: [16+ {17+ where: {18+ and: [19+ { href_matches: "/*" },20+ {21+ not: {22+ href_matches: [23+ "/*/archive/*",24+ "/*/raw/*",25+ "/*/releases/*",26+ "/*/install.sh",27+ "/*/koh/*",28+ "/*/update-info",29+ "/*/meta",30+ "/*/bundle*",31+ "/*/file",32+ "/source",33+ "/api/*",34+ "/admin/backup*",35+ "/admin/notifications/*",36+ "/admin/sign-out",37+ "/*/issues/*",38+ "/sign-in*",39+ "/connect*",40+ ],41+ },42+ },43+ ],44+ },45+ eagerness: "moderate",46+ },47+ ],48+});49+1050 /** §22.6: robots.txt, the sitemap (public repos only, as everywhere, §20) and the favicon. */1151 export function registerHousekeeping(app: Hono, ctx: PageContext): void {1252 const origin = `https://${ctx.domain}`;53+ app.get("/speculation.json", (c) =>54+ c.body(SPECULATION, 200, {55+ "Content-Type": "application/speculationrules+json",56+ "Cache-Control": "public, max-age=3600",57+ }),58+ );1359 app.get("/robots.txt", (c) =>1460 c.body(`User-agent: *\nAllow: /\n\nSitemap: ${origin}/sitemap.xml\n`, 200, {1561 "Content-Type": "text/plain; charset=UTF-8",src/pages/layout.ts+1 −4
@@ -17,8 +17,6 @@1717 // Content at a save never changes, but strip can remove it, so it is long-lived rather than1818 // immutable, and `private`, so no shared cache keeps it after the repo turns private (§20).1919 const SAVE_CACHE = "private, max-age=31536000";20-const FONTS =21- "https://fonts.bunny.net/css?family=bellota:700|comfortaa:700|martian-mono:400|quicksand:400,600|sacramento:400&display=swap";2220 2321 /**2422 * The gate of §20: a private repo and an unknown one both come back null, and so both 404 alike.@@ -68,8 +66,6 @@6866 <meta name="theme-color" content="#090706">6967 <link rel="canonical" href="${url}">7068 <link rel="icon" href="/favicon.svg" type="image/svg+xml">71-<link rel="preconnect" href="https://fonts.bunny.net">72-<link rel="stylesheet" href="${FONTS}">7369 <link rel="stylesheet" href="/static/css/kepr.css?v=${version}">7470 <script src="/static/js/kepr.js?v=${version}" defer></script>7571 </head>@@ -81,6 +77,7 @@8177 ${branding && html`<span class="wordmark-by">${branding}</span>`}8278 <span class="nav-links"><a href="/browse">browse</a><a href="/docs">docs</a>${!setting(ctx.kepr, "support_off") && html`<a href="/support">support</a>`}<a href="/sign-in?next=${encodeURIComponent(options.path).replaceAll("%2F", "/")}">log in</a></span>8379 </nav>80+<script src="/static/js/nav.js?v=${version}"></script>8481 <main>8582 ${options.body}8683 </main>src/pages/repo.ts+8 −1
@@ -27,7 +27,7 @@2727 relativeTime,2828 } from "./layout.ts";2929 import { renderMarkdown, renderParagraph } from "./markdown.ts";30-import { timelineSvg } from "./timeline.ts";30+import { timelineSaves, timelineSvg } from "./timeline.ts";3131 3232 const PAGE_SIZE = 50;3333 // Each opens inline under the tabs (kepr.js), as Kepr 2's panels did; the page is the fallback.@@ -60,6 +60,13 @@6060 ${details(repoDetails(repo))}`,6161 }),6262 );63+ });64+ // The timeline's saves, fetched by kepr.js once the page has drawn (§21.4).65+ app.get("/:repo/timeline", (c) => {66+ const repo = openPublicRepo(ctx, c.req.param("repo"));67+ if (!repo) return notFound(c, ctx);68+ cacheFor(c, false);69+ return c.json(timelineSaves(timeline(repo.mirror)));6370 });6471 // The Atlas picker's rows, fetched by kepr.js when it first opens (§21.5).6572 app.get("/:repo/atlas/saves", (c) => {src/pages/timeline.ts+44 −23
@@ -4,40 +4,61 @@44 55 const TIP_CHARS = 120;66 7+/** One dot of the drifting timeline, as kepr.js reads it from `/<repo>/timeline`. */8+export type TimelineSave = { id: string; t: number; message: string; meta: string };9+710 /**8- * §21.4's dotted timeline as SVG, so it draws without JavaScript (kepr.js swaps it for Kepr 2's9- * drifting canvas, reading each save from its dot): one dot per save on a dashed line, x by10- * time (as a percentage, so it scales without squashing dots), brighter and fuller where11- * neighbours crowd within a fortieth of the span.11+ * §21.4's dotted timeline as SVG, so it draws without JavaScript: one dot per save on a dashed12+ * line, x by time (as a percentage, so it scales without squashing dots), brighter and fuller where13+ * neighbours crowd within a fortieth of the span. kepr.js swaps it for Kepr 2's drifting canvas,14+ * fetching each save's words from `data-src` after the page has drawn: carried as tooltips in the15+ * page, they were most of a big repo's page.1216 */1317 export function timelineSvg(repo: string, saves: Save[]): Html {1418 const times = saves.map((save) => save.created_at);1519 // Replayed saves keep their own times, so the line is not in time order.16- const first = times.reduce((a, b) => Math.min(a, b), times[0] ?? 0);17- const span = times.reduce((a, b) => Math.max(a, b), first) - first;20+ const sorted = times.toSorted((a, b) => a - b);21+ const first = sorted[0] ?? 0;22+ const span = (sorted.at(-1) ?? first) - first;1823 const reach = Math.max(span / 40, 3600);19- const dots = saves.map((save) => {20- const x = span === 0 ? 50 : 1 + (98 * (save.created_at - first)) / span;21- // ponytail: O(n²) neighbour count; a sliding window over sorted times if a repo22- // reaches ~5,000 saves.23- const near = times.filter((t) => Math.abs(t - save.created_at) <= reach).length;24+ const dots = times.map((t) => {25+ const x = span === 0 ? 50 : 1 + (98 * (t - first)) / span;26+ const near = countBetween(sorted, t - reach, t + reach);2427 const level = near >= 8 ? 3 : near >= 4 ? 2 : near >= 2 ? 1 : 0;25- const meta = [formatDate(save.created_at), save.agent, save.session]26- .filter(Boolean)27- .join(" · ");28- // The tip is 340 px wide; a whole message wrapped to many lines and was most of a big29- // repo's page.30- const line = save.message.split("\n")[0] as string;31- const message = line.length > TIP_CHARS ? `${line.slice(0, TIP_CHARS - 1)}…` : line;32- const tip = `${save.id}\n${message}\n${meta}`;33- return html`<a href="/${34- repo35- }/saves/${save.id}" class="dot d${level}" data-t="${save.created_at}"><title>${tip}</title><circle cx="${x.toFixed(3)}%" cy="28" r="${2.5 + level * 0.5}"/></a>`;28+ return html`<circle class="dot d${level}" cx="${x.toFixed(3)}%" cy="28" r="${2.5 + level * 0.5}"/>`;3629 });3730 return html`<svg class="timeline" width="100%" height="56" role="img" aria-label="${3831 saves.length39- } saves over time">32+ } saves over time" data-src="/${repo}/timeline">4033 <line class="timeline-line" x1="0" x2="100%" y1="28" y2="28"/>4134 ${dots}4235 </svg>`;36+}37+38+/** Each save's ID, time, first line (cut to fit the 340 px tip) and when, who and in what session. */39+export function timelineSaves(saves: Save[]): TimelineSave[] {40+ return saves.map((save) => {41+ const line = save.message.split("\n")[0] as string;42+ return {43+ id: save.id,44+ t: save.created_at,45+ message: line.length > TIP_CHARS ? `${line.slice(0, TIP_CHARS - 1)}…` : line,46+ meta: [formatDate(save.created_at), save.agent, save.session].filter(Boolean).join(" · "),47+ };48+ });49+}50+51+// How many of the sorted times fall within [low, high].52+function countBetween(sorted: number[], low: number, high: number): number {53+ // The index of the first time that `before` no longer holds for.54+ const first = (before: (time: number) => boolean) => {55+ let [lo, hi] = [0, sorted.length];56+ while (lo < hi) {57+ const mid = (lo + hi) >> 1;58+ if (before(sorted[mid] as number)) lo = mid + 1;59+ else hi = mid;60+ }61+ return lo;62+ };63+ return first((t) => t <= high) - first((t) => t < low);4364 }src/server/app.ts+12 −4
@@ -43,7 +43,6 @@4343 const MEDIA_UPLOAD = /^\/admin\/repos\/[^/]+\/media$/;4444 export const PUBLIC_DIR = join(import.meta.dir, "../../public");4545 // §18 allows Bunny Fonts: its stylesheet and the font files it points at.46-const FONTS = "https://fonts.bunny.net";4746 4847 /** §25's headers: the CSP, with no inline scripts, and no framing. */4948 export function securityHeaders(imgSrc: (string | (() => string))[] = ["'self'"]) {@@ -51,8 +50,8 @@5150 contentSecurityPolicy: {5251 defaultSrc: ["'self'"],5352 scriptSrc: ["'self'"],54- styleSrc: ["'self'", FONTS],55- fontSrc: [FONTS],53+ styleSrc: ["'self'"],54+ fontSrc: ["'self'"],5655 imgSrc,5756 mediaSrc: imgSrc,5857 objectSrc: ["'none'"],@@ -85,6 +84,9 @@8584 app.use("*", async (c, next) => {8685 await next();8786 const path = c.req.path;87+ if (c.res.headers.get("Content-Type")?.startsWith("text/html")) {88+ c.header("Speculation-Rules", '"/speculation.json"');89+ }8890 if (/^\/(admin(?:\/|$)|sign-in$|connect$)/.test(path))8991 c.header("Referrer-Policy", "same-origin");9092 if (/^\/[^/]+\/raw\/.+/.test(path) || path.startsWith("/static/assets/")) {@@ -128,7 +130,13 @@128130 path.startsWith("assets/") ? join(assetsDir, path.slice(7)) : join(PUBLIC_DIR, path),129131 );130132 if (!(await file.exists())) return c.notFound();131- c.header("Cache-Control", "public, max-age=3600");133+ // A versioned URL names one build's file, and a font file never changes under its name, so134+ // neither needs asking again.135+ const versioned = c.req.query("v") !== undefined || path.startsWith("fonts/");136+ c.header(137+ "Cache-Control",138+ versioned ? "public, max-age=31536000, immutable" : "public, max-age=3600",139+ );132140 return c.body(await file.bytes(), 200, { "Content-Type": file.type });133141 });134142 const mirrorDir = join(config.dataDir, "mirror");src/server/login.ts+18 −2
@@ -83,9 +83,23 @@8383 * a page on another site can neither read nor guess it, and it dies with the session.8484 */8585 export function formToken(c: Context): string {86- return sha256(`form:${getCookie(c, SESSION_COOKIE) ?? ""}`);86+ return formTokenOf(getCookie(c, SESSION_COOKIE) ?? "");8787 }8888 89+function formTokenOf(session: string): string {90+ return sha256(`form:${session}`);91+}92+93+// The readable mark beside the session: its form token, so the navbar can offer log out without94+// asking the server (nav.js). Readable by the site's own scripts only, as any page's form is.95+function setEditorCookie(c: Context, session: string): void {96+ setCookie(c, EDITOR_COOKIE, formTokenOf(session), {97+ ...COOKIE,98+ httpOnly: false,99+ maxAge: SESSION_SECONDS,100+ });101+}102+89103 /**90104 * The refusal page behind every admin entry point, `/connect` included: one answer for a missing91105 * session, a foreign origin, an expired form token or too many attempts, wherever it arrives.@@ -117,6 +131,8 @@117131 app.use("/admin/*", async (c, next) => {118132 const token = sessionToken(ctx, c);119133 if (!token) return refuse(c, 401, "Sign in first.");134+ // A mark set before it carried the token is brought up to date.135+ if (getCookie(c, EDITOR_COOKIE) !== formTokenOf(token)) setEditorCookie(c, token);120136 if (c.req.method !== "GET") {121137 const error = await adminPostRefusal(ctx, c);122138 if (error) return refuse(c, 403, error);@@ -155,7 +171,7 @@155171 .query("INSERT INTO sessions (token_hash, expires_at) VALUES (?, ?)")156172 .run(sha256(token), at + SESSION_SECONDS);157173 setCookie(c, SESSION_COOKIE, token, { ...COOKIE, maxAge: SESSION_SECONDS });158- setCookie(c, EDITOR_COOKIE, "1", { ...COOKIE, httpOnly: false, maxAge: SESSION_SECONDS });174+ setEditorCookie(c, token);159175 deleteCookie(c, SIGN_IN_COOKIE, COOKIE);160176 return c.redirect(next, 303);161177 });src/server/reserved.ts+1 −0
@@ -18,6 +18,7 @@1818 "robots.txt",1919 "sign-in",2020 "sitemap.xml",21+ "speculation.json",2122 "source",2223 "static",2324 "support",tests/navbar.test.ts+62 −52
@@ -1,58 +1,68 @@1-import { expect, test } from "bun:test";1+// Covers the signed-in navbar (nav.js): "admin" and "log out" in place of "log in", built from2+// the readable mark signing in sets beside the session, with no request.3+4+import { describe, expect, test } from "bun:test";25 import { runInNewContext } from "node:vm";6+import { Browser, instance } from "./browser.ts";7+import { makeRepos } from "./fixture.ts";38 4-const script = await Bun.file(new URL("../public/js/kepr.js", import.meta.url)).text();5-const navbar = script.slice(6- script.indexOf("async function navbarLogin()"),7- script.indexOf("// Signed in, a repo page"),8-);9+const script = await Bun.file(new URL("../public/js/nav.js", import.meta.url)).text();10+const TOKEN = "ab".repeat(32);911 10-test("navbar offers admin and protected log out only after confirming a live session", async () => {11- for (const [cookie, status, logout] of [12- ["", 200, false],13- ["kepr_editor=1", 401, false],14- ["kepr_editor=1", 200, true],15- ] as const) {16- const nodes: Record<string, unknown>[] = [];17- let replacement: unknown;18- const token = { name: "form", value: "session-form-token" };19- const document = {20- cookie,21- querySelector: () => ({22- replaceWith: (node: unknown) => {23- replacement = node;24- },25- }),26- createElement: (tag: string) => {27- const node: Record<string, unknown> = {28- tag,29- addEventListener: () => {},30- append: (...children: unknown[]) => {31- node.children = children;32- },33- };34- nodes.push(node);35- return node;12+function run(cookie: string) {13+ const nodes: Record<string, unknown>[] = [];14+ let replacement: unknown[] = [];15+ const document = {16+ cookie,17+ querySelector: () => ({18+ replaceWith: (...replaced: unknown[]) => {19+ replacement = replaced;3620 },37- };38- await runInNewContext(`${navbar}\nnavbarLogin()`, {39- document,40- fetch: async () => ({ ok: status === 200, text: async () => "admin page" }),41- DOMParser: class {42- parseFromString() {43- return { querySelector: () => token };44- }45- },46- console,47- });48- expect(Boolean(replacement)).toBe(logout);49- if (logout) {50- expect(nodes[0]?.method).toBe("post");51- expect(nodes[0]?.action).toBe("/admin/sign-out");52- expect(nodes[0]?.children).toEqual([token, nodes[1]]);53- expect(nodes[1]?.textContent).toBe("log out");54- expect(nodes.find((node) => node.tag === "a")?.href).toBe("/admin");55- expect(nodes.find((node) => node.tag === "a")?.textContent).toBe("admin");21+ }),22+ createElement: (tag: string) => {23+ const node: Record<string, unknown> = {24+ tag,25+ append: (...children: unknown[]) => {26+ node.children = children;27+ },28+ };29+ nodes.push(node);30+ return node;31+ },32+ };33+ runInNewContext(script, { document });34+ return { nodes, replacement };35+}36+37+describe("the signed-in navbar", () => {38+ test("stays as it is without a mark carrying a form token", () => {39+ for (const cookie of ["", "kepr_editor=1", `kepr_editor=${TOKEN.slice(1)}`]) {40+ expect(run(cookie).replacement).toEqual([]);5641 }57- }42+ });43+44+ test("offers admin and a log out form posting the mark's token", () => {45+ const { nodes, replacement } = run(`theme=dark; kepr_editor=${TOKEN}`);46+ const [admin, form] = replacement as Record<string, unknown>[];47+ expect(admin).toMatchObject({ tag: "a", href: "/admin", textContent: "admin" });48+ expect(form).toMatchObject({ tag: "form", method: "post", action: "/admin/sign-out" });49+ const [field, button] = (form?.children ?? []) as Record<string, unknown>[];50+ expect(field).toMatchObject({ tag: "input", type: "hidden", name: "form", value: TOKEN });51+ expect(button).toMatchObject({ tag: "button", type: "submit", textContent: "log out" });52+ expect(nodes).toHaveLength(4);53+ });54+55+ test("the mark's token is the one log out needs", async () => {56+ const repos = await makeRepos(["app"]);57+ const browser = new Browser((await instance(repos.bucketDir)).app);58+ await browser.signIn();59+ const mark = browser.cookies.get("kepr_editor") ?? "";60+ expect(mark).toMatch(/^[0-9a-f]{64}$/);61+ // A mark from before it carried the token is brought up to date by any admin page.62+ browser.cookies.set("kepr_editor", "1");63+ await browser.get("/admin");64+ expect(browser.cookies.get("kepr_editor")).toBe(mark);65+ expect((await browser.post("/admin/sign-out", { form: mark })).status).toBe(303);66+ expect((await browser.get("/admin")).status).toBe(401);67+ });5868 });tests/pages.test.ts+50 −7
@@ -7,7 +7,7 @@77 import { syncAll } from "../src/bucket/sync.ts";88 import { fileAt, objectBytes, type Save } from "../src/mirror/queries.ts";99 import { openMirror } from "../src/mirror/store.ts";10-import { timelineSvg } from "../src/pages/timeline.ts";10+import { timelineSaves, timelineSvg } from "../src/pages/timeline.ts";1111 import { createApp } from "../src/server/app.ts";1212 import { openKeprDb } from "../src/server/db.ts";1313 import { type Fixture, makeKohBucket } from "./fixture.ts";@@ -109,7 +109,7 @@109109 expect(page).toContain('<span class="lang-badge">TypeScript</span>');110110 expect(page).toContain("3 saves");111111 expect(page).not.toContain('class="install-line"');112- expect(page.match(/class="dot d\d"/g)?.length).toBe(3);112+ expect(page.match(/<circle class="dot d\d"/g)?.length).toBe(3);113113 expect(page).not.toContain("density = activity");114114 expect(historyIds(page)).toEqual([head(), second(), first()]);115115 expect(page).toContain('data-copy="koh steal kepr.example/demo"');@@ -159,12 +159,33 @@159159 expect(dots).toEqual(["50.000", "1.000", "99.000", "25.500"]);160160 });161161 162+ test("a dot's neighbours are counted within a fractional reach exactly", () => {163+ // A span of 144,001 s makes the reach 3,600.025 s: the save at 3,601 s is not within it.164+ const at = (created_at: number) => ({ ...(saves[0] as Save), created_at });165+ const svg = timelineSvg("demo", [at(0), at(3601), at(144001)]).value;166+ expect(svg.match(/class="dot d(\d)"/g)).toEqual([167+ 'class="dot d0"',168+ 'class="dot d0"',169+ 'class="dot d0"',170+ ]);171+ });172+162173 test("a dot's tooltip keeps a long message's first 120 characters", () => {163174 const long = { ...(saves[0] as Save), message: `${"x".repeat(150)}\nsecond line` };164- const svg = timelineSvg("demo", [long]).value;165- expect(svg).toContain(`\n${"x".repeat(119)}…\n`);166- expect(svg).not.toContain("x".repeat(120));175+ expect(timelineSaves([long])[0]?.message).toBe(`${"x".repeat(119)}…`);167176 });177+178+ test("the page carries plain dots; their words come from /<repo>/timeline", async () => {179+ const page = await (await get("/demo")).text();180+ expect(page).toContain('data-src="/demo/timeline"');181+ expect(/<svg class="timeline"[\s\S]*?<\/svg>/.exec(page)?.[0]).not.toContain("<title>");182+ const response = await get("/demo/timeline");183+ expect(response.headers.get("cache-control")).toBe("public, max-age=60");184+ const saves = (await response.json()) as { id: string; message: string }[];185+ expect(saves.map((save) => save.message)).toContain("say hello");186+ expect(saves.length).toBe(3);187+ expect((await get("/nope/timeline")).status).toBe(404);188+ });168189 });169190 170191 describe("the file viewer", () => {@@ -446,8 +467,8 @@446467 expect(favicon.headers.get("content-security-policy")).not.toContain("sandbox");447468 expect((await get("/static/../package.json")).status).toBe(404);448469 const csp = (await get("/demo")).headers.get("content-security-policy") ?? "";449- expect(csp).toContain("style-src 'self' https://fonts.bunny.net");450- expect(csp).toContain("font-src https://fonts.bunny.net");470+ expect(csp).toContain("style-src 'self';");471+ expect(csp).toContain("font-src 'self';");451472 expect(csp).not.toContain("unsafe-inline");452473 });453474 @@ -474,4 +495,26 @@474495 '<link rel="icon" href="/favicon.svg" type="image/svg+xml">',475496 );476497 });498+});499+500+test("pages are prefetched on hover, never a link whose GET does something", async () => {501+ const page = await get("/demo");502+ expect(page.headers.get("speculation-rules")).toBe('"/speculation.json"');503+ const rules = await get("/speculation.json");504+ expect(rules.headers.get("content-type")).toBe("application/speculationrules+json");505+ const text = await rules.text();506+ for (const pattern of [507+ "/*/archive/*",508+ "/*/raw/*",509+ "/*/releases/*",510+ "/*/koh/*",511+ "/api/*",512+ "/admin/backup*",513+ "/admin/notifications/*",514+ "/*/issues/*",515+ "/sign-in*",516+ "/connect*",517+ ]) {518+ expect(text).toContain(JSON.stringify(pattern));519+ }477520 });tests/repotext.test.ts+5 −2
@@ -29,10 +29,13 @@2929 browser = new Browser(inst.app);3030 });3131 32- test("signing in leaves a script-readable mark that says only that, beside the session", async () => {32+ test("signing in leaves a script-readable mark, the form token, beside the session", async () => {3333 const response = await browser.signIn();3434 const cookies = response.headers.getSetCookie();35- expect(cookies.find((line) => line.startsWith("kepr_editor=1"))).not.toMatch(/HttpOnly/i);35+ expect(cookies.find((line) => line.startsWith("kepr_editor="))).toMatch(36+ /^kepr_editor=[0-9a-f]{64};/,37+ );38+ expect(cookies.find((line) => line.startsWith("kepr_editor="))).not.toMatch(/HttpOnly/i);3639 expect(cookies.find((line) => line.startsWith("kepr_session="))).toMatch(/HttpOnly/i);3740 // The public page is the same for everyone, signed in or not.3841 expect(await (await browser.request("/app")).text()).toBe(await page("/app"));tests/script.test.ts+16 −0
@@ -0,0 +1,16 @@1+// Covers kepr.js's start: every island it calls is defined, so one missing name can't stop the2+// rest of the page's scripts.3+4+import { expect, test } from "bun:test";5+6+const script = await Bun.file(new URL("../public/js/kepr.js", import.meta.url)).text();7+8+test("every function kepr.js calls at start is defined in it", () => {9+ const started = [...script.matchAll(/^(\w+)\(/gm)].map((match) => match[1] as string);10+ expect(started.length).toBeGreaterThan(5);11+ for (const name of started) {12+ expect(`${name} ${new RegExp(`^(async )?function ${name}\\(`, "m").test(script)}`).toBe(13+ `${name} true`,14+ );15+ }16+});