Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions docs/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,13 @@ Kept out on purpose: tags, signatures, private messages, ranks, badges,
polls, category subscriptions, rich-text toolbar. Posts are Markdown with a
Write/Preview toggle and a one-line hint.

RSS feeds (RSS 2.0, under `/feeds`, listed on `/feeds`): new topics, every
post, unanswered; per category new topics or every post; replies to one
topic; new posts matching a search. Items carry the whole post with absolute
links and attachments. Every page announces its own feed with `<link
rel="alternate">` and has a quiet RSS button. Feeds show only visible posts,
also to admins, and sit in Cloudflare's cache for five minutes.

Hearts and reactions: every post has a heart, always visible, one click to
like (as on Discourse). Five more emoji (👍 😄 🎉 🤔 👀) sit behind "+ React"
and only show once someone used them. Counts carry the names of who reacted
Expand Down Expand Up @@ -206,8 +213,11 @@ There is no queue to work through:
- A post the filter calls spam at confidence 0.9 or higher is not published.
Its author is told so, without a reason, and given a contact address.
- Three refused posts ban the account, so the filter can't be probed for long.
- Everything else is published: a doubtful verdict, an error, a slow answer,
or no key at all.
- The check runs before the post is stored, so other people and the feeds
never see a refused post, not even briefly.
- Everything else is published: a doubtful verdict, an error, a slow answer
(5 seconds), or no key at all. When the filter can't be reached, the admins
get an email, at most one every 6 hours (Frederik, 2026-10-16).
- A refused post is stored hidden, restorable from `/admin`, and deleted with
its files after 30 days by the hourly cron.

Expand Down
22 changes: 21 additions & 1 deletion web/client/app.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,29 @@
// The forum's only script: dismissing the banner, the composer (drag and drop,
// The forum's only script: the light/dark switch, dismissing the banner, the composer (drag and drop,
// paste, preview) and live reactions. Everything else is plain HTML forms.

const $ = <T extends Element = HTMLElement>(sel: string, root: ParentNode = document) => root.querySelector<T>(sel);
const $$ = <T extends Element = HTMLElement>(sel: string, root: ParentNode = document) => [...root.querySelectorAll<T>(sel)];

// --- Light and dark -----------------------------------------------------------

// The page follows the device until someone picks the other mode. Picking the
// device's own mode again forgets the choice, so it follows the device again.
const deviceDark = matchMedia("(prefers-color-scheme: dark)");
for (const btn of $$("[data-theme-toggle]")) {
btn.addEventListener("click", () => {
const root = document.documentElement;
const isDark = root.dataset.theme ? root.dataset.theme === "dark" : deviceDark.matches;
const next = isDark ? "light" : "dark";
if ((next === "dark") === deviceDark.matches) {
delete root.dataset.theme;
document.cookie = "nbf_theme=; path=/; max-age=0; samesite=lax";
} else {
root.dataset.theme = next;
document.cookie = `nbf_theme=${next}; path=/; max-age=31536000; samesite=lax`;
}
});
}

// --- Banner -------------------------------------------------------------------

for (const btn of $$("[data-dismiss-banner]")) {
Expand Down
38 changes: 37 additions & 1 deletion web/public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,26 @@
color-scheme: dark;
}
}
/* Picked with the sun/moon button in the header; the device setting otherwise. */
:root[data-theme="dark"] {
--blue: #8fbcef;
--heart: #e58a80;
--accent: #c98a5a;
--ink: #e8eef6;
--ink-2: #b6c4d5;
--ink-3: #93a3b8;
--rule: #1f3a5a;
--rule-strong: #e8eef6;
--wash: #0a2441;
--paper: #041a32;
--field: #061f3b;
--button: #e8eef6;
--button-hover: #8fbcef;
--danger: #ff8a8a;
--success: #6fd6a0;
--code-bg: #020f1f;
color-scheme: dark;
}

:root {
--gap: 24px;
Expand Down Expand Up @@ -124,6 +144,14 @@ button, input, select, textarea { font: inherit; color: inherit; }
.header-nav { grid-column: 10 / -1; justify-self: end; display: flex; align-items: center; gap: 8px; }
.header-icon { color: #c9d5e3; width: 40px; height: 40px; }
.header-icon:hover { color: #fff; background: rgb(255 255 255 / 8%); }
/* The theme button shows the moon in light mode and the sun in dark mode: what a click switches to. */
.theme-toggle .icon-sun { display: none; }
:root[data-theme="dark"] .theme-toggle .icon-sun { display: block; }
:root[data-theme="dark"] .theme-toggle .icon-moon { display: none; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .theme-toggle .icon-sun { display: block; }
:root:not([data-theme="light"]) .theme-toggle .icon-moon { display: none; }
}
.icon-button.nav-toggle { display: none; color: #fff; margin-left: -8px; }

.user-menu { position: relative; }
Expand Down Expand Up @@ -174,7 +202,7 @@ button, input, select, textarea { font: inherit; color: inherit; }

.main-head { display: grid; grid-template-columns: subgrid; align-items: end; margin-bottom: 32px; }
.main-head-text { grid-column: 1 / 7; }
.main-head-action { grid-column: 7 / -1; justify-self: end; }
.main-head-action { grid-column: 7 / -1; justify-self: end; display: flex; gap: 8px; }
.main.narrow > .main-head { grid-column: 1 / -1; }
.main-head h1, .topic-head h1, .auth-card h1, article.prose > h1 { font-size: 40px; line-height: 44px; font-weight: 800; letter-spacing: -0.025em; }
.lede { font-size: 18px; line-height: 28px; color: var(--ink-2); margin-top: 8px; }
Expand Down Expand Up @@ -210,6 +238,7 @@ button, input, select, textarea { font: inherit; color: inherit; }
.avatar-md { width: 40px; height: 40px; font-size: 15px; }
.avatar-sm { width: 28px; height: 28px; font-size: 12px; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .avatar { background: hsl(var(--hue) 25% 26%); color: #e8eef6; } }
:root[data-theme="dark"] .avatar { background: hsl(var(--hue) 25% 26%); color: #e8eef6; }

.cat { display: inline-flex; align-items: center; gap: 8px; font-weight: 700; color: var(--ink-2); text-decoration: none; }
.cat:hover { color: var(--ink); text-decoration: underline; }
Expand Down Expand Up @@ -313,6 +342,7 @@ button, input, select, textarea { font: inherit; color: inherit; }
.prose pre { font-family: var(--mono); background: var(--code-bg); color: var(--code-ink); padding: 16px 20px; overflow-x: auto; font-size: 15px; line-height: 24px; border-radius: var(--radius); }
.prose pre code { background: none; padding: 0; color: inherit; font-size: inherit; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .prose pre { border: 1px solid var(--rule); } }
:root[data-theme="dark"] .prose pre { border: 1px solid var(--rule); }
.prose img { border: 1px solid var(--rule); border-radius: var(--radius); }
.prose hr { border: 0; border-top: 1px solid var(--rule); margin: 24px 0; }
.prose table { border-collapse: collapse; }
Expand Down Expand Up @@ -436,12 +466,18 @@ article.prose > h1 { margin-bottom: 24px; }
.nav-open .sidebar { display: block; position: fixed; z-index: 25; top: 0; left: 0; bottom: 0; width: 288px; overflow-y: auto; padding: 24px; background: var(--paper); border-right: 1px solid var(--rule); box-shadow: 0 0 0 100vmax rgb(4 26 50 / 40%); }
}

@media (max-width: 400px) {
/* Room for the theme button next to "Sign in" on small phones. */
.brand-sub { display: none; }
}
@media (max-width: 720px) {
:root { --pad: 16px; --gap: 16px; --header: 64px; }
.header-row { grid-template-columns: minmax(0, 1fr) auto; row-gap: 8px; padding-block: 12px; }
.header-brand, .header-nav { grid-column: auto; }
.search { grid-column: 1 / -1; grid-row: 2; }
.brand-word, .brand-sub { font-size: 20px; }
.header-nav { gap: 4px; }
.theme-toggle { width: 32px; }
.shell { padding-top: 24px; }
.main, .main-head, .topic, .posts, .topic-foot, .topics { display: block; }
.main-head-action { margin-top: 16px; justify-self: start; }
Expand Down
2 changes: 1 addition & 1 deletion web/scripts/build-icons.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const NAMES = [
"plus", "heart", "smile-plus", "link", "reply", "paperclip", "eye", "eye-off", "circle-check",
"pin", "lock", "settings", "log-out", "log-in", "chevron-left", "chevron-right", "x", "menu",
"download", "file-archive", "file", "box", "image", "ban", "rotate-ccw", "trash-2", "user-round",
"search", "circle-help", "quote", "archive",
"search", "circle-help", "quote", "archive", "rss", "sun", "moon",
];

const dir = path.join(import.meta.dirname, "../node_modules/lucide-static/icons");
Expand Down
Loading
Loading