Compare commits

...
Author SHA1 Message Date
Kristian Wind 8d42f34065 Merge pull request #50 from kristianwind/charcoal-apple-colors
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m47s
CI / Docker image builds (push) Successful in 1m48s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 14m30s
Match Charcoal theme to Apple Notes' exact colours
2026-09-03 17:51:16 +02:00
kristianwindandClaude Opus 4.8 905ceebc06 fix(theme): match Charcoal to Apple Notes' sampled colours
Kristian sent the exact values; the first Charcoal was a neutral grey guess
and missed them. Now: three panel greys, lightest sidebar (#2d3133) to
darkest editor (#1e1e1e) with the list (#242b2e) between — the reverse of
the usual sunken-is-darkest order, so the editor takes its own --editor-bg
rather than sharing the list's surface. The accent is amber: the selected
menu item is solid #ffce35 with dark text, a selected note is gold #a78c26
with dark text, and the section headings drop to a quiet grey (#8a8e90).

Each per-element colour is a fallback token only Charcoal sets, so every
other theme reads through to the shared token unchanged. Verified the
computed values land on Kristian's samples exactly.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 17:42:55 +02:00
Kristian Wind 87c557941a Merge pull request #49 from kristianwind/theme-charcoal-polish
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m47s
CI / Docker image builds (push) Successful in 1m47s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 14m25s
Charcoal theme, elegant selection, rounded note list, confirm sign-out
2026-09-03 16:37:44 +02:00
Kristian Wind 0e1e803486 Merge pull request #48 from kristianwind/totp-qr
2FA: show a scannable QR code when enabling two-factor auth
2026-09-03 16:37:36 +02:00
kristianwindandClaude Opus 4.8 f62ffc8d92 feat(2fa): show a scannable QR code when enabling two-factor auth
The setup screen showed the secret and the otpauth URI as text and asked
you to scan — with nothing to scan. It renders the URI as a QR now, an SVG
(one dark path on a white card, so it reads in either theme) generated from
the barcode encoder already in the module graph via pquerna/otp. The secret
stays below it for a device that cannot scan.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 16:27:11 +02:00
7 changed files with 135 additions and 33 deletions
+2 -2
View File
@@ -3,8 +3,10 @@ module github.com/kristianwind/verdande
go 1.26.3
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc
github.com/coder/websocket v1.8.15
github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/emersion/go-message v0.18.2
github.com/go-chi/chi/v5 v5.3.1
github.com/go-webauthn/webauthn v0.17.4
github.com/pquerna/otp v1.5.0
@@ -14,9 +16,7 @@ require (
)
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
+36
View File
@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/boombuler/barcode/qr"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
)
@@ -50,6 +51,41 @@ func NewTOTPSecret(issuer, account string) (secret, uri string, err error) {
return key.Secret(), key.URL(), nil
}
// TOTPQRSVG renders the otpauth:// URI as a QR code, as an SVG a phone camera can
// read. SVG rather than a PNG data URI so it stays crisp at any size and adds no
// base64 blob to the page — one dark path on a white square, which is what a scanner
// needs whatever theme the page is in.
//
// The barcode encoder is already in the module graph (pquerna/otp draws on it), so
// this borrows a dependency rather than adding one.
func TOTPQRSVG(uri string) (string, error) {
code, err := qr.Encode(uri, qr.M, qr.Auto)
if err != nil {
return "", fmt.Errorf("auth: encode totp qr: %w", err)
}
b := code.Bounds()
n := b.Dx()
// A four-module quiet zone all round, which the spec requires and scanners rely
// on — without it the code runs to the edge and a reader cannot find its border.
const quiet = 4
size := n + quiet*2
var sb strings.Builder
fmt.Fprintf(&sb, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" shape-rendering="crispEdges">`, size, size)
sb.WriteString(`<rect width="100%" height="100%" fill="#ffffff"/><path fill="#111312" d="`)
for y := 0; y < n; y++ {
for x := 0; x < n; x++ {
// Dark modules are black; the red channel is 0 for those and full for the
// light ones. One 1×1 rect per dark module, merged into a single path.
if r, _, _, _ := code.At(b.Min.X+x, b.Min.Y+y).RGBA(); r == 0 {
fmt.Fprintf(&sb, "M%d %dh1v1h-1z", x+quiet, y+quiet)
}
}
}
sb.WriteString(`"/></svg>`)
return sb.String(), nil
}
// TOTPURI rebuilds the otpauth:// URI for an existing secret, for showing the QR
// code again before enrolment is confirmed.
func TOTPURI(secret, issuer, account string) string {
+9 -1
View File
@@ -17,6 +17,9 @@ import (
type totpSetupResponse struct {
Secret string `json:"secret"`
URI string `json:"uri"`
// The URI as a scannable QR code (SVG). The secret and URI stay for typing in by
// hand on a device that cannot scan.
QR string `json:"qr"`
}
func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
@@ -54,7 +57,12 @@ func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "store totp secret", err)
return
}
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri})
qrSVG, err := auth.TOTPQRSVG(uri)
if err != nil {
s.internal(w, r, "render totp qr", err)
return
}
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri, QR: qrSVG})
}
type totpConfirmResponse struct {
+29 -18
View File
@@ -210,27 +210,38 @@
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.4);
}
/* Kul — a neutral dark grey, the greys of Apple Notes rather than the default's
faint green cast. The same green accent, so it still reads as verdande; only the
grounds, surfaces and inks turn to plain grey. */
/* Kul — the greys of Apple Notes' dark mode, to Kristian's own sampled values.
Three panel greys, lightest sidebar to darkest editor — the reverse of the usual
sunken-is-darkest order, which is why the editor gets its own --editor-bg rather
than sharing --surface-raised with the list. The accent turns amber, and the
selected menu item, selected note and section headings each get a fallback token
so this theme can hit its colours without touching any other. */
[data-theme='charcoal'] {
--ground: #1c1c1c;
--surface: #232323;
--surface-raised: #2b2b2b;
--surface-sunken: #171717;
--line: #363636;
--line-strong: #474747;
--ground: #1e1e1e;
--surface: #2a3033;
--surface-raised: #242b2e; /* the note list */
--surface-sunken: #2d3133; /* the menu / sidebar — lightest here */
--line: #34393b;
--line-strong: #414749;
--ink: #ededed;
--ink-muted: #a4a4a4;
/* Light enough to clear 4.5:1 against the ground — the quiet tier of dates and
counts is the first to disappear if it is too dim. */
--ink-faint: #8e8e8e;
--ink: #ffffff;
--ink-muted: #dcdcdc;
--ink-faint: #9ba0a2;
--accent: #5fbf8f;
--accent-hover: #6fcf9f;
--accent-sunken: #23332b80;
--accent-ink: #0d1411;
/* Amber, Apple's Notes accent. The bright value is the selected menu item; the
dark text rides on top of it. */
--accent: #ffce35;
--accent-hover: #ffd75a;
--accent-sunken: #ffce35;
--accent-ink: #1e1e1e;
/* Per-element, and only here — every other theme falls back to the shared token
in the var() calls that read these. */
--editor-bg: #1e1e1e; /* the text field, a shade below the list */
--note-on-bg: #a78c26; /* a selected note in the list — gold */
--note-on-ink: #1e1e1e; /* dark text on the gold */
--nav-active-ink: #1e1e1e; /* dark text on the amber menu item */
--heading-ink: #8a8e90; /* PROJEKTER and the group names — quiet grey */
--p1: #cf6b6b;
--p2: #d69f5c;
+7 -2
View File
@@ -1336,7 +1336,9 @@
h2 {
font-size: var(--menu-size);
font-weight: 600;
color: var(--ink);
/* Kul sets these headings to a quiet grey (--heading-ink); every other theme
falls back to the reading ink and keeps them bold and bright. */
color: var(--heading-ink, var(--ink));
}
/* The heading's mark, in the muted ink so the bold name still leads. Sized and
@@ -1591,7 +1593,10 @@
in both themes. */
a.active {
background: var(--accent-sunken);
color: var(--ink);
/* On a solid accent (Kul's amber menu item) the label goes dark via
--nav-active-ink; on the tinted accent-sunken every other theme uses, it
falls back to the reading ink. */
color: var(--nav-active-ink, var(--ink));
font-weight: 600;
}
+34 -6
View File
@@ -138,6 +138,7 @@
let totpSecret = $state('');
let totpURI = $state('');
let totpQR = $state('');
let totpCode = $state('');
let totpErrors = $state({});
let recoveryCodes = $state([]);
@@ -163,6 +164,7 @@
enablePassword = '';
totpSecret = r.secret;
totpURI = r.uri;
totpQR = r.qr;
} catch (e) {
totpErrors = e.fields ?? {};
if (!Object.keys(totpErrors).length) app.toast(humanMessage(e));
@@ -177,6 +179,7 @@
recoveryCodes = r.recovery_codes;
totpSecret = '';
totpURI = '';
totpQR = '';
totpCode = '';
app.user = await api.me();
} catch (e) {
@@ -492,8 +495,13 @@
{:else if totpSecret}
<div class="field">
<p class="hint">{t('account.scanHint')}</p>
{#if totpQR}
<!-- The server renders the otpauth URI as an SVG QR — one dark path on a
white square, so it scans in either theme. It is the URI it already
sent, drawn; nothing here builds it. -->
<div class="qr">{@html totpQR}</div>
{/if}
<p class="mono secret">{totpSecret}</p>
<p class="hint mono uri">{totpURI}</p>
</div>
<form onsubmit={confirmTOTP}>
@@ -514,7 +522,14 @@
<div class="row">
<button class="primary" type="submit">{t('account.confirm')}</button>
<button class="secondary" onclick={() => (totpSecret = '')}>{t('account.cancel')}</button>
<button
class="secondary"
onclick={() => {
totpSecret = '';
totpURI = '';
totpQR = '';
}}>{t('account.cancel')}</button
>
</div>
</form>
{:else}
@@ -859,6 +874,23 @@
color: var(--ink-faint);
}
/* On its own white card whatever the theme: a QR is dark-on-light or it does not
scan, so the square keeps its own background rather than taking the page's. */
.qr {
width: 176px;
height: 176px;
padding: var(--s2);
background: #ffffff;
border: 1px solid var(--line);
border-radius: var(--radius);
}
.qr :global(svg) {
display: block;
width: 100%;
height: 100%;
}
.secret {
margin: 0;
padding: var(--s3);
@@ -869,10 +901,6 @@
overflow-wrap: anywhere;
}
.uri {
overflow-wrap: anywhere;
}
/* The understated variant: present, not loud. Bordered and in the muted ink so
it reads as a footnote, brightening on hover. The cup is outline-only. */
.bmc {
+18 -4
View File
@@ -1480,8 +1480,20 @@
}
.row.on {
background: var(--surface-raised);
color: var(--ink);
background: var(--note-on-bg, var(--surface-raised));
color: var(--note-on-ink, var(--ink));
}
/* On a themed selection colour (Kul's gold), the row's own quieter tiers go dark
too, or the date and the filed line vanish into it. Other themes have no
--note-on-ink, so these fall back to their normal muted inks. */
.row.on .when,
.row.on .filed {
color: var(--note-on-ink, var(--ink-muted));
}
.row.on .filed-icon {
color: var(--note-on-ink, var(--ink-faint));
}
/* Samme størrelse som en opgavetitel. En note og en opgave er to ting af samme
@@ -1577,8 +1589,10 @@
min-height: 0;
min-width: 0;
/* A nuance lighter than the ground in dark, still white in light — matches
the note list beside it so the two read as one writing surface. */
background: var(--surface-raised);
the note list beside it so the two read as one writing surface. Kul gives
the text field its own darker grey via --editor-bg; other themes fall back
to the shared surface so the list and the editor stay the same colour. */
background: var(--editor-bg, var(--surface-raised));
border: 1px solid var(--line);
border-radius: var(--radius);
/* Luft nok til at teksten ikke rører kanten.