Compare commits

...
Author SHA1 Message Date
kristianwindandClaude Opus 4.8 c2d4ce0179 fix(notes): open a note on the first tap, not the second
On a phone the list/editor switch keyed on the loaded note, which arrives a
moment after the tap — so the first tap looked like nothing happened and
people tapped again. It keys on the selected id now, set the instant a row
is tapped, so the pane switches at once; the back button is there
immediately and the body fills in when it arrives, with a quiet pane rather
than the "pick a note" prompt in the half-second between.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 20:27:28 +02:00
Kristian Wind a1b8cc6ddc Merge pull request #51 from kristianwind/notes-mobile-detail
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 8m51s
CI / Docker image builds (push) Successful in 1m58s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 14m24s
Notes on a phone: show the note (master-detail layout)
2026-09-03 19:15:05 +02:00
kristianwindandClaude Opus 4.8 8a2c46f5ea fix(notes): show the note on a phone — one pane at a time
On a phone the note list and the editor stacked in one column, so a tapped
note opened below a full-height list, out of sight — "kan vælge en note men
kan ikke se indholdet". Now it is master-detail: the list fills the screen
until a note is opened, the editor takes over once one is, and an iOS-style
"‹ Noter" at the top of the editor goes back. The cards drop their border
and corners on one column, where there is nothing beside them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 19:07:02 +02:00
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
2026-09-03 16:27:11 +02:00
7 changed files with 191 additions and 38 deletions
+2 -2
View File
@@ -3,8 +3,10 @@ module github.com/kristianwind/verdande
go 1.26.3 go 1.26.3
require ( require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc
github.com/coder/websocket v1.8.15 github.com/coder/websocket v1.8.15
github.com/emersion/go-imap/v2 v2.0.0-beta.8 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-chi/chi/v5 v5.3.1
github.com/go-webauthn/webauthn v0.17.4 github.com/go-webauthn/webauthn v0.17.4
github.com/pquerna/otp v1.5.0 github.com/pquerna/otp v1.5.0
@@ -14,9 +16,7 @@ require (
) )
require ( require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/dustin/go-humanize v1.0.1 // 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/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
+36
View File
@@ -10,6 +10,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/boombuler/barcode/qr"
"github.com/pquerna/otp" "github.com/pquerna/otp"
"github.com/pquerna/otp/totp" "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 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 // TOTPURI rebuilds the otpauth:// URI for an existing secret, for showing the QR
// code again before enrolment is confirmed. // code again before enrolment is confirmed.
func TOTPURI(secret, issuer, account string) string { func TOTPURI(secret, issuer, account string) string {
+9 -1
View File
@@ -17,6 +17,9 @@ import (
type totpSetupResponse struct { type totpSetupResponse struct {
Secret string `json:"secret"` Secret string `json:"secret"`
URI string `json:"uri"` 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) { 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) s.internal(w, r, "store totp secret", err)
return 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 { type totpConfirmResponse struct {
+29 -18
View File
@@ -210,27 +210,38 @@
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.4); --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 /* Kul — the greys of Apple Notes' dark mode, to Kristian's own sampled values.
faint green cast. The same green accent, so it still reads as verdande; only the Three panel greys, lightest sidebar to darkest editor — the reverse of the usual
grounds, surfaces and inks turn to plain grey. */ 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'] { [data-theme='charcoal'] {
--ground: #1c1c1c; --ground: #1e1e1e;
--surface: #232323; --surface: #2a3033;
--surface-raised: #2b2b2b; --surface-raised: #242b2e; /* the note list */
--surface-sunken: #171717; --surface-sunken: #2d3133; /* the menu / sidebar — lightest here */
--line: #363636; --line: #34393b;
--line-strong: #474747; --line-strong: #414749;
--ink: #ededed; --ink: #ffffff;
--ink-muted: #a4a4a4; --ink-muted: #dcdcdc;
/* Light enough to clear 4.5:1 against the ground — the quiet tier of dates and --ink-faint: #9ba0a2;
counts is the first to disappear if it is too dim. */
--ink-faint: #8e8e8e;
--accent: #5fbf8f; /* Amber, Apple's Notes accent. The bright value is the selected menu item; the
--accent-hover: #6fcf9f; dark text rides on top of it. */
--accent-sunken: #23332b80; --accent: #ffce35;
--accent-ink: #0d1411; --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; --p1: #cf6b6b;
--p2: #d69f5c; --p2: #d69f5c;
+7 -2
View File
@@ -1336,7 +1336,9 @@
h2 { h2 {
font-size: var(--menu-size); font-size: var(--menu-size);
font-weight: 600; 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 /* The heading's mark, in the muted ink so the bold name still leads. Sized and
@@ -1591,7 +1593,10 @@
in both themes. */ in both themes. */
a.active { a.active {
background: var(--accent-sunken); 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; font-weight: 600;
} }
+34 -6
View File
@@ -138,6 +138,7 @@
let totpSecret = $state(''); let totpSecret = $state('');
let totpURI = $state(''); let totpURI = $state('');
let totpQR = $state('');
let totpCode = $state(''); let totpCode = $state('');
let totpErrors = $state({}); let totpErrors = $state({});
let recoveryCodes = $state([]); let recoveryCodes = $state([]);
@@ -163,6 +164,7 @@
enablePassword = ''; enablePassword = '';
totpSecret = r.secret; totpSecret = r.secret;
totpURI = r.uri; totpURI = r.uri;
totpQR = r.qr;
} catch (e) { } catch (e) {
totpErrors = e.fields ?? {}; totpErrors = e.fields ?? {};
if (!Object.keys(totpErrors).length) app.toast(humanMessage(e)); if (!Object.keys(totpErrors).length) app.toast(humanMessage(e));
@@ -177,6 +179,7 @@
recoveryCodes = r.recovery_codes; recoveryCodes = r.recovery_codes;
totpSecret = ''; totpSecret = '';
totpURI = ''; totpURI = '';
totpQR = '';
totpCode = ''; totpCode = '';
app.user = await api.me(); app.user = await api.me();
} catch (e) { } catch (e) {
@@ -492,8 +495,13 @@
{:else if totpSecret} {:else if totpSecret}
<div class="field"> <div class="field">
<p class="hint">{t('account.scanHint')}</p> <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="mono secret">{totpSecret}</p>
<p class="hint mono uri">{totpURI}</p>
</div> </div>
<form onsubmit={confirmTOTP}> <form onsubmit={confirmTOTP}>
@@ -514,7 +522,14 @@
<div class="row"> <div class="row">
<button class="primary" type="submit">{t('account.confirm')}</button> <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> </div>
</form> </form>
{:else} {:else}
@@ -859,6 +874,23 @@
color: var(--ink-faint); 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 { .secret {
margin: 0; margin: 0;
padding: var(--s3); padding: var(--s3);
@@ -869,10 +901,6 @@
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.uri {
overflow-wrap: anywhere;
}
/* The understated variant: present, not loud. Bordered and in the muted ink so /* 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. */ it reads as a footnote, brightening on hover. The cup is outline-only. */
.bmc { .bmc {
+74 -9
View File
@@ -707,7 +707,10 @@
}} }}
/> />
<div class="notes"> <!-- `has-note` drives the phone layout: one column, showing the list until a note
is opened and the editor once one is. On a wide screen both are always up and
the class does nothing. -->
<div class="notes" class:has-note={selectedId}>
<aside> <aside>
<div class="head"> <div class="head">
<h1>{t('notes.title')}</h1> <h1>{t('notes.title')}</h1>
@@ -916,7 +919,15 @@
</aside> </aside>
<section class="editor"> <section class="editor">
{#if selected} {#if selectedId}
<!-- Keyed on the id, which is set the instant a row is tapped — not on the
loaded note, which arrives a moment later. On a phone that moment was the
whole bug: the pane did not switch until the body had loaded, so the first
tap looked like nothing and people tapped again. -->
<!-- Phone only (hidden on a wide screen by CSS): the way back to the list,
since there the editor fills the screen and the list is not beside it. -->
<button class="back" onclick={() => open(null)}>‹ {t('notes.title')}</button>
{#if selected}
<NoteEditor <NoteEditor
note={selected} note={selected}
notes={notes} notes={notes}
@@ -1050,6 +1061,12 @@
{/if} {/if}
</div> </div>
</footer> </footer>
{:else}
<!-- Chosen, but its full text is still loading. Keep the pane (so the tap
felt like it landed) and stay quiet — the "pick a note" line is for
when nothing is chosen at all, not for this half-second. -->
<p class="empty" aria-busy="true"></p>
{/if}
{:else} {:else}
<p class="empty">{t('notes.pickOne')}</p> <p class="empty">{t('notes.pickOne')}</p>
{/if} {/if}
@@ -1480,8 +1497,20 @@
} }
.row.on { .row.on {
background: var(--surface-raised); background: var(--note-on-bg, var(--surface-raised));
color: var(--ink); 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 /* Samme størrelse som en opgavetitel. En note og en opgave er to ting af samme
@@ -1577,8 +1606,10 @@
min-height: 0; min-height: 0;
min-width: 0; min-width: 0;
/* A nuance lighter than the ground in dark, still white in light — matches /* 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. */ the note list beside it so the two read as one writing surface. Kul gives
background: var(--surface-raised); 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: 1px solid var(--line);
border-radius: var(--radius); border-radius: var(--radius);
/* Luft nok til at teksten ikke rører kanten. /* Luft nok til at teksten ikke rører kanten.
@@ -1897,14 +1928,48 @@
font-size: var(--text-sm); font-size: var(--text-sm);
} }
/* The way back to the list on a phone — an iOS-style "‹ Noter" at the top of the
editor. Hidden on a wide screen, where the list is always beside the editor. */
.back {
display: none;
align-self: flex-start;
align-items: center;
gap: var(--s1);
margin-bottom: var(--s2);
padding: var(--s1) 0;
font-size: var(--text-base);
color: var(--accent);
}
@media (max-width: 700px) { @media (max-width: 700px) {
.notes { .notes {
grid-template-columns: 1fr; grid-template-columns: 1fr;
padding: 0;
gap: 0;
} }
aside { /* One pane at a time. The two-column card layout has no room on a phone, so
border-right: 0; the list shows until a note is opened and the editor takes over once one is
padding-right: 0; — the back button is the way between them. Without this the editor stacked
below a full-height list, so a tapped note opened out of sight. */
.notes.has-note aside {
display: none;
}
.notes:not(.has-note) .editor {
display: none;
}
/* Full-bleed: the card border and rounded corners are for sitting beside
another card, and on one column there is nothing beside it. */
aside,
.editor {
border: 0;
border-radius: 0;
}
.back {
display: inline-flex;
} }
} }
</style> </style>