Your music, beautifully tracked. All yours. (coming soon) teal.fm
teal-fm atproto

Merge pull request #19 from teal-fm/stamp

streamline root, add stamp page

authored by

natalie and committed by
GitHub
e97510d1 ab65183a

+1012 -35
+2 -1
apps/aqua/package.json
··· 25 25 "@atproto/sync": "^0.1.5", 26 26 "@atproto/syntax": "^0.3.0", 27 27 "@atproto/xrpc-server": "^0.6.4", 28 + "@braintree/sanitize-url": "^7.1.0", 28 29 "@hono/node-server": "^1.13.7", 29 30 "@libsql/client": "^0.14.0", 30 31 "dotenv": "^16.4.5", ··· 59 60 "sourcemap": true, 60 61 "clean": true 61 62 } 62 - } 63 + }
+72 -1
apps/aqua/src/auth/router.ts
··· 1 1 import { atclient } from "./client"; 2 2 import { db } from "@teal/db/connect"; 3 + import {atProtoSession} from "@teal/db/schema" 4 + import { eq } from "drizzle-orm" 3 5 import { EnvWithCtx, TealContext } from "@/ctx"; 4 6 import { Hono } from "hono"; 5 7 import { tealSession } from "@teal/db/schema"; ··· 7 9 import { env } from "@/lib/env"; 8 10 9 11 export async function callback(c: TealContext) { 10 - // Initiate the OAuth flow 11 12 try { 12 13 const honoParams = c.req.query(); 13 14 console.log("params", honoParams); ··· 41 42 maxAge: 60 * 60 * 24 * 365, 42 43 }); 43 44 45 + if(params.get("spa")) { 46 + return c.json({ 47 + provider: "atproto", 48 + jwt: did, 49 + accessToken: did, 50 + }) 51 + } 52 + 44 53 return c.redirect("/"); 45 54 } catch (e) { 46 55 console.error(e); ··· 48 57 } 49 58 } 50 59 60 + 61 + // Refresh an access token from a refresh token. Should be only used in SPAs. 62 + // Pass in 'key' and 'refresh_token' query params. 63 + export async function refresh(c: TealContext) { 64 + try { 65 + const honoParams = c.req.query(); 66 + console.log("params", honoParams); 67 + const params = new URLSearchParams(honoParams); 68 + let key = params.get("key"); 69 + let refresh_token = params.get("refresh_token"); 70 + if(!key || !refresh_token) { 71 + return Response.json({error: "Missing key or refresh_token"}); 72 + } 73 + // check if refresh token is valid 74 + let r_tk_check = await db.select().from(atProtoSession).where(eq(atProtoSession.key, key)).execute() as any; 75 + 76 + if(r_tk_check.tokenSet.refresh_token !== refresh_token) { 77 + return Response.json({error: "Invalid refresh token"}); 78 + } 79 + 80 + 81 + const session = await atclient.restore(key); 82 + 83 + const did = session.did; 84 + 85 + // Process successful authentication here 86 + console.log("User authenticated as:", did); 87 + 88 + // gen opaque tealSessionKey 89 + const sess = crypto.randomUUID(); 90 + await db 91 + .insert(tealSession) 92 + .values({ 93 + key: sess, 94 + // ATP session key (DID) 95 + session: JSON.stringify(did), 96 + provider: "atproto", 97 + }) 98 + .execute(); 99 + 100 + // cookie time 101 + console.log("Setting cookie", sess); 102 + setCookie(c, "tealSession", "teal:" + sess, { 103 + httpOnly: true, 104 + secure: env.HOST.startsWith("https"), 105 + sameSite: "lax", 106 + path: "/", 107 + maxAge: 60 * 60 * 24 * 365, 108 + }); 109 + 110 + return c.json({ 111 + provider:"atproto", 112 + jwt: did, 113 + accessToken: did, 114 + }) 115 + } catch (e) { 116 + console.error(e); 117 + return Response.json({ error: "Could not authorize user" }); 118 + } 119 + } 120 + 51 121 const app = new Hono<EnvWithCtx>(); 52 122 53 123 app.get("/callback", async (c) => callback(c)); 124 + app.get("/refresh", async (c) => refresh(c)); 54 125 55 126 export const getAuthRouter = () => { 56 127 return app;
+186 -31
apps/aqua/src/index.ts
··· 1 1 import { serve } from "@hono/node-server"; 2 + import { serveStatic } from '@hono/node-server/serve-static' 2 3 import { Hono } from "hono"; 3 4 import { db } from "@teal/db/connect"; 4 5 import { getAuthRouter } from "./auth/router"; ··· 7 8 import { env } from "./lib/env"; 8 9 import { getCookie, deleteCookie } from "hono/cookie"; 9 10 import { atclient } from "./auth/client"; 10 - import { getContextDID, getSessionAgent, getUserInfo } from "./lib/auth"; 11 + import { getSessionAgent } from "./lib/auth"; 12 + import { RichText } from "@atproto/api"; 13 + import { sanitizeUrl } from "@braintree/sanitize-url"; 14 + 15 + const HEAD = `<head> 16 + <link rel="stylesheet" href="/latex.css"> 17 + </head>` 11 18 12 19 const logger = pino({ name: "server start" }); 13 20 ··· 22 29 }); 23 30 24 31 app.get("/", async (c) => { 25 - const cookies = getCookie(c, "tealSession"); 26 - const sessCookie = cookies?.split("teal:")[1]; 32 + const tealSession = getCookie(c, "tealSession"); 27 33 28 34 // Serve logged in content 29 - if (sessCookie != undefined) { 30 - const session = await getContextDID(c); 31 - 32 - if (session != undefined) { 33 - const agent = await getSessionAgent(c); 34 - const post = await agent?.getPost({repo: "teal.fm", rkey: "3lb2c74v73c2a"}); 35 - // const agent = await getSessionAgent(c); 36 - // const followers = await agent?.getFollowers(); 37 - return c.html( 38 - `<div id="root"> 39 - <div id="header"> 35 + if (tealSession) { 36 + // const followers = await agent?.getFollowers(); 37 + return c.html( 38 + ` 39 + ${HEAD} 40 + <div id="root"> 41 + <div id="header" style="display: flex; flex-direction: column; gap: 0.5rem; width: 100%;"> 42 + <div> 40 43 <h1>teal.fm</h1> 41 44 <p>Your music, beautifully tracked. (soon.)</p> 42 45 </div> 43 - <div class="container"> 44 - <h1>${post?.value.text}</h1> 46 + <div style=" width: 100%; display: flex; flex-direction: row; justify-content: space-between; gap: 0.5rem;"> 47 + <div> 48 + <a href="/">home</a> 49 + <a href="/stamp">stamp</a> 50 + </div> 51 + <a href="/logout" style="background-color: #cc0000; color: white; border: none; padding: 0rem 0.5rem; border-radius: 0.5rem;">logout</a> 45 52 </div> 46 - <form action="/logout" method="post" class="session-form"> 47 - <button type="submit">Log out</button> 48 - </form> 49 - </div>` 50 - ); 51 - } 53 + </div> 54 + <div class="container"> 55 + 56 + </div> 57 + </div>`, 58 + ); 52 59 } 53 60 54 61 // Serve non-logged in content 55 62 return c.html( 56 - `<div id="root"> 63 + ` 64 + ${HEAD} 65 + <div id="root"> 57 66 <div id="header"> 58 67 <h1>teal.fm</h1> 59 68 <p>Your music, beautifully tracked. (soon.)</p> 69 + <div style=" width: 100%; display: flex; flex-direction: row; justify-content: space-between; gap: 0.5rem;"> 70 + <div> 71 + <a href="/">home</a> 72 + <a href="/stamp">stamp</a> 73 + </div> 74 + <button style="background-color: #acf; color: white; border: none; padding: 0rem 0.5rem; border-radius: 0.5rem;"><a href="/login">Login</a></button> 75 + </div> 60 76 </div> 61 77 <div class="container"> 62 - <button><a href="/login">Login</a></button> 63 78 <div class="signup-cta"> 64 79 Don't have an account on the Atmosphere? 65 80 <a href="https://bsky.app">Sign up for Bluesky</a> to create one now! 66 81 </div> 67 82 </div> 68 - </div>` 83 + </div>`, 69 84 ); 70 85 }); 71 86 72 87 app.get("/login", (c) => { 88 + const tealSession = getCookie(c, "tealSession"); 89 + 73 90 return c.html( 74 - `<div id="root"> 91 + ` 92 + ${HEAD} 93 + <div id="root"> 75 94 <div id="header"> 76 95 <h1>teal.fm</h1> 77 96 <p>Your music, beautifully tracked. (soon.)</p> 97 + <div style=" width: 100%; display: flex; flex-direction: row; justify-content: space-between; gap: 0.5rem;"> 98 + <div> 99 + <a href="/">home</a> 100 + <a href="/stamp">stamp</a> 101 + </div> 102 + <div /> 103 + </div> 78 104 </div> 79 105 <div class="container"> 80 106 <form action="/login" method="post" class="login-form"> ··· 91 117 <a href="https://bsky.app">Sign up for Bluesky</a> to create one now! 92 118 </div> 93 119 </div> 94 - </div>` 120 + </div>`, 95 121 ); 96 122 }); 97 123 98 124 app.post("/login", async (c: TealContext) => { 99 125 const body = await c.req.parseBody(); 100 - const { handle } = body; 126 + let { handle } = body; 127 + // shouldn't be a file, escape now 128 + if (handle instanceof File) return c.redirect("/login"); 129 + handle = sanitizeUrl(handle); 101 130 console.log("handle", handle); 102 131 // Initiate the OAuth flow 103 132 try { ··· 122 151 return c.redirect("/"); 123 152 }); 124 153 154 + app.get("/stamp", (c) => { 155 + // check logged in 156 + const tealSession = getCookie(c, "tealSession"); 157 + if (!tealSession) { 158 + return c.redirect("/login"); 159 + } 160 + return c.html( 161 + ` 162 + ${HEAD} 163 + <div id="root"> 164 + <div id="header"> 165 + <h1>teal.fm</h1> 166 + <p>Your music, beautifully tracked. (soon.)</p> 167 + <div style=" width: 100%; display: flex; flex-direction: row; justify-content: space-between; gap: 0.5rem;"> 168 + <div> 169 + <a href="/">home</a> 170 + <a href="/stamp">stamp</a> 171 + </div> 172 + <form action="/logout" method="post" class="session-form"> 173 + <button type="submit" style="background-color: #cc0000; color: white; border: none; padding: 0rem 0.5rem; border-radius: 0.5rem;">logout</button> 174 + </form> 175 + </div> 176 + </div> 177 + <div class="container"> 178 + <p>🛠️ while we're building our music tracker, share what you're listening to here!<br/> 179 + <a href="https://emojipedia.org/white-flower">💮</a> we'll create a post on Bluesky for you to share with the world!<br/>​</p> 180 + <form action="/stamp" method="post" class="login-form" style="display: flex; flex-direction: column; gap: 0.5rem;"> 181 + <input 182 + type="text" 183 + name="artist" 184 + placeholder="artist name (eg blink-182)" 185 + required 186 + /> 187 + <input 188 + type="text" 189 + name="track" 190 + placeholder="track title (eg what's my age again?)" 191 + required 192 + /> 193 + <input 194 + type="text" 195 + name="link" 196 + placeholder="https://www.youtube.com/watch?v=K7l5ZeVVoCA" 197 + /> 198 + <button type="submit" style="width: 15%">Stamp!</button> 199 + </form> 200 + <div class="signup-cta"> 201 + Don't have an account on the Atmosphere? 202 + <a href="https://bsky.app">Sign up for Bluesky</a> to create one now! 203 + </div> 204 + </div> 205 + </div>`, 206 + ); 207 + }); 208 + 209 + app.post("/stamp", async (c: TealContext) => { 210 + const body = await c.req.parseBody(); 211 + let { artist, track, link } = body; 212 + // shouldn't get a File, escape now 213 + if (artist instanceof File || track instanceof File || link instanceof File) return c.redirect("/stamp"); 214 + 215 + artist = sanitizeUrl(artist); 216 + track = sanitizeUrl(track); 217 + link = sanitizeUrl(link); 218 + 219 + const agent = await getSessionAgent(c); 220 + 221 + if (agent) { 222 + const rt = new RichText({ 223 + text: `💮 now playing: 224 + artist: ${artist} 225 + track: ${track} 226 + 227 + powered by @teal.fm`, 228 + }); 229 + await rt.detectFacets(agent); 230 + 231 + let embed = undefined; 232 + if (link) { 233 + embed = { 234 + $type: "app.bsky.embed.external", 235 + external: { 236 + uri: link, 237 + title: track, 238 + description: `${artist} - ${track}`, 239 + }, 240 + }; 241 + } 242 + const post = await agent.post({ 243 + text: rt.text, 244 + facets: rt.facets, 245 + embed: embed, 246 + }); 247 + 248 + console.log(`post: ${post}`); 249 + 250 + return c.html( 251 + ` 252 + ${HEAD} 253 + <div id="root"> 254 + <div id="header"> 255 + <h1>teal.fm</h1> 256 + <p>Your music, beautifully tracked. (soon.)</p> 257 + <div style=" width: 100%; display: flex; flex-direction: row; justify-content: space-between; gap: 0.5rem;"> 258 + <div> 259 + <a href="/">home</a> 260 + <a href="/stamp">stamp</a> 261 + </div> 262 + <form action="/logout" method="post" class="session-form"> 263 + <button type="submit" style="background-color: #cc0000; color: white; border: none; padding: 0rem 0.5rem; border-radius: 0.5rem;">logout</button> 264 + </form> 265 + </div> 266 + </div> 267 + <div class="container"> 268 + <h2 class="stamp-success">Success! 🎉</h2> 269 + <p>Your post is being tracked by the Atmosphere.</p> 270 + <p>You can view it <a href="https://bsky.app/profile/${agent.did}/post/${post.uri.split("/").pop()}">here</a>.</p> 271 + </div> 272 + </div>`, 273 + ); 274 + } 275 + return c.html(`<h1>doesn't look like you're logged in... try <a href="/login">logging in?</a></h1>`); 276 + }); 277 + 278 + app.use('/*', serveStatic({ root: '/src/public' })); 279 + 125 280 const run = async () => { 126 281 logger.info("Running in " + navigator.userAgent); 127 282 if (navigator.userAgent.includes("Node")) { ··· 136 291 `Listening on ${ 137 292 info.address == "::1" 138 293 ? "http://localhost" 139 - // TODO: below should probably be https:// 140 - // but i just want to ctrl click in the terminal 141 - : "http://" + info.address 294 + : // TODO: below should probably be https:// 295 + // but i just want to ctrl click in the terminal 296 + "http://" + info.address 142 297 }:${info.port} (${info.family})`, 143 298 ); 144 299 },
+5 -2
apps/aqua/src/lib/auth.ts
··· 3 3 import { Session } from "@atproto/oauth-client-node"; 4 4 import { tealSession } from "@teal/db/schema"; 5 5 import { eq } from "drizzle-orm"; 6 - import { getCookie } from "hono/cookie"; 6 + import { deleteCookie, getCookie } from "hono/cookie"; 7 7 import { atclient } from "@/auth/client"; 8 8 import { Agent } from "@atproto/api"; 9 9 ··· 20 20 21 21 export async function getUserInfo( 22 22 c: TealContext 23 - ): Promise<UserInfo> | undefined { 23 + ): Promise<UserInfo | undefined> { 24 24 // init session agent 25 25 const agent = await getSessionAgent(c); 26 26 if (agent && agent.did) { ··· 54 54 }).execute(); 55 55 56 56 if (!session) { 57 + // we should log them out here and redirect to home to double check 58 + deleteCookie(c, "tealSession"); 59 + c.redirect("/"); 57 60 throw new Error("No DID found in session"); 58 61 } 59 62 return session.session.replace(/['"]/g, "");
apps/aqua/src/public/fonts/LM-bold-italic.ttf

This is a binary file and will not be displayed.

apps/aqua/src/public/fonts/LM-bold-italic.woff

This is a binary file and will not be displayed.

apps/aqua/src/public/fonts/LM-bold-italic.woff2

This is a binary file and will not be displayed.

apps/aqua/src/public/fonts/LM-bold.ttf

This is a binary file and will not be displayed.

apps/aqua/src/public/fonts/LM-bold.woff

This is a binary file and will not be displayed.

apps/aqua/src/public/fonts/LM-bold.woff2

This is a binary file and will not be displayed.

apps/aqua/src/public/fonts/LM-italic.ttf

This is a binary file and will not be displayed.

apps/aqua/src/public/fonts/LM-italic.woff

This is a binary file and will not be displayed.

apps/aqua/src/public/fonts/LM-italic.woff2

This is a binary file and will not be displayed.

apps/aqua/src/public/fonts/LM-regular.ttf

This is a binary file and will not be displayed.

apps/aqua/src/public/fonts/LM-regular.woff

This is a binary file and will not be displayed.

apps/aqua/src/public/fonts/LM-regular.woff2

This is a binary file and will not be displayed.

apps/aqua/src/public/fonts/Libertinus-bold-italic.woff2

This is a binary file and will not be displayed.

apps/aqua/src/public/fonts/Libertinus-bold.woff2

This is a binary file and will not be displayed.

apps/aqua/src/public/fonts/Libertinus-italic.woff2

This is a binary file and will not be displayed.

apps/aqua/src/public/fonts/Libertinus-regular.woff2

This is a binary file and will not be displayed.

apps/aqua/src/public/fonts/Libertinus-semibold-italic.woff2

This is a binary file and will not be displayed.

apps/aqua/src/public/fonts/Libertinus-semibold.woff2

This is a binary file and will not be displayed.

+747
apps/aqua/src/public/latex.css
··· 1 + /*! 2 + * LaTeX.css (https://latex.vercel.app/) 3 + * 4 + * Source: https://github.com/vincentdoerig/latex-css 5 + * Licensed under MIT (https://github.com/vincentdoerig/latex-css/blob/master/LICENSE) 6 + */ 7 + 8 + @font-face { 9 + font-family: 'Latin Modern'; 10 + font-style: normal; 11 + font-weight: normal; 12 + font-display: swap; 13 + src: url('./fonts/LM-regular.woff2') format('woff2'), 14 + url('./fonts/LM-regular.woff') format('woff'), 15 + url('./fonts/LM-regular.ttf') format('truetype'); 16 + } 17 + 18 + @font-face { 19 + font-family: 'Latin Modern'; 20 + font-style: italic; 21 + font-weight: normal; 22 + font-display: swap; 23 + src: url('./fonts/LM-italic.woff2') format('woff2'), 24 + url('./fonts/LM-italic.woff') format('woff'), 25 + url('./fonts/LM-italic.ttf') format('truetype'); 26 + } 27 + 28 + @font-face { 29 + font-family: 'Latin Modern'; 30 + font-style: normal; 31 + font-weight: bold; 32 + font-display: swap; 33 + src: url('./fonts/LM-bold.woff2') format('woff2'), 34 + url('./fonts/LM-bold.woff') format('woff'), 35 + url('./fonts/LM-bold.ttf') format('truetype'); 36 + } 37 + 38 + @font-face { 39 + font-family: 'Latin Modern'; 40 + font-style: italic; 41 + font-weight: bold; 42 + font-display: swap; 43 + src: url('./fonts/LM-bold-italic.woff2') format('woff2'), 44 + url('./fonts/LM-bold-italic.woff') format('woff'), 45 + url('./fonts/LM-bold-italic.ttf') format('truetype'); 46 + } 47 + 48 + @font-face { 49 + font-family: 'Libertinus'; 50 + font-style: normal; 51 + font-weight: normal; 52 + font-display: swap; 53 + src: url('./fonts/Libertinus-regular.woff2') format('woff2'); 54 + } 55 + 56 + @font-face { 57 + font-family: 'Libertinus'; 58 + font-style: italic; 59 + font-weight: normal; 60 + font-display: swap; 61 + src: url('./fonts/Libertinus-italic.woff2') format('woff2'); 62 + } 63 + 64 + @font-face { 65 + font-family: 'Libertinus'; 66 + font-style: normal; 67 + font-weight: bold; 68 + font-display: swap; 69 + src: url('./fonts/Libertinus-bold.woff2') format('woff2'); 70 + } 71 + 72 + @font-face { 73 + font-family: 'Libertinus'; 74 + font-style: italic; 75 + font-weight: bold; 76 + font-display: swap; 77 + src: url('./fonts/Libertinus-bold-italic.woff2') format('woff2'); 78 + } 79 + 80 + @font-face { 81 + font-family: 'Libertinus'; 82 + font-style: normal; 83 + font-weight: 600; 84 + font-display: swap; 85 + src: url('./fonts/Libertinus-semibold.woff2') format('woff2'); 86 + } 87 + 88 + @font-face { 89 + font-family: 'Libertinus'; 90 + font-style: italic; 91 + font-weight: 600; 92 + font-display: swap; 93 + src: url('./fonts/Libertinus-semibold-italic.woff2') format('woff2'); 94 + } 95 + 96 + /* Box sizing rules */ 97 + *, 98 + *::before, 99 + *::after { 100 + box-sizing: border-box; 101 + } 102 + 103 + :root { 104 + --body-color: hsl(0, 5%, 10%); 105 + --body-bg-color: hsl(210, 20%, 98%); 106 + --link-visited: hsl(0, 100%, 33%); 107 + --link-focus-outline: hsl(220, 90%, 52%); 108 + --pre-bg-color: hsl(210, 28%, 93%); 109 + --kbd-bg-color: hsl(210, 5%, 100%); 110 + --kbd-border-color: hsl(210, 5%, 70%); 111 + --table-border-color: black; 112 + --border-width-thin: 1.36px; 113 + --border-width-thick: 2.27px; 114 + --sidenote-target-border-color: hsl(55, 55%, 70%); 115 + --footnotes-border-color: hsl(0, 0%, 39%); 116 + --text-indent-size: 1.463rem; /* In 12pt [Latin Modern font] LaTeX article 117 + \parindent =~ 17.625pt; taking also into account the ratio 118 + 1pt[LaTeX] = (72 / 72.27) * 1pt[HTML], with default 12pt/1rem LaTeX.css font 119 + size, the identation value in rem CSS units is: 120 + \parindent =~ 17.625 * (72 / 72.27) / 12 = 1.463rem. */ 121 + } 122 + 123 + .latex-dark { 124 + --body-color: hsl(0, 0%, 86%); 125 + --body-bg-color: hsl(0, 0%, 16%); 126 + --link-visited: hsl(196 80% 77%); 127 + --link-focus-outline: hsl(215, 63%, 73%); 128 + --pre-bg-color: hsl(0, 1%, 25%); 129 + --kbd-bg-color: hsl(0, 0%, 16%); 130 + --kbd-border-color: hsl(210, 5%, 70%); 131 + --table-border-color: white; 132 + --sidenote-target-border-color: hsl(0, 0%, 59%); 133 + --footnotes-border-color: hsl(0, 0%, 59%); 134 + --proof-symbol-filter: invert(80%); 135 + } 136 + 137 + @media (prefers-color-scheme: dark) { 138 + .latex-dark-auto { 139 + --body-color: hsl(0, 0%, 86%); 140 + --body-bg-color: hsl(0, 0%, 16%); 141 + --link-visited: hsl(196 80% 77%); 142 + --link-focus-outline: hsl(215, 63%, 73%); 143 + --pre-bg-color: hsl(0, 1%, 25%); 144 + --kbd-bg-color: hsl(0, 0%, 16%); 145 + --kbd-border-color: hsl(210, 5%, 70%); 146 + --table-border-color: white; 147 + --sidenote-target-border-color: hsl(0, 0%, 59%); 148 + --footnotes-border-color: hsl(0, 0%, 59%); 149 + --proof-symbol-filter: invert(80%); 150 + } 151 + } 152 + 153 + /* Remove default margin */ 154 + body, 155 + h1, 156 + h2, 157 + h3, 158 + h4, 159 + p, 160 + ul[class], 161 + ol[class], 162 + li, 163 + figure, 164 + figcaption, 165 + dl, 166 + dd { 167 + margin: 0; 168 + } 169 + 170 + /* Make default font-size 1rem and add smooth scrolling to anchors */ 171 + html { 172 + font-size: 1rem; 173 + } 174 + @media (prefers-reduced-motion: no-preference) { 175 + html { 176 + scroll-behavior: smooth; 177 + } 178 + } 179 + 180 + body.libertinus { 181 + font-family: 'Libertinus', Georgia, Cambria, 'Times New Roman', Times, serif; 182 + } 183 + 184 + body { 185 + font-family: 'Latin Modern', Georgia, Cambria, 'Times New Roman', Times, serif; 186 + line-height: 1.8; 187 + 188 + max-width: 80ch; 189 + min-height: 100vh; 190 + overflow-x: hidden; 191 + margin: 0 auto; 192 + padding: 2rem 1.25rem; 193 + 194 + counter-reset: theorem definition sidenote-counter; 195 + 196 + color: var(--body-color); 197 + background-color: var(--body-bg-color); 198 + 199 + text-rendering: optimizeLegibility; 200 + } 201 + 202 + /* Justify and hyphenate all paragraphs */ 203 + p { 204 + text-align: justify; 205 + hyphens: auto; 206 + -webkit-hyphens: auto; 207 + -moz-hyphens: auto; 208 + margin-top: 1rem; 209 + } 210 + 211 + /* Indents paragraphs like in LaTeX documents*/ 212 + .indent-pars p { 213 + text-indent: var(--text-indent-size); 214 + } 215 + 216 + .indent-pars p.no-indent, 217 + p.no-indent { 218 + text-indent: 0; 219 + } 220 + 221 + .indent-pars ol p, 222 + .indent-pars ul p { 223 + text-indent: 0; 224 + } 225 + 226 + .indent-pars h2 + p, 227 + .indent-pars h3 + p, 228 + .indent-pars h4 + p { 229 + text-indent: 0; 230 + } 231 + 232 + /* A elements that don't have a class get default styles */ 233 + a:not([class]) { 234 + text-decoration-skip-ink: auto; 235 + } 236 + 237 + /* Make links red */ 238 + a, 239 + a:visited { 240 + color: var(--link-visited); 241 + } 242 + 243 + a:focus { 244 + outline-offset: 2px; 245 + outline: 2px solid var(--link-focus-outline); 246 + } 247 + 248 + /* Make images easier to work with */ 249 + img { 250 + max-width: 100%; 251 + height: auto; 252 + display: block; 253 + } 254 + 255 + /* Inherit fonts for inputs and buttons */ 256 + input, 257 + button, 258 + textarea, 259 + select { 260 + font: inherit; 261 + } 262 + 263 + /* Prevent textarea from overflowing */ 264 + textarea { 265 + width: 100%; 266 + } 267 + 268 + /* Natural flow and rhythm in articles by default */ 269 + article > * + * { 270 + margin-top: 1em; 271 + } 272 + 273 + /* Styles for inline code or code snippets */ 274 + code, 275 + pre, 276 + kbd { 277 + font-family: Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', 278 + monospace; 279 + font-size: 85%; 280 + hyphens: none; 281 + } 282 + pre { 283 + padding: 1rem 1.4rem; 284 + max-width: 100%; 285 + overflow: auto; 286 + border-radius: 4px; 287 + background: var(--pre-bg-color); 288 + } 289 + pre code { 290 + font-size: 95%; 291 + position: relative; 292 + } 293 + kbd { 294 + background: var(--kbd-bg-color); 295 + border: 1px solid var(--kbd-border-color); 296 + border-radius: 2px; 297 + padding: 2px 4px; 298 + font-size: 75%; 299 + } 300 + 301 + /* Better tables */ 302 + table:not(.borders-custom) { 303 + border-collapse: collapse; 304 + border-spacing: 0; 305 + width: auto; 306 + max-width: 100%; 307 + border-top: var(--border-width-thick) solid var(--table-border-color); 308 + border-bottom: var(--border-width-thick) solid var(--table-border-color); 309 + /* display: block; */ 310 + overflow-x: auto; /* does not work because element is not block */ 311 + /* white-space: nowrap; */ 312 + counter-increment: caption; 313 + } 314 + /* add bottom border on column table headings */ 315 + table:not(.borders-custom) tr > th[scope='col'] { 316 + border-bottom: var(--border-width-thin) solid var(--table-border-color); 317 + } 318 + /* add right border on row table headings */ 319 + table:not(.borders-custom) tr > th[scope='row'] { 320 + border-right: var(--border-width-thin) solid var(--table-border-color); 321 + } 322 + table:not(.borders-custom) > tbody > tr:first-child > td, 323 + table:not(.borders-custom) > tbody > tr:first-child > th { 324 + border-top: var(--border-width-thin) solid var(--table-border-color); 325 + } 326 + table:not(.borders-custom) > tbody > tr:last-child > td, 327 + table:not(.borders-custom) > tbody > tr:last-child > th { 328 + border-bottom: var(--border-width-thin) solid var(--table-border-color); 329 + } 330 + 331 + th, 332 + td { 333 + text-align: left; 334 + padding: 0.5rem; 335 + line-height: 1.1; 336 + } 337 + /* Table caption */ 338 + caption { 339 + text-align: left; 340 + font-size: 0.923em; 341 + /* border-bottom: 2pt solid #000; */ 342 + padding: 0 0.25em 0.25em; 343 + width: 100%; 344 + margin-left: 0; 345 + } 346 + 347 + caption::before { 348 + content: 'Table ' counter(caption) '. '; 349 + font-weight: bold; 350 + } 351 + 352 + /* allow scroll on the x-axis */ 353 + .scroll-wrapper { 354 + overflow-x: auto; 355 + } 356 + 357 + /* if a table is wrapped in a scroll wrapper, 358 + the table cells shouldn't wrap */ 359 + .scroll-wrapper > table td { 360 + white-space: nowrap; 361 + } 362 + 363 + /* Table custom borders */ 364 + table.borders-custom { 365 + border-collapse: collapse; 366 + border-spacing: 0; 367 + width: auto; 368 + max-width: 100%; 369 + overflow-x: auto; 370 + counter-increment: caption; 371 + } 372 + 373 + .border-top-thick { 374 + border-top: var(--border-width-thick) solid var(--table-border-color); 375 + } 376 + .border-right-thick { 377 + border-right: var(--border-width-thick) solid var(--table-border-color); 378 + } 379 + .border-bottom-thick { 380 + border-bottom: var(--border-width-thick) solid var(--table-border-color); 381 + } 382 + .border-left-thick { 383 + border-left: var(--border-width-thick) solid var(--table-border-color); 384 + } 385 + 386 + .border-top-thin { 387 + border-top: var(--border-width-thin) solid var(--table-border-color); 388 + } 389 + .border-right-thin { 390 + border-right: var(--border-width-thin) solid var(--table-border-color); 391 + } 392 + .border-bottom-thin { 393 + border-bottom: var(--border-width-thin) solid var(--table-border-color); 394 + } 395 + .border-left-thin { 396 + border-left: var(--border-width-thin) solid var(--table-border-color); 397 + } 398 + 399 + /* Table column alignment */ 400 + .col-1-l tr > :nth-child(1), 401 + .col-2-l tr > :nth-child(2), 402 + .col-3-l tr > :nth-child(3), 403 + .col-4-l tr > :nth-child(4), 404 + .col-5-l tr > :nth-child(5), 405 + .col-6-l tr > :nth-child(6), 406 + .col-7-l tr > :nth-child(7), 407 + .col-8-l tr > :nth-child(8), 408 + .col-9-l tr > :nth-child(9), 409 + .col-10-l tr > :nth-child(10), 410 + .col-11-l tr > :nth-child(11), 411 + .col-12-l tr > :nth-child(12) { 412 + text-align: left; 413 + } 414 + .col-1-c tr > :nth-child(1), 415 + .col-2-c tr > :nth-child(2), 416 + .col-3-c tr > :nth-child(3), 417 + .col-4-c tr > :nth-child(4), 418 + .col-5-c tr > :nth-child(5), 419 + .col-6-c tr > :nth-child(6), 420 + .col-7-c tr > :nth-child(7), 421 + .col-8-c tr > :nth-child(8), 422 + .col-9-c tr > :nth-child(9), 423 + .col-10-c tr > :nth-child(10), 424 + .col-11-c tr > :nth-child(11), 425 + .col-12-c tr > :nth-child(12) { 426 + text-align: center; 427 + } 428 + .col-1-r tr > :nth-child(1), 429 + .col-2-r tr > :nth-child(2), 430 + .col-3-r tr > :nth-child(3), 431 + .col-4-r tr > :nth-child(4), 432 + .col-5-r tr > :nth-child(5), 433 + .col-6-r tr > :nth-child(6), 434 + .col-7-r tr > :nth-child(7), 435 + .col-8-r tr > :nth-child(8), 436 + .col-9-r tr > :nth-child(9), 437 + .col-10-r tr > :nth-child(10), 438 + .col-11-r tr > :nth-child(11), 439 + .col-12-r tr > :nth-child(12) { 440 + text-align: right; 441 + } 442 + 443 + /* Format figure captions (based on table captions) */ 444 + figure { 445 + counter-increment: figcaption; 446 + } 447 + figcaption { 448 + text-align: left; 449 + font-size: 0.923em; 450 + padding: 0.25em 0.25em 0; 451 + width: 100%; 452 + margin-left: 0; 453 + } 454 + figcaption::before { 455 + content: 'Figure ' counter(figcaption) '. '; 456 + font-weight: bold; 457 + } 458 + 459 + /* Center align the title */ 460 + h1:first-child { 461 + text-align: center; 462 + } 463 + 464 + /* Nested ordered list for ToC */ 465 + nav ol { 466 + counter-reset: item; 467 + padding-left: 2rem; 468 + } 469 + nav li { 470 + display: block; 471 + } 472 + nav li::before { 473 + content: counters(item, '.') ' '; 474 + counter-increment: item; 475 + padding-right: 0.85rem; 476 + } 477 + 478 + /* Center definitions (most useful for display equations) */ 479 + dl dd { 480 + text-align: center; 481 + } 482 + 483 + /* Theorem */ 484 + .theorem { 485 + counter-increment: theorem; 486 + display: block; 487 + margin: 12px 0; 488 + font-style: italic; 489 + } 490 + .theorem::before { 491 + content: 'Theorem ' counter(theorem) '. '; 492 + font-weight: bold; 493 + font-style: normal; 494 + } 495 + 496 + /* Lemma */ 497 + .lemma { 498 + counter-increment: theorem; 499 + display: block; 500 + margin: 12px 0; 501 + font-style: italic; 502 + } 503 + .lemma::before { 504 + content: 'Lemma ' counter(theorem) '. '; 505 + font-weight: bold; 506 + font-style: normal; 507 + } 508 + 509 + /* Proof */ 510 + .proof { 511 + display: block; 512 + margin: 12px 0; 513 + font-style: normal; 514 + position: relative; 515 + } 516 + .proof::before { 517 + content: 'Proof. ' attr(title); 518 + font-style: italic; 519 + } 520 + .proof::after { 521 + content: '◾️'; 522 + filter: var(--proof-symbol-filter); 523 + position: absolute; 524 + right: -12px; 525 + bottom: -2px; 526 + } 527 + 528 + /* Definition */ 529 + .definition { 530 + counter-increment: definition; 531 + display: block; 532 + margin: 12px 0; 533 + font-style: normal; 534 + } 535 + .definition::before { 536 + content: 'Definition ' counter(definition) '. '; 537 + font-weight: bold; 538 + font-style: normal; 539 + } 540 + 541 + /* Center align author name, use small caps and add vertical spacing */ 542 + .author { 543 + margin: 0.85rem 0; 544 + font-variant-caps: small-caps; 545 + text-align: center; 546 + } 547 + 548 + /* Sidenotes */ 549 + 550 + .sidenote { 551 + font-size: 0.8rem; 552 + float: right; 553 + clear: right; 554 + width: 18vw; 555 + margin-right: -20vw; 556 + margin-bottom: 1em; 557 + text-indent: 0; 558 + } 559 + 560 + .sidenote.left { 561 + float: left; 562 + margin-left: -20vw; 563 + margin-bottom: 1em; 564 + text-indent: 0; 565 + } 566 + 567 + /* (WIP) add border when a sidenote is clicked on */ 568 + .sidenote:target { 569 + border: var(--sidenote-target-border-color) 1.5px solid; 570 + padding: 0 .5rem; 571 + scroll-margin-block-start: 10rem; 572 + } 573 + 574 + /* sidenote counter */ 575 + .sidenote-number { 576 + counter-increment: sidenote-counter; 577 + } 578 + 579 + .sidenote-number::after, 580 + .sidenote::before { 581 + position: relative; 582 + vertical-align: baseline; 583 + } 584 + 585 + /* add number in main content */ 586 + .sidenote-number::after { 587 + content: counter(sidenote-counter); 588 + font-size: 0.7rem; 589 + top: -0.5rem; 590 + left: 0.1rem; 591 + } 592 + 593 + /* add number in front of the sidenote */ 594 + .sidenote-number ~ .sidenote::before { 595 + content: counter(sidenote-counter) ' '; 596 + font-size: 0.7rem; 597 + top: -0.5rem; 598 + } 599 + 600 + label.sidenote-toggle:not(.sidenote-number) { 601 + display: none; 602 + } 603 + 604 + /* sidenotes inside blockquotes are indented more */ 605 + blockquote .sidenote { 606 + margin-right: -24vw; 607 + width: 18vw; 608 + } 609 + 610 + 611 + label.sidenote-toggle { 612 + display: inline; 613 + cursor: pointer; 614 + } 615 + 616 + input.sidenote-toggle { 617 + display: none; 618 + } 619 + 620 + @media (max-width: 1050px) { 621 + label.sidenote-toggle:not(.sidenote-number) { 622 + display: inline; 623 + } 624 + .sidenote { 625 + display: none; 626 + } 627 + .sidenote-toggle:checked + .sidenote { 628 + display: block; 629 + margin: 0.5rem 1.25rem 1rem 0.5rem; 630 + float: left; 631 + left: 1rem; 632 + clear: both; 633 + width: 95%; 634 + } 635 + /* tweak indentation of sidenote inside a blockquote */ 636 + blockquote .sidenote { 637 + margin-right: -25vw; 638 + width: 16vw; 639 + } 640 + } 641 + 642 + /* Make footnote text smaller and left align it (looks bad with long URLs) */ 643 + .footnotes p { 644 + text-align: left; 645 + line-height: 1.5; 646 + font-size: 85%; 647 + margin-bottom: 0.4rem; 648 + } 649 + .footnotes { 650 + border-top: 1px solid var(--footnotes-border-color); 651 + } 652 + 653 + /* Center title and paragraph */ 654 + .abstract, 655 + .abstract p { 656 + text-align: center; 657 + margin-top: 0; 658 + } 659 + .abstract { 660 + margin: 2.25rem 0; 661 + } 662 + .abstract > h2 { 663 + font-size: 1rem; 664 + margin-bottom: -0.2rem; 665 + } 666 + 667 + /* Format the LaTeX symbol correctly (a higher up, e lower) */ 668 + .latex span:nth-child(1) { 669 + text-transform: uppercase; 670 + font-size: 0.75em; 671 + vertical-align: 0.28em; 672 + margin-left: -0.48em; 673 + margin-right: -0.15em; 674 + line-height: 1ex; 675 + } 676 + 677 + .latex span:nth-child(2) { 678 + text-transform: uppercase; 679 + vertical-align: -0.5ex; 680 + margin-left: -0.1667em; 681 + margin-right: -0.125em; 682 + line-height: 1ex; 683 + } 684 + 685 + /* Heading typography */ 686 + h1 { 687 + font-size: 2.5rem; 688 + line-height: 3.25rem; 689 + margin-bottom: 1.625rem; 690 + } 691 + 692 + h2 { 693 + font-size: 1.7rem; 694 + line-height: 2rem; 695 + margin-top: 3rem; 696 + } 697 + 698 + h3 { 699 + font-size: 1.4rem; 700 + margin-top: 2.5rem; 701 + } 702 + 703 + h4 { 704 + font-size: 1.2rem; 705 + margin-top: 2rem; 706 + } 707 + 708 + h5 { 709 + font-size: 1rem; 710 + margin-top: 1.8rem; 711 + } 712 + 713 + h6 { 714 + font-size: 1rem; 715 + font-style: italic; 716 + font-weight: normal; 717 + margin-top: 2.5rem; 718 + } 719 + 720 + h3, 721 + h4, 722 + h5, 723 + h6 { 724 + line-height: 1.625rem; 725 + } 726 + 727 + h1 + h2 { 728 + margin-top: 1.625rem; 729 + } 730 + 731 + h2 + h3, 732 + h3 + h4, 733 + h4 + h5 { 734 + margin-top: 0.8rem; 735 + } 736 + 737 + h5 + h6 { 738 + margin-top: -0.8rem; 739 + } 740 + 741 + h2, 742 + h3, 743 + h4, 744 + h5, 745 + h6 { 746 + margin-bottom: 0.8rem; 747 + }
bun.lockb

This is a binary file and will not be displayed.