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
42 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 −0
package.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": {

view file

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 }

view file

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.

view file

public/fonts/bellota-cyrillic-700-normal.woff2binary

binary file, not shown

view file

public/fonts/bellota-latin-700-normal.woff2binary

binary file, not shown

view file

public/fonts/bellota-latin-ext-700-normal.woff2binary

binary file, not shown

view file

public/fonts/bellota-vietnamese-700-normal.woff2binary

binary file, not shown

view file

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.

view file

public/fonts/comfortaa-cyrillic-700-normal.woff2binary

binary file, not shown

view file

public/fonts/comfortaa-cyrillic-ext-700-normal.woff2binary

binary file, not shown

view file

public/fonts/comfortaa-greek-700-normal.woff2binary

binary file, not shown

view file

public/fonts/comfortaa-latin-700-normal.woff2binary

binary file, not shown

view file

public/fonts/comfortaa-latin-ext-700-normal.woff2binary

binary file, not shown

view file

public/fonts/comfortaa-vietnamese-700-normal.woff2binary

binary file, not shown

view file

public/fonts/martian-mono-cyrillic-400-normal.woff2binary

binary file, not shown

view file

public/fonts/martian-mono-cyrillic-ext-400-normal.woff2binary

binary file, not shown

view file

public/fonts/martian-mono-latin-400-normal.woff2binary

binary file, not shown

view file

public/fonts/martian-mono-latin-ext-400-normal.woff2binary

binary file, not shown

view file

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.

view file

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.

view file

public/fonts/quicksand-latin-400-normal.woff2binary

binary file, not shown

view file

public/fonts/quicksand-latin-600-normal.woff2binary

binary file, not shown

view file

public/fonts/quicksand-latin-ext-400-normal.woff2binary

binary file, not shown

view file

public/fonts/quicksand-latin-ext-600-normal.woff2binary

binary file, not shown

view file

public/fonts/quicksand-vietnamese-400-normal.woff2binary

binary file, not shown

view file

public/fonts/quicksand-vietnamese-600-normal.woff2binary

binary file, not shown

view file

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.

view file

public/fonts/sacramento-latin-400-normal.woff2binary

binary file, not shown

view file

public/fonts/sacramento-latin-ext-400-normal.woff2binary

binary file, not shown

view file

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();

view file

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+}

view file

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",

view file

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>

view file

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) => {

view file

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 }

view file

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");

view file

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 	});

view file

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",

view file

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 });

view file

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 });

view file

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"));

view file

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+});

view file