🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

Update compiled Tailwind CSS

juprodh 4112e704 604bd82d

+180 -129
+7
biome.json
··· 18 18 "recommended": true 19 19 } 20 20 }, 21 + "css": { 22 + "parser": { 23 + "cssModules": false, 24 + "allowWrongLineComments": false, 25 + "tailwindDirectives": true 26 + } 27 + }, 21 28 "javascript": { 22 29 "formatter": { 23 30 "quoteStyle": "double"
+8
bun.lock
··· 22 22 "devDependencies": { 23 23 "@atproto/dev-env": "0.3.213", 24 24 "@biomejs/biome": "^2.4.4", 25 + "@parcel/watcher": "^2.5.6", 25 26 "@tailwindcss/cli": "^4.2.1", 27 + "@tailwindcss/typography": "^0.5.16", 26 28 "@types/bun": "latest", 27 29 "@types/d3": "^7.4.3", 28 30 "@types/diff-match-patch": "^1.0.36", ··· 574 576 575 577 "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="], 576 578 579 + "@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="], 580 + 577 581 "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], 578 582 579 583 "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], ··· 757 761 "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], 758 762 759 763 "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], 764 + 765 + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], 760 766 761 767 "debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], 762 768 ··· 1113 1119 "pino-http": ["pino-http@8.6.1", "", { "dependencies": { "get-caller-file": "^2.0.5", "pino": "^8.17.1", "pino-std-serializers": "^6.2.2", "process-warning": "^3.0.0" } }, "sha512-J0hiJgUExtBXP2BjrK4VB305tHXS31sCmWJ9XJo2wPkLHa1NFPuW4V9wjG27PAc2fmBCigiNhQKpvrx+kntBPA=="], 1114 1120 1115 1121 "pino-std-serializers": ["pino-std-serializers@6.2.2", "", {}, "sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA=="], 1122 + 1123 + "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], 1116 1124 1117 1125 "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], 1118 1126
+3
package.json
··· 7 7 "dev": "bun run --watch src/server/index.ts", 8 8 "dev:firehose": "bun run --watch src/firehose/index.ts", 9 9 "build:css": "bunx @tailwindcss/cli -i public/style.css -o public/dist.css", 10 + "dev:css": "bunx @tailwindcss/cli -i public/style.css -o public/dist.css --watch", 10 11 "test": "bun test", 11 12 "test:unit": "bun test tests/lib/ tests/atproto/ tests/firehose/ tests/server/", 12 13 "test:integration": "bun test tests/integration/", ··· 23 24 "devDependencies": { 24 25 "@atproto/dev-env": "0.3.213", 25 26 "@biomejs/biome": "^2.4.4", 27 + "@parcel/watcher": "^2.5.6", 26 28 "@tailwindcss/cli": "^4.2.1", 29 + "@tailwindcss/typography": "^0.5.16", 27 30 "@types/bun": "latest", 28 31 "@types/d3": "^7.4.3", 29 32 "@types/diff-match-patch": "^1.0.36",
+2
public/style.css
··· 1 1 @import "tailwindcss"; 2 + @plugin "@tailwindcss/typography"; 3 + @source "../src";
+16
scripts/dev-full.ts
··· 13 13 const children: Subprocess[] = []; 14 14 15 15 async function main() { 16 + console.log("Building CSS (watch mode)..."); 17 + const css = spawn({ 18 + cmd: [ 19 + "bunx", 20 + "@tailwindcss/cli", 21 + "-i", 22 + "public/style.css", 23 + "-o", 24 + "public/dist.css", 25 + "--watch", 26 + ], 27 + stdout: "inherit", 28 + stderr: "inherit", 29 + }); 30 + children.push(css); 31 + 16 32 console.log("Starting test PDS (via Node)..."); 17 33 18 34 const pdsScript = resolve(import.meta.dir, "start-pds.mjs");
+4 -4
src/views/access-denied.ts
··· 19 19 20 20 let actionHtml: string; 21 21 if (hasPending) { 22 - actionHtml = `<p class="text-sm text-${THEME.textMuted}">${msg.access.pendingRequest}</p>`; 22 + actionHtml = `<p class="text-sm ${THEME.textMuted}">${msg.access.pendingRequest}</p>`; 23 23 } else if (session) { 24 24 actionHtml = `<form method="POST" action="/wiki/${wikiSlug}/-/request-access"> 25 - <button type="submit" class="px-4 py-2 bg-${THEME.accent} text-white text-sm font-medium rounded hover:bg-${THEME.accentHover}">${msg.access.requestAccess}</button> 25 + <button type="submit" class="px-4 py-2 ${THEME.accentBg} text-white text-sm font-medium rounded ${THEME.accentDarkHoverBg}">${msg.access.requestAccess}</button> 26 26 </form>`; 27 27 } else { 28 - actionHtml = `<p class="text-sm text-${THEME.textMuted}">${msg.access.loginToRequest}</p>`; 28 + actionHtml = `<p class="text-sm ${THEME.textMuted}">${msg.access.loginToRequest}</p>`; 29 29 } 30 30 31 31 return layout( ··· 33 33 ` 34 34 <div class="text-center py-12"> 35 35 <h1 class="text-2xl font-bold mb-4">${escapeHtml(wikiName)}</h1> 36 - <p class="text-${THEME.textMuted} mb-6">${msg.access.noAccess}</p> 36 + <p class="${THEME.textMuted} mb-6">${msg.access.noAccess}</p> 37 37 ${actionHtml} 38 38 </div> 39 39 `,
+8 -8
src/views/edit-note.ts
··· 19 19 ` 20 20 <form method="POST" action="/wiki/${wikiSlug}/${noteSlug}/edit" class="flex flex-col gap-4 md:h-[calc(100vh-8rem)] md:overflow-hidden"> 21 21 <div class="shrink-0"> 22 - <label for="title" class="block text-sm font-medium text-${THEME.textSecondary} mb-1">${msg.editor.title}</label> 22 + <label for="title" class="block text-sm font-medium ${THEME.textSecondary} mb-1">${msg.editor.title}</label> 23 23 <input 24 24 id="title" 25 25 name="title" 26 26 type="text" 27 27 value="${escapeHtml(noteTitle)}" 28 28 required 29 - class="w-full border border-${THEME.borderInput} rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-${THEME.accentFocus}" 29 + class="w-full border ${THEME.borderInput} rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 ${THEME.accentFocusRing}" 30 30 > 31 31 </div> 32 32 <div class="flex-1 min-h-0 pb-4"> 33 - <label for="content" class="block text-sm font-medium text-${THEME.textSecondary} mb-1">${msg.editor.content}</label> 33 + <label for="content" class="block text-sm font-medium ${THEME.textSecondary} mb-1">${msg.editor.content}</label> 34 34 <textarea 35 35 id="content" 36 36 name="content" 37 37 rows="24" 38 - class="w-full h-full ${THEME.fontMono} text-sm border border-${THEME.borderInput} rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-${THEME.accentFocus} md:resize-none" 38 + class="w-full h-full ${THEME.fontMono} text-sm border ${THEME.borderInput} rounded px-3 py-2 focus:outline-none focus:ring-2 ${THEME.accentFocusRing} md:resize-none" 39 39 >${escapeForTextarea(currentContent)}</textarea> 40 40 </div> 41 41 <div class="shrink-0"> 42 - <label for="message" class="block text-sm font-medium text-${THEME.textSecondary} mb-1">${msg.editor.editSummary}</label> 42 + <label for="message" class="block text-sm font-medium ${THEME.textSecondary} mb-1">${msg.editor.editSummary}</label> 43 43 <input 44 44 id="message" 45 45 name="message" 46 46 type="text" 47 - class="w-full border border-${THEME.borderInput} rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-${THEME.accentFocus}" 47 + class="w-full border ${THEME.borderInput} rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 ${THEME.accentFocusRing}" 48 48 placeholder="${msg.editor.editSummaryPlaceholder}" 49 49 > 50 50 </div> ··· 52 52 <div class="shrink-0 flex items-center gap-3 pb-1"> 53 53 <button 54 54 type="submit" 55 - class="px-4 py-2 bg-${THEME.accent} text-white text-sm font-medium rounded hover:bg-${THEME.accentHover}" 55 + class="px-4 py-2 ${THEME.accentBg} text-white text-sm font-medium rounded ${THEME.accentDarkHoverBg}" 56 56 >${msg.editor.save}</button> 57 - <a href="/wiki/${wikiSlug}/${noteSlug}" class="text-sm text-${THEME.textMuted} hover:underline">${msg.editor.cancel}</a> 57 + <a href="/wiki/${wikiSlug}/${noteSlug}" class="text-sm ${THEME.textMuted} hover:underline">${msg.editor.cancel}</a> 58 58 </div> 59 59 </form> 60 60 `,
+6 -6
src/views/home.ts
··· 20 20 .map( 21 21 (w) => ` 22 22 <li> 23 - <a href="/wiki/${w.slug}" class="text-${THEME.accent} hover:underline text-lg">${w.name}</a> 23 + <a href="/wiki/${w.slug}" class="${THEME.accentText} hover:underline text-lg">${w.name}</a> 24 24 <span class="ml-2 text-xs px-2 py-0.5 rounded-full ${ 25 25 w.visibility === "public" 26 - ? `bg-${THEME.statusPublicBg} text-${THEME.statusPublicText}` 27 - : `bg-${THEME.statusPrivateBg} text-${THEME.statusPrivateText}` 28 - }">${w.visibility === "public" ? msg.home.public : msg.home.private}</span><span class="ml-1 text-xs text-${THEME.textMuted}">${w.language}</span> 26 + ? `${THEME.statusPublicBg} ${THEME.statusPublicText}` 27 + : `${THEME.statusPrivateBg} ${THEME.statusPrivateText}` 28 + }">${w.visibility === "public" ? msg.home.public : msg.home.private}</span><span class="ml-1 text-xs ${THEME.textMuted}">${w.language}</span> 29 29 </li>`, 30 30 ) 31 31 .join("\n"); 32 32 33 - const createButton = `<a href="/wiki/new" class="px-3 py-1.5 bg-${THEME.accent} text-white text-sm font-medium rounded hover:bg-${THEME.accentHover}">${msg.createWiki.create}</a>`; 33 + const createButton = `<a href="/wiki/new" class="px-3 py-1.5 ${THEME.accentBg} text-white text-sm font-medium rounded ${THEME.accentDarkHoverBg}">${msg.createWiki.create}</a>`; 34 34 35 35 return layout( 36 36 msg.home.wikis, ··· 41 41 </div> 42 42 ${renderSearchBar(undefined, locale)} 43 43 <ul class="space-y-3"> 44 - ${wikiList || `<li class="text-${THEME.textMuted}">${msg.home.noWikisYet}</li>`} 44 + ${wikiList || `<li class="${THEME.textMuted}">${msg.home.noWikisYet}</li>`} 45 45 </ul> 46 46 `, 47 47 options,
+22 -22
src/views/layout.ts
··· 26 26 const targetId = "inline-search-results"; 27 27 return `<div class="relative mb-4"> 28 28 <input type="text" placeholder="${placeholder}" autocomplete="off" 29 - class="w-full px-3 py-1.5 text-sm border border-${THEME.borderDefault} rounded-lg outline-none focus:border-${THEME.accentBorder} focus:ring-1 focus:ring-${THEME.accentRing} bg-${THEME.bgSurface}" 29 + class="w-full px-3 py-1.5 text-sm border ${THEME.borderDefault} rounded-lg outline-none ${THEME.accentInputFocusBorder} focus:ring-1 ${THEME.accentSubtleRing} ${THEME.bgSurface}" 30 30 hx-get="${url}" hx-trigger="input changed delay:150ms" hx-target="#${targetId}" name="q"> 31 - <div id="${targetId}" class="absolute z-10 left-0 right-0 mt-1 bg-${THEME.bgSurface} border border-${THEME.borderDefault} rounded-lg shadow-lg max-h-64 overflow-y-auto empty:hidden"></div> 31 + <div id="${targetId}" class="absolute z-10 left-0 right-0 mt-1 ${THEME.bgSurface} border ${THEME.borderDefault} rounded-lg shadow-lg max-h-64 overflow-y-auto empty:hidden"></div> 32 32 </div>`; 33 33 } 34 34 ··· 41 41 const noteLinks = notes 42 42 .map((n) => { 43 43 const isCurrent = n.slug === options.currentNoteSlug; 44 - return `<li><a href="/wiki/${options.wikiSlug}/${n.slug}" class="${isCurrent ? `font-bold text-${THEME.accentHover}` : `text-${THEME.textSecondary} hover:text-${THEME.accent}`} block truncate text-sm">${escapeHtml(n.title)}</a></li>`; 44 + return `<li><a href="/wiki/${options.wikiSlug}/${n.slug}" class="${isCurrent ? `font-bold ${THEME.accentDarkText}` : `${THEME.textSecondary} ${THEME.accentHoverText}`} block truncate text-sm">${escapeHtml(n.title)}</a></li>`; 45 45 }) 46 46 .join("\n"); 47 47 ··· 51 51 52 52 const editLink = 53 53 options.currentNoteSlug && canEdit 54 - ? `<a href="/wiki/${options.wikiSlug}/${options.currentNoteSlug}/edit" class="block mb-2 px-3 py-1.5 bg-${THEME.accent} text-white text-sm font-medium rounded hover:bg-${THEME.accentHover} text-center">${msg.wiki.edit}</a>` 54 + ? `<a href="/wiki/${options.wikiSlug}/${options.currentNoteSlug}/edit" class="block mb-2 px-3 py-1.5 ${THEME.accentBg} text-white text-sm font-medium rounded ${THEME.accentDarkHoverBg} text-center">${msg.wiki.edit}</a>` 55 55 : ""; 56 56 57 57 const newNoteLink = canEdit 58 - ? `<a href="/wiki/${options.wikiSlug}/new" class="block mb-4 px-3 py-1.5 border border-${THEME.accent} text-${THEME.accent} text-sm font-medium rounded hover:bg-${THEME.accentLight} text-center">${msg.wiki.newNote}</a>` 58 + ? `<a href="/wiki/${options.wikiSlug}/new" class="block mb-4 px-3 py-1.5 border ${THEME.accentBorder} ${THEME.accentText} text-sm font-medium rounded ${THEME.accentLightHoverBg} text-center">${msg.wiki.newNote}</a>` 59 59 : ""; 60 60 61 61 const adminLinks = isAdmin 62 - ? `<div class="mt-4 pt-4 border-t border-${THEME.borderSubtle} space-y-1"> 63 - <a href="/wiki/${options.wikiSlug}/-/members" class="block text-xs text-${THEME.textMuted} hover:text-${THEME.accent}">${msg.access.members}</a> 64 - <a href="/wiki/${options.wikiSlug}/-/settings" class="block text-xs text-${THEME.textMuted} hover:text-${THEME.accent}">Settings</a> 62 + ? `<div class="mt-4 pt-4 border-t ${THEME.borderSubtle} space-y-1"> 63 + <a href="/wiki/${options.wikiSlug}/-/members" class="block text-xs ${THEME.textMuted} ${THEME.accentHoverText}">${msg.access.members}</a> 64 + <a href="/wiki/${options.wikiSlug}/-/settings" class="block text-xs ${THEME.textMuted} ${THEME.accentHoverText}">Settings</a> 65 65 </div>` 66 66 : ""; 67 67 ··· 71 71 ${editLink} 72 72 ${newNoteLink} 73 73 <nav> 74 - <h3 class="text-xs font-semibold text-${THEME.textMuted} uppercase tracking-wide mb-2">${msg.wiki.pages}</h3> 74 + <h3 class="text-xs font-semibold ${THEME.textMuted} uppercase tracking-wide mb-2">${msg.wiki.pages}</h3> 75 75 <ul class="space-y-1">${noteLinks}</ul> 76 76 </nav> 77 77 ${adminLinks} ··· 94 94 95 95 const session = options?.session; 96 96 const authHtml = session 97 - ? `<span class="text-sm text-${THEME.textSecondary}">${escapeHtml(session.handle)}</span> 97 + ? `<span class="text-sm ${THEME.textSecondary}">${escapeHtml(session.handle)}</span> 98 98 <form method="POST" action="/logout" class="inline"> 99 - <button type="submit" class="text-sm text-${THEME.textMuted} hover:text-${THEME.textSecondary}">${msg.nav.logout}</button> 99 + <button type="submit" class="text-sm ${THEME.textMuted} ${THEME.textSecondaryHover}">${msg.nav.logout}</button> 100 100 </form>` 101 - : `<a href="/login" class="text-sm text-${THEME.textMuted} hover:text-${THEME.textSecondary}" 101 + : `<a href="/login" class="text-sm ${THEME.textMuted} ${THEME.textSecondaryHover}" 102 102 onclick="event.preventDefault(); const h = prompt('${msg.nav.loginPrompt}'); if (h) window.location = '/login?handle=' + encodeURIComponent(h);"> 103 103 ${msg.nav.login}</a>`; 104 104 ··· 107 107 `<option value="${l}"${l === locale ? " selected" : ""}>${l.toUpperCase()}</option>`, 108 108 ).join(""); 109 109 const localePicker = `<form method="POST" action="/set-locale" class="inline"> 110 - <select name="locale" onchange="this.form.submit()" class="text-xs bg-${THEME.bgSurface} border border-${THEME.borderDefault} rounded px-1 py-0.5 cursor-pointer"> 110 + <select name="locale" onchange="this.form.submit()" class="text-xs ${THEME.bgSurface} border ${THEME.borderDefault} rounded px-1 py-0.5 cursor-pointer"> 111 111 ${localeOptions} 112 112 </select> 113 113 </form>`; ··· 118 118 <meta charset="UTF-8"> 119 119 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 120 120 <title>${title} — atwiki</title> 121 - <script src="https://cdn.tailwindcss.com?plugins=typography"></script> 121 + <link rel="stylesheet" href="/public/dist.css"> 122 122 <script src="https://unpkg.com/htmx.org@2.0.4"></script> 123 123 ${extraScripts} 124 124 </head> 125 - <body class="bg-${THEME.bgPage} text-${THEME.textPrimary} min-h-screen"> 126 - <nav class="bg-${THEME.bgSurface} border-b border-${THEME.borderDefault} px-6 py-3 flex items-center gap-4"> 127 - <a href="/" class="text-lg font-semibold text-${THEME.accent} shrink-0">atwiki</a> 128 - <div class="flex-1 text-center">${options?.wikiName ? `<a href="/wiki/${options.wikiSlug}" class="text-lg font-medium text-${THEME.textSecondary} hover:text-${THEME.accent}">${escapeHtml(options.wikiName)}</a>` : ""}</div> 125 + <body class="${THEME.bgPage} ${THEME.textPrimary} min-h-screen"> 126 + <nav class="${THEME.bgSurface} border-b ${THEME.borderDefault} px-6 py-3 flex items-center gap-4"> 127 + <a href="/" class="text-lg font-semibold ${THEME.accentText} shrink-0">atwiki</a> 128 + <div class="flex-1 text-center">${options?.wikiName ? `<a href="/wiki/${options.wikiSlug}" class="text-lg font-medium ${THEME.textSecondary} ${THEME.accentHoverText}">${escapeHtml(options.wikiName)}</a>` : ""}</div> 129 129 <a href="https://tangled.org/juprodh.bsky.social/atwiki" target="_blank" rel="noopener noreferrer" class="shrink-0" title="View source on tangled"> 130 130 <img src="/public/tangled.svg" alt="tangled" class="w-5 h-5"> 131 131 </a> ··· 137 137 <dialog id="search-modal" class="p-0 rounded-xl shadow-2xl backdrop:bg-black/50 w-full max-w-lg mt-[15vh]" onclick="if(event.target===this)this.close()"> 138 138 <div class="p-4"> 139 139 <input id="search-input" type="text" placeholder="${options?.wikiSlug ? msg.search.searchNotes : msg.search.searchWikis}" autocomplete="off" 140 - class="w-full px-4 py-2.5 text-base border border-${THEME.borderInput} rounded-lg outline-none focus:border-${THEME.accentFocus} focus:ring-2 focus:ring-${THEME.accentRing}" 140 + class="w-full px-4 py-2.5 text-base border ${THEME.borderInput} rounded-lg outline-none ${THEME.accentFocusBorder} focus:ring-2 ${THEME.accentSubtleRing}" 141 141 hx-get="/search${options?.wikiSlug ? `?wiki=${options.wikiSlug}` : ""}" 142 142 hx-trigger="input changed delay:150ms, keyup[key=='Enter']" 143 143 hx-target="#search-results" 144 144 name="q"> 145 145 </div> 146 - <div id="search-results" class="max-h-80 overflow-y-auto border-t border-${THEME.borderSubtle}"> 147 - <div class="p-4 text-sm text-${THEME.textMuted} text-center">${options?.wikiSlug ? msg.search.typeToSearchNotes : msg.search.typeToSearchWikis}</div> 146 + <div id="search-results" class="max-h-80 overflow-y-auto border-t ${THEME.borderSubtle}"> 147 + <div class="p-4 text-sm ${THEME.textMuted} text-center">${options?.wikiSlug ? msg.search.typeToSearchNotes : msg.search.typeToSearchWikis}</div> 148 148 </div> 149 149 </dialog> 150 150 <script> ··· 161 161 if (link) document.getElementById('search-modal').close(); 162 162 }); 163 163 </script> 164 - ${options?.pageTitle ? `<div class="text-center py-6 border-b border-${THEME.borderSubtle}"><h1 class="text-3xl font-bold text-${THEME.textPrimary}">${escapeHtml(options.pageTitle)}</h1></div>` : ""} 164 + ${options?.pageTitle ? `<div class="text-center py-6 border-b ${THEME.borderSubtle}"><h1 class="text-3xl font-bold ${THEME.textPrimary}">${escapeHtml(options.pageTitle)}</h1></div>` : ""} 165 165 ${options?.sidebarNotes ? renderWithSidebar(body, options) : `<main class="max-w-6xl mx-auto px-6 py-8">${body}</main>`} 166 166 </body> 167 167 </html>`;
+22 -22
src/views/members.ts
··· 12 12 function renderIdentity(did: string, profile: ProfileInfo | undefined): string { 13 13 const avatar = profile?.avatar 14 14 ? `<img src="${escapeHtml(profile.avatar)}" alt="" class="w-8 h-8 rounded-full inline-block mr-2 align-middle" />` 15 - : `<span class="w-8 h-8 rounded-full inline-block mr-2 align-middle bg-${THEME.borderDefault}"></span>`; 15 + : `<span class="w-8 h-8 rounded-full inline-block mr-2 align-middle ${THEME.bgPlaceholder}"></span>`; 16 16 17 17 const name = profile?.displayName 18 18 ? `<span class="font-medium">${escapeHtml(profile.displayName)}</span>` 19 19 : ""; 20 20 const handle = profile?.handle 21 - ? `<span class="text-${THEME.textMuted} text-xs">@${escapeHtml(profile.handle)}</span>` 22 - : `<span class="font-mono text-xs text-${THEME.textMuted}">${escapeHtml(did.slice(0, 24))}…</span>`; 21 + ? `<span class="${THEME.textMuted} text-xs">@${escapeHtml(profile.handle)}</span>` 22 + : `<span class="font-mono text-xs ${THEME.textMuted}">${escapeHtml(did.slice(0, 24))}…</span>`; 23 23 24 24 return `<span title="${escapeHtml(did)}" class="inline-flex items-center gap-1"> 25 25 ${avatar} ··· 52 52 const roleCell = isOwner 53 53 ? `<span class="text-sm">${escapeHtml(m.role)}</span>` 54 54 : `<form method="POST" action="/wiki/${wikiSlug}/-/members/${encodeURIComponent(m.did)}/change-role" class="inline-flex items-center gap-1"> 55 - <select name="role" class="text-xs border border-${THEME.borderDefault} rounded px-1 py-0.5"> 55 + <select name="role" class="text-xs border ${THEME.borderDefault} rounded px-1 py-0.5"> 56 56 ${roleOptions(m.role)} 57 57 </select> 58 - <button type="submit" class="text-xs text-${THEME.accent} hover:underline">${msg.access.saveRole}</button> 58 + <button type="submit" class="text-xs ${THEME.accentText} hover:underline">${msg.access.saveRole}</button> 59 59 </form>`; 60 60 const removeButton = isOwner 61 - ? `<span class="text-xs text-${THEME.textMuted}" title="${msg.access.cannotRemoveOwner}">—</span>` 61 + ? `<span class="text-xs ${THEME.textMuted}" title="${msg.access.cannotRemoveOwner}">—</span>` 62 62 : `<form method="POST" action="/wiki/${wikiSlug}/-/members/${encodeURIComponent(m.did)}/remove" class="inline"> 63 - <button type="submit" class="text-xs text-${THEME.errorText} hover:underline">${msg.access.remove}</button> 63 + <button type="submit" class="text-xs ${THEME.errorText} hover:underline">${msg.access.remove}</button> 64 64 </form>`; 65 - return `<tr class="border-b border-${THEME.borderSubtle}"> 65 + return `<tr class="border-b ${THEME.borderSubtle}"> 66 66 <td class="py-2 pr-4">${renderIdentity(m.did, profiles.get(m.did))}</td> 67 67 <td class="py-2 pr-4">${roleCell}</td> 68 - <td class="py-2 pr-4 text-sm text-${THEME.textMuted}">${escapeHtml(m.created_at)}</td> 68 + <td class="py-2 pr-4 text-sm ${THEME.textMuted}">${escapeHtml(m.created_at)}</td> 69 69 <td class="py-2 text-sm">${removeButton}</td> 70 70 </tr>`; 71 71 }) ··· 73 73 74 74 const requestRows = requests 75 75 .map( 76 - (r) => `<tr class="border-b border-${THEME.borderSubtle}"> 76 + (r) => `<tr class="border-b ${THEME.borderSubtle}"> 77 77 <td class="py-2 pr-4">${renderIdentity(r.did, profiles.get(r.did))}</td> 78 - <td class="py-2 pr-4 text-sm text-${THEME.textMuted}">${escapeHtml(r.created_at)}</td> 78 + <td class="py-2 pr-4 text-sm ${THEME.textMuted}">${escapeHtml(r.created_at)}</td> 79 79 <td class="py-2 text-sm"> 80 80 <form method="POST" action="/wiki/${wikiSlug}/-/members/${encodeURIComponent(r.did)}/approve" class="inline-flex items-center gap-2"> 81 - <select name="role" class="text-xs border border-${THEME.borderDefault} rounded px-1 py-0.5"> 81 + <select name="role" class="text-xs border ${THEME.borderDefault} rounded px-1 py-0.5"> 82 82 <option value="contributor">contributor</option> 83 83 <option value="admin">admin</option> 84 84 <option value="viewer">viewer</option> 85 85 </select> 86 - <button type="submit" class="text-xs text-${THEME.accent} hover:underline">${msg.access.approve}</button> 86 + <button type="submit" class="text-xs ${THEME.accentText} hover:underline">${msg.access.approve}</button> 87 87 </form> 88 88 </td> 89 89 </tr>`, ··· 97 97 98 98 <table class="w-full mb-8"> 99 99 <thead> 100 - <tr class="border-b border-${THEME.borderDefault} text-left text-xs font-semibold text-${THEME.textMuted} uppercase tracking-wide"> 100 + <tr class="border-b ${THEME.borderDefault} text-left text-xs font-semibold ${THEME.textMuted} uppercase tracking-wide"> 101 101 <th class="pb-2 pr-4">${msg.access.member}</th> 102 102 <th class="pb-2 pr-4">${msg.access.role}</th> 103 103 <th class="pb-2 pr-4">${msg.access.joined}</th> ··· 105 105 </tr> 106 106 </thead> 107 107 <tbody> 108 - ${memberRows || `<tr><td colspan="4" class="py-4 text-sm text-${THEME.textMuted}">${msg.access.noMembers}</td></tr>`} 108 + ${memberRows || `<tr><td colspan="4" class="py-4 text-sm ${THEME.textMuted}">${msg.access.noMembers}</td></tr>`} 109 109 </tbody> 110 110 </table> 111 111 112 112 <h2 class="text-lg font-semibold mb-4">${msg.access.pendingRequests}</h2> 113 113 <table class="w-full mb-10"> 114 114 <thead> 115 - <tr class="border-b border-${THEME.borderDefault} text-left text-xs font-semibold text-${THEME.textMuted} uppercase tracking-wide"> 115 + <tr class="border-b ${THEME.borderDefault} text-left text-xs font-semibold ${THEME.textMuted} uppercase tracking-wide"> 116 116 <th class="pb-2 pr-4">${msg.access.member}</th> 117 117 <th class="pb-2 pr-4">${msg.access.requested}</th> 118 118 <th class="pb-2"></th> 119 119 </tr> 120 120 </thead> 121 121 <tbody> 122 - ${requestRows || `<tr><td colspan="3" class="py-4 text-sm text-${THEME.textMuted}">${msg.access.noRequests}</td></tr>`} 122 + ${requestRows || `<tr><td colspan="3" class="py-4 text-sm ${THEME.textMuted}">${msg.access.noRequests}</td></tr>`} 123 123 </tbody> 124 124 </table> 125 125 126 126 <h2 class="text-lg font-semibold mb-4">${msg.access.addMember}</h2> 127 127 <form method="POST" action="/wiki/${wikiSlug}/-/members/add" class="flex items-end gap-3 flex-wrap"> 128 128 <div class="flex flex-col gap-1"> 129 - <label class="text-xs font-medium text-${THEME.textMuted}">${msg.access.addMemberLabel}</label> 129 + <label class="text-xs font-medium ${THEME.textMuted}">${msg.access.addMemberLabel}</label> 130 130 <input type="text" name="did" required autocomplete="off" 131 131 placeholder="${msg.access.addMemberPlaceholder}" 132 - class="px-3 py-1.5 text-sm border border-${THEME.borderInput} rounded focus:outline-none focus:border-${THEME.accentBorder} w-72" /> 132 + class="px-3 py-1.5 text-sm border ${THEME.borderInput} rounded focus:outline-none ${THEME.accentInputFocusBorder} w-72" /> 133 133 </div> 134 134 <div class="flex flex-col gap-1"> 135 - <label class="text-xs font-medium text-${THEME.textMuted}">${msg.access.role}</label> 136 - <select name="role" class="text-sm border border-${THEME.borderInput} rounded px-2 py-1.5 focus:outline-none focus:border-${THEME.accentBorder}"> 135 + <label class="text-xs font-medium ${THEME.textMuted}">${msg.access.role}</label> 136 + <select name="role" class="text-sm border ${THEME.borderInput} rounded px-2 py-1.5 focus:outline-none ${THEME.accentInputFocusBorder}"> 137 137 <option value="contributor">contributor</option> 138 138 <option value="admin">admin</option> 139 139 <option value="viewer">viewer</option> 140 140 </select> 141 141 </div> 142 142 <button type="submit" 143 - class="px-4 py-1.5 bg-${THEME.accent} text-white text-sm font-medium rounded hover:opacity-90"> 143 + class="px-4 py-1.5 ${THEME.accentBg} text-white text-sm font-medium rounded hover:opacity-90"> 144 144 ${msg.access.add} 145 145 </button> 146 146 </form>
+10 -10
src/views/new-note.ts
··· 18 18 const msg = t(locale); 19 19 20 20 const errorHtml = options?.error 21 - ? `<div class="mb-4 p-3 bg-${THEME.errorBg} border border-${THEME.errorBorder} text-${THEME.errorText} text-sm rounded">${escapeHtml(options.error)}</div>` 21 + ? `<div class="mb-4 p-3 ${THEME.errorBg} border ${THEME.errorBorder} ${THEME.errorText} text-sm rounded">${escapeHtml(options.error)}</div>` 22 22 : ""; 23 23 24 24 const titleValue = escapeHtml(options?.titleValue ?? ""); ··· 31 31 return layout( 32 32 `${msg.wiki.newNote} — ${wikiName}`, 33 33 ` 34 - <h1 class="text-3xl font-bold text-${THEME.textPrimary} mb-6">${msg.wiki.newNote}</h1> 34 + <h1 class="text-3xl font-bold ${THEME.textPrimary} mb-6">${msg.wiki.newNote}</h1> 35 35 ${errorHtml} 36 36 <form method="POST" action="/wiki/${wikiSlug}/new" class="space-y-4"> 37 37 <div> 38 - <label for="title" class="block text-sm font-medium text-${THEME.textSecondary} mb-1">${msg.editor.title}</label> 38 + <label for="title" class="block text-sm font-medium ${THEME.textSecondary} mb-1">${msg.editor.title}</label> 39 39 <input 40 40 id="title" 41 41 name="title" 42 42 type="text" 43 43 value="${titleValue}" 44 44 required 45 - class="w-full border border-${THEME.borderInput} rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-${THEME.accentFocus}" 45 + class="w-full border ${THEME.borderInput} rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 ${THEME.accentFocusRing}" 46 46 placeholder="${msg.editor.titlePlaceholder}" 47 47 > 48 48 </div> 49 49 <div> 50 - <label for="content" class="block text-sm font-medium text-${THEME.textSecondary} mb-1">${msg.editor.content}</label> 50 + <label for="content" class="block text-sm font-medium ${THEME.textSecondary} mb-1">${msg.editor.content}</label> 51 51 <textarea 52 52 id="content" 53 53 name="content" 54 54 rows="16" 55 - class="w-full ${THEME.fontMono} text-sm border border-${THEME.borderInput} rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-${THEME.accentFocus}" 55 + class="w-full ${THEME.fontMono} text-sm border ${THEME.borderInput} rounded px-3 py-2 focus:outline-none focus:ring-2 ${THEME.accentFocusRing}" 56 56 placeholder="${msg.editor.newNotePlaceholder}" 57 57 >${escapedContent}</textarea> 58 58 </div> 59 59 <div> 60 - <label for="message" class="block text-sm font-medium text-${THEME.textSecondary} mb-1">${msg.editor.editSummary}</label> 60 + <label for="message" class="block text-sm font-medium ${THEME.textSecondary} mb-1">${msg.editor.editSummary}</label> 61 61 <input 62 62 id="message" 63 63 name="message" 64 64 type="text" 65 - class="w-full border border-${THEME.borderInput} rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-${THEME.accentFocus}" 65 + class="w-full border ${THEME.borderInput} rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 ${THEME.accentFocusRing}" 66 66 placeholder="${msg.editor.editSummaryPlaceholder}" 67 67 > 68 68 </div> ··· 70 70 <div class="flex items-center gap-3"> 71 71 <button 72 72 type="submit" 73 - class="px-4 py-2 bg-${THEME.accent} text-white text-sm font-medium rounded hover:bg-${THEME.accentHover}" 73 + class="px-4 py-2 ${THEME.accentBg} text-white text-sm font-medium rounded ${THEME.accentDarkHoverBg}" 74 74 >${msg.editor.createNote}</button> 75 - <a href="/wiki/${wikiSlug}" class="text-sm text-${THEME.textMuted} hover:underline">${msg.editor.cancel}</a> 75 + <a href="/wiki/${wikiSlug}" class="text-sm ${THEME.textMuted} hover:underline">${msg.editor.cancel}</a> 76 76 </div> 77 77 </form> 78 78 `,
+10 -10
src/views/new-wiki.ts
··· 28 28 const msg = t(locale); 29 29 30 30 const errorHtml = options?.error 31 - ? `<div class="mb-4 p-3 bg-${THEME.errorBg} border border-${THEME.errorBorder} text-${THEME.errorText} text-sm rounded">${escapeHtml(options.error)}</div>` 31 + ? `<div class="mb-4 p-3 ${THEME.errorBg} border ${THEME.errorBorder} ${THEME.errorText} text-sm rounded">${escapeHtml(options.error)}</div>` 32 32 : ""; 33 33 34 34 const nameValue = escapeHtml(options?.nameValue ?? ""); ··· 43 43 return layout( 44 44 msg.createWiki.heading, 45 45 ` 46 - <h1 class="text-3xl font-bold text-${THEME.textPrimary} mb-6">${msg.createWiki.heading}</h1> 46 + <h1 class="text-3xl font-bold ${THEME.textPrimary} mb-6">${msg.createWiki.heading}</h1> 47 47 ${errorHtml} 48 48 <form method="POST" action="/wiki/new" class="space-y-4 max-w-lg"> 49 49 <div> 50 - <label for="name" class="block text-sm font-medium text-${THEME.textSecondary} mb-1">${msg.createWiki.name}</label> 50 + <label for="name" class="block text-sm font-medium ${THEME.textSecondary} mb-1">${msg.createWiki.name}</label> 51 51 <input 52 52 id="name" 53 53 name="name" 54 54 type="text" 55 55 value="${nameValue}" 56 56 required 57 - class="w-full border border-${THEME.borderInput} rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-${THEME.accentFocus}" 57 + class="w-full border ${THEME.borderInput} rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 ${THEME.accentFocusRing}" 58 58 placeholder="${msg.createWiki.namePlaceholder}" 59 59 > 60 60 </div> 61 61 <div> 62 - <label for="language" class="block text-sm font-medium text-${THEME.textSecondary} mb-1">${msg.createWiki.language}</label> 62 + <label for="language" class="block text-sm font-medium ${THEME.textSecondary} mb-1">${msg.createWiki.language}</label> 63 63 <select 64 64 id="language" 65 65 name="language" 66 66 required 67 - class="w-full border border-${THEME.borderInput} rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-${THEME.accentFocus}" 67 + class="w-full border ${THEME.borderInput} rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 ${THEME.accentFocusRing}" 68 68 > 69 69 <option value="">—</option> 70 70 ${langOptions} 71 71 </select> 72 72 </div> 73 73 <div> 74 - <label for="visibility" class="block text-sm font-medium text-${THEME.textSecondary} mb-1">${msg.createWiki.visibility}</label> 74 + <label for="visibility" class="block text-sm font-medium ${THEME.textSecondary} mb-1">${msg.createWiki.visibility}</label> 75 75 <select 76 76 id="visibility" 77 77 name="visibility" 78 78 required 79 - class="w-full border border-${THEME.borderInput} rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-${THEME.accentFocus}" 79 + class="w-full border ${THEME.borderInput} rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 ${THEME.accentFocusRing}" 80 80 > 81 81 <option value="public"${selectedVis === "public" ? " selected" : ""}>${msg.home.public}</option> 82 82 <option value="private"${selectedVis === "private" ? " selected" : ""}>${msg.home.private}</option> ··· 85 85 <div class="flex items-center gap-3"> 86 86 <button 87 87 type="submit" 88 - class="px-4 py-2 bg-${THEME.accent} text-white text-sm font-medium rounded hover:bg-${THEME.accentHover}" 88 + class="px-4 py-2 ${THEME.accentBg} text-white text-sm font-medium rounded ${THEME.accentDarkHoverBg}" 89 89 >${msg.createWiki.create}</button> 90 - <a href="/" class="text-sm text-${THEME.textMuted} hover:underline">${msg.createWiki.cancel}</a> 90 + <a href="/" class="text-sm ${THEME.textMuted} hover:underline">${msg.createWiki.cancel}</a> 91 91 </div> 92 92 </form> 93 93 `,
+8 -8
src/views/search-results.ts
··· 11 11 const msg = t(locale); 12 12 13 13 if (results.length === 0) { 14 - return `<div class="p-4 text-sm text-${THEME.textMuted} text-center">${escapeHtml(fmt(msg.search.noNotesMatching, { query }))}</div>`; 14 + return `<div class="p-4 text-sm ${THEME.textMuted} text-center">${escapeHtml(fmt(msg.search.noNotesMatching, { query }))}</div>`; 15 15 } 16 16 17 17 return results 18 18 .map( 19 19 (r) => 20 - `<a href="/wiki/${r.wiki_slug}/${r.slug}" class="search-result block px-4 py-2 hover:bg-${THEME.accentLight} focus:bg-${THEME.accentLight} outline-none" data-search-result> 21 - <div class="text-sm font-medium text-${THEME.textPrimary}">${escapeHtml(r.title)}</div> 22 - <div class="text-xs text-${THEME.textMuted}">${escapeHtml(r.slug)}</div> 20 + `<a href="/wiki/${r.wiki_slug}/${r.slug}" class="search-result block px-4 py-2 ${THEME.accentLightHoverBg} ${THEME.accentLightFocusBg} outline-none" data-search-result> 21 + <div class="text-sm font-medium ${THEME.textPrimary}">${escapeHtml(r.title)}</div> 22 + <div class="text-xs ${THEME.textMuted}">${escapeHtml(r.slug)}</div> 23 23 </a>`, 24 24 ) 25 25 .join(""); ··· 33 33 const msg = t(locale); 34 34 35 35 if (results.length === 0) { 36 - return `<div class="p-4 text-sm text-${THEME.textMuted} text-center">${escapeHtml(fmt(msg.search.noWikisMatching, { query }))}</div>`; 36 + return `<div class="p-4 text-sm ${THEME.textMuted} text-center">${escapeHtml(fmt(msg.search.noWikisMatching, { query }))}</div>`; 37 37 } 38 38 39 39 return results 40 40 .map( 41 41 (r) => 42 - `<a href="/wiki/${r.slug}" class="search-result block px-4 py-2 hover:bg-${THEME.accentLight} focus:bg-${THEME.accentLight} outline-none" data-search-result> 43 - <div class="text-sm font-medium text-${THEME.textPrimary}">${escapeHtml(r.name)}</div> 44 - <div class="text-xs text-${THEME.textMuted}">${escapeHtml(r.slug)} · ${r.visibility}</div> 42 + `<a href="/wiki/${r.slug}" class="search-result block px-4 py-2 ${THEME.accentLightHoverBg} ${THEME.accentLightFocusBg} outline-none" data-search-result> 43 + <div class="text-sm font-medium ${THEME.textPrimary}">${escapeHtml(r.name)}</div> 44 + <div class="text-xs ${THEME.textMuted}">${escapeHtml(r.slug)} · ${r.visibility}</div> 45 45 </a>`, 46 46 ) 47 47 .join("");
+10 -10
src/views/settings.ts
··· 13 13 options: SettingsPageOptions, 14 14 ): string { 15 15 const errorBanner = options.error 16 - ? `<p class="mb-4 text-sm text-${THEME.errorText} bg-${THEME.errorBg} border border-${THEME.errorBorder} rounded px-3 py-2">${escapeHtml(options.error)}</p>` 16 + ? `<p class="mb-4 text-sm ${THEME.errorText} ${THEME.errorBg} border ${THEME.errorBorder} rounded px-3 py-2">${escapeHtml(options.error)}</p>` 17 17 : ""; 18 18 const dangerZone = isOwner 19 - ? `<section class="mt-10 border border-${THEME.errorBorder} rounded-lg p-6 bg-${THEME.errorBg}"> 20 - <h2 class="text-lg font-semibold text-${THEME.errorText} mb-4">Danger zone</h2> 21 - <p class="text-sm text-${THEME.textSecondary} mb-4"> 19 + ? `<section class="mt-10 border ${THEME.errorBorder} rounded-lg p-6 ${THEME.errorBg}"> 20 + <h2 class="text-lg font-semibold ${THEME.errorText} mb-4">Danger zone</h2> 21 + <p class="text-sm ${THEME.textSecondary} mb-4"> 22 22 Permanently delete this wiki and all its notes. This cannot be undone. 23 23 </p> 24 24 <form method="POST" action="/wiki/${wikiSlug}/-/delete" 25 25 onsubmit="return document.getElementById('confirm-name').value === '${escapeHtml(wikiName)}' 26 26 || (alert('Wiki name does not match.'), false)"> 27 - <label class="block text-sm font-medium text-${THEME.textSecondary} mb-1"> 27 + <label class="block text-sm font-medium ${THEME.textSecondary} mb-1"> 28 28 Type <strong>${escapeHtml(wikiName)}</strong> to confirm 29 29 </label> 30 30 <input id="confirm-name" type="text" name="confirm" autocomplete="off" 31 - class="block w-full max-w-sm px-3 py-1.5 text-sm border border-${THEME.borderInput} rounded mb-3 focus:outline-none focus:border-${THEME.accentBorder}" 31 + class="block w-full max-w-sm px-3 py-1.5 text-sm border ${THEME.borderInput} rounded mb-3 focus:outline-none ${THEME.accentInputFocusBorder}" 32 32 placeholder="${escapeHtml(wikiName)}" /> 33 33 <button type="submit" 34 - class="px-4 py-2 bg-${THEME.errorText} text-white text-sm font-medium rounded hover:opacity-90"> 34 + class="px-4 py-2 ${THEME.errorActionBg} text-white text-sm font-medium rounded hover:opacity-90"> 35 35 Delete wiki 36 36 </button> 37 37 </form> 38 38 </section>` 39 - : `<section class="mt-10 border border-${THEME.borderDefault} rounded-lg p-6"> 40 - <h2 class="text-lg font-semibold text-${THEME.textMuted} mb-2">Danger zone</h2> 41 - <p class="text-sm text-${THEME.textMuted}">Only the wiki owner can delete this wiki.</p> 39 + : `<section class="mt-10 border ${THEME.borderDefault} rounded-lg p-6"> 40 + <h2 class="text-lg font-semibold ${THEME.textMuted} mb-2">Danger zone</h2> 41 + <p class="text-sm ${THEME.textMuted}">Only the wiki owner can delete this wiki.</p> 42 42 </section>`; 43 43 44 44 return layout(
+39 -24
src/views/theme.ts
··· 5 5 * Future: user-selectable themes swap these values (e.g. accent color, font). 6 6 */ 7 7 8 - // --- Colors (Tailwind class fragments, e.g. "indigo-600") --- 9 - 10 8 export const THEME = { 11 - // Brand / accent 12 - accent: "indigo-600", 13 - accentHover: "indigo-700", 14 - accentLight: "indigo-50", 15 - accentFocus: "indigo-500", 16 - accentBorder: "indigo-400", 17 - accentRing: "indigo-200", 9 + // Accent / brand (indigo-600) 10 + accentBg: "bg-indigo-600", 11 + accentText: "text-indigo-600", 12 + accentBorder: "border-indigo-600", 13 + accentHoverText: "hover:text-indigo-600", 14 + 15 + // Accent dark (indigo-700) 16 + accentDarkHoverBg: "hover:bg-indigo-700", 17 + accentDarkText: "text-indigo-700", 18 + 19 + // Accent light (indigo-50) 20 + accentLightHoverBg: "hover:bg-indigo-50", 21 + accentLightFocusBg: "focus:bg-indigo-50", 22 + 23 + // Focus / ring states 24 + accentFocusRing: "focus:ring-indigo-500", 25 + accentFocusBorder: "focus:border-indigo-500", 26 + accentInputFocusBorder: "focus:border-indigo-400", 27 + accentSubtleRing: "focus:ring-indigo-200", 18 28 19 29 // Text hierarchy 20 - textPrimary: "gray-900", 21 - textSecondary: "gray-700", 22 - textMuted: "gray-500", 30 + textPrimary: "text-gray-900", 31 + textSecondary: "text-gray-700", 32 + textMuted: "text-gray-500", 33 + textSecondaryHover: "hover:text-gray-700", 23 34 24 35 // Backgrounds 25 - bgPage: "gray-50", 26 - bgSurface: "white", 36 + bgPage: "bg-gray-50", 37 + bgSurface: "bg-white", 38 + bgPlaceholder: "bg-gray-200", 27 39 28 40 // Borders 29 - borderDefault: "gray-200", 30 - borderInput: "gray-300", 31 - borderSubtle: "gray-100", 41 + borderDefault: "border-gray-200", 42 + borderInput: "border-gray-300", 43 + borderSubtle: "border-gray-100", 32 44 33 45 // Status 34 - statusPublicBg: "green-100", 35 - statusPublicText: "green-700", 36 - statusPrivateBg: "yellow-100", 37 - statusPrivateText: "yellow-700", 38 - errorBg: "red-50", 39 - errorBorder: "red-200", 40 - errorText: "red-700", 46 + statusPublicBg: "bg-green-100", 47 + statusPublicText: "text-green-700", 48 + statusPrivateBg: "bg-yellow-100", 49 + statusPrivateText: "text-yellow-700", 50 + 51 + // Error 52 + errorBg: "bg-red-50", 53 + errorBorder: "border-red-200", 54 + errorText: "text-red-700", 55 + errorActionBg: "bg-red-700", 41 56 42 57 // Fonts 43 58 fontMono: "font-mono",
+5 -5
src/views/wiki.ts
··· 21 21 .map( 22 22 (n) => ` 23 23 <li> 24 - <a href="/wiki/${wikiSlug}/${n.slug}" class="text-${THEME.accent} hover:underline">${n.title}</a> 24 + <a href="/wiki/${wikiSlug}/${n.slug}" class="${THEME.accentText} hover:underline">${n.title}</a> 25 25 </li>`, 26 26 ) 27 27 .join("\n"); 28 28 29 29 const langBadge = wikiLanguage 30 - ? ` <span class="ml-2 text-xs text-${THEME.textMuted}">${wikiLanguage}</span>` 30 + ? ` <span class="ml-2 text-xs ${THEME.textMuted}">${wikiLanguage}</span>` 31 31 : ""; 32 32 33 33 const isAdmin = 34 34 options?.accessLevel === "admin" || options?.accessLevel === "edit"; 35 35 const newNoteButton = isAdmin 36 - ? `<a href="/wiki/${wikiSlug}/new" class="px-3 py-1.5 bg-${THEME.accent} text-white text-sm font-medium rounded hover:bg-${THEME.accentHover}">${msg.wiki.newNote}</a>` 36 + ? `<a href="/wiki/${wikiSlug}/new" class="px-3 py-1.5 ${THEME.accentBg} text-white text-sm font-medium rounded ${THEME.accentDarkHoverBg}">${msg.wiki.newNote}</a>` 37 37 : ""; 38 38 39 39 return layout( 40 40 wikiName, 41 41 ` 42 42 <h1 class="text-2xl font-bold mb-2">${wikiName}${langBadge}</h1> 43 - <p class="text-${THEME.textMuted} mb-6">/${wikiSlug}</p> 43 + <p class="${THEME.textMuted} mb-6">/${wikiSlug}</p> 44 44 <div class="flex items-center justify-between mb-3"> 45 45 <h2 class="text-lg font-semibold">${msg.wiki.notes}</h2> 46 46 ${newNoteButton} 47 47 </div> 48 48 <ul class="space-y-2"> 49 - ${noteList || `<li class="text-${THEME.textMuted}">${msg.wiki.noNotesYet}</li>`} 49 + ${noteList || `<li class="${THEME.textMuted}">${msg.wiki.noNotesYet}</li>`} 50 50 </ul> 51 51 `, 52 52 { ...options, wikiName, wikiSlug },