detached 0fa4cbf

feat(visual): restyle lock screen around a centered minimal input

Drop the prompt label, center the masked field on screen with an inverted
relief (50% bottom/right edges), grey the box border to 75%, show the date
and time right-aligned above the box and refreshed every minute, and use a
short field with large dots for PINs. Prompt notes move to the hint line.
commit
0fa4cbf6d5a04cffef5dc7a826010a9ee4dab178
author
brice · · 2026-10-01 13:17 UTC
committer
brice · · 2026-10-01 16:10 UTC
parent
565bd35
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +108 -41
M internal/adapters/visual/lock.go +3 -1
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "io"
77 "os"
8+ "time"
89
910 "github.com/bnema/neferafk/internal/adapters/visualproc"
1011 "github.com/bnema/neferafk/internal/ports"
@@ -224,6 +225,7 @@ func (s *session) startLock(ctx context.Context, c ports.VisualLock) *lockRun {
224225 done: make(chan error, 1), authDone: make(chan struct{}), locked: make(chan struct{}, 1),
225226 }
226227 go func() { defer close(l.authDone); ctl.run(lctx) }()
228+ go clockWake(lctx.Done(), ctl.wake)
227229 go func() {
228230 secret := nefergui.NewSecretBuffer(ports.AuthMaxSecret)
229231 err := s.locker.RunLock(lctx, lockConfig(s.log, ctl, secret, l.locked))
@@ -240,7 +242,7 @@ func lockConfig(log zerowrap.Logger, ctl *authController, secret *nefergui.Secre
240242 return nefergui.LockConfig{
241243 Secret: secret,
242244 View: func(f *nefergui.Frame, st nefergui.LockState) {
243- lockView(f, st, promptKind(ctl.prompt.Load()))
245+ lockView(f, st, promptKind(ctl.prompt.Load()), time.Now())
244246 },
245247 OnLocked: func() {
246248 select {
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 "io"6 "io"
7 "os"7 "os"
8+ "time"
8 9
9 "github.com/bnema/neferafk/internal/adapters/visualproc"10 "github.com/bnema/neferafk/internal/adapters/visualproc"
10 "github.com/bnema/neferafk/internal/ports"11 "github.com/bnema/neferafk/internal/ports"
@@ -224,6 +225,7 @@ func (s *session) startLock(ctx context.Context, c ports.VisualLock) *lockRun {
224 done: make(chan error, 1), authDone: make(chan struct{}), locked: make(chan struct{}, 1),225 done: make(chan error, 1), authDone: make(chan struct{}), locked: make(chan struct{}, 1),
225 }226 }
226 go func() { defer close(l.authDone); ctl.run(lctx) }()227 go func() { defer close(l.authDone); ctl.run(lctx) }()
228+ go clockWake(lctx.Done(), ctl.wake)
227 go func() {229 go func() {
228 secret := nefergui.NewSecretBuffer(ports.AuthMaxSecret)230 secret := nefergui.NewSecretBuffer(ports.AuthMaxSecret)
229 err := s.locker.RunLock(lctx, lockConfig(s.log, ctl, secret, l.locked))231 err := s.locker.RunLock(lctx, lockConfig(s.log, ctl, secret, l.locked))
@@ -240,7 +242,7 @@ func lockConfig(log zerowrap.Logger, ctl *authController, secret *nefergui.Secre
240 return nefergui.LockConfig{242 return nefergui.LockConfig{
241 Secret: secret,243 Secret: secret,
242 View: func(f *nefergui.Frame, st nefergui.LockState) {244 View: func(f *nefergui.Frame, st nefergui.LockState) {
243- lockView(f, st, promptKind(ctl.prompt.Load()))245+ lockView(f, st, promptKind(ctl.prompt.Load()), time.Now())
244 },246 },
245 OnLocked: func() {247 OnLocked: func() {
246 select {248 select {
M internal/adapters/visual/view.go +82 -35
@@ -1,61 +1,108 @@
11 package visual
22
33 import (
4+ "strings"
5+ "time"
6+
47 "github.com/bnema/nefergui"
58 )
69
7-// V1 lock look: black screen, one centered monospace box with a white border,
8-// masked input (bullets only). No clock, avatar or widgets. The view sees only
9-// the typed-character count, never the secret.
10+// Lock look: black screen, one monospace box with a grey border. The masked
11+// input sits at the exact screen center: the date line above the box and an
12+// equal-height spacer below it balance each other, and an empty spacer inside
13+// the box balances the status line under the input. The view sees only the
14+// typed-character count, never the secret.
1015 const (
1116 cssScreen = "display:flex;flex-direction:column;align-items:center;justify-content:center;" +
1217 "width:100%;height:100%;background-color:#000000;color:#ffffff;font-family:monospace;font-size:18px"
13- cssBox = "display:flex;flex-direction:column;align-items:center;border:2px solid #ffffff;" +
14- "padding:24px;min-width:320px;background-color:#000000"
15- cssLabel = "color:#ffffff"
16- // The input field: a visible white-underlined line, bullets plus a cursor.
17- cssMask = "color:#ffffff;min-height:28px;min-width:280px;margin-top:12px;padding:4px 8px;" +
18- "border-bottom:2px solid #ffffff"
19- cssHint = "color:#ffffff;margin-top:12px;min-height:22px"
18+ // cssFrame fixes the shared width so the date line ends at the box edge.
19+ cssFrame = "display:flex;flex-direction:column;align-items:stretch;width:360px"
20+ cssClock = "color:#ffffff;text-align:right;height:24px;margin-bottom:8px"
21+ // cssBalance mirrors cssClock below the box.
22+ cssBalance = "height:24px;margin-top:8px"
23+ cssBox = "display:flex;flex-direction:column;align-items:stretch;border:2px solid #bfbfbf;" +
24+ "padding:24px;background-color:#000000"
25+ // The box content is 360-2*2-2*24 = 308px wide; each field is centered by
26+ // an explicit left margin: (308 - (width + 16 padding + 2 border)) / 2.
27+ // The input field: an inverted relief, bottom and right edges at 50% white.
28+ cssMask = "color:#ffffff;height:32px;line-height:32px;width:280px;padding:4px 8px;margin-left:5px;" +
29+ "border-bottom:2px solid rgba(255,255,255,0.5);border-right:2px solid rgba(255,255,255,0.5)"
30+ // cssMaskPIN: a short field with large, centered bullets.
31+ cssMaskPIN = "color:#ffffff;height:32px;line-height:32px;width:160px;padding:4px 8px;margin-left:65px;" +
32+ "font-size:24px;letter-spacing:6px;text-align:center;" +
33+ "border-bottom:2px solid rgba(255,255,255,0.5);border-right:2px solid rgba(255,255,255,0.5)"
34+ // cssHint and cssHintBalance have the same outer height.
35+ cssHint = "color:#ffffff;margin-top:12px;height:22px;text-align:center"
36+ cssHintBalance = "margin-bottom:12px;height:22px"
37+
38+ clockLayout = "Mon 02 Jan 2006 15:04"
2039 )
2140
22-// labelFor is the fixed, non-secret caption for the requested credential.
23-func labelFor(p promptKind) string {
41+// hintFor is the line under the input: the attempt status first, then a note
42+// for prompts the bare field cannot convey. It never carries typed input.
43+func hintFor(s nefergui.LockStatus, p promptKind) string {
44+ switch s {
45+ case nefergui.LockBusy:
46+ return "..."
47+ case nefergui.LockFailed:
48+ return "Denied"
49+ }
2450 switch p {
25- case promptPIN:
26- return "PIN"
2751 case promptFallback:
28- return "Password (PIN unavailable)"
52+ return "PIN unavailable, use password"
2953 case promptUnavailable:
3054 return "Authentication unavailable"
3155 case promptMore:
32- return "Password (again)"
33- case promptPassword:
34- return "Password"
56+ return "Enter again"
3557 }
36- return "Locked"
58+ return " "
3759 }
3860
39-// hintFor is the minimal failure/busy line.
40-func hintFor(s nefergui.LockStatus) string {
41- switch s {
42- case nefergui.LockBusy:
43- return "..."
44- case nefergui.LockFailed:
45- return "Denied"
61+// maskFor is the field content and style: large dots for a PIN, bullets
62+// plus a cursor for a password.
63+func maskFor(s nefergui.LockState, p promptKind) (string, string) {
64+ if p == promptPIN {
65+ return strings.Repeat("●", s.Mask), cssMaskPIN
4666 }
47- return " "
67+ return s.Bullets() + "_", cssMask
4868 }
4969
50-// UnlockBox draws the centered box.
51-func UnlockBox(root nefergui.Node, s nefergui.LockState, p promptKind) {
52- box := root.Column(nefergui.Class("box"), nefergui.Inline(cssBox))
53- box.Text(labelFor(p), nefergui.Key("label"), nefergui.Inline(cssLabel))
54- box.Text(s.Bullets()+"_", nefergui.Key("mask"), nefergui.Inline(cssMask))
55- box.Text(hintFor(s.Status), nefergui.Key("hint"), nefergui.Inline(cssHint))
70+// UnlockBox draws the date line, the box and the balancing spacer.
71+func UnlockBox(root nefergui.Node, s nefergui.LockState, p promptKind, now time.Time) {
72+ frame := root.Column(nefergui.Class("frame"), nefergui.Inline(cssFrame))
73+ frame.Text(now.Format(clockLayout), nefergui.Key("clock"), nefergui.Inline(cssClock))
74+ box := frame.Column(nefergui.Class("box"), nefergui.Key("box"), nefergui.Inline(cssBox))
75+ box.Spacer(nefergui.Key("hint-balance"), nefergui.Inline(cssHintBalance))
76+ mask, css := maskFor(s, p)
77+ box.Text(mask, nefergui.Key("mask"), nefergui.Inline(css))
78+ box.Text(hintFor(s.Status, p), nefergui.Key("hint"), nefergui.Inline(cssHint))
79+ frame.Spacer(nefergui.Key("balance"), nefergui.Inline(cssBalance))
5680 }
5781
5882 // lockView builds the whole lock screen.
59-func lockView(f *nefergui.Frame, s nefergui.LockState, p promptKind) {
60- UnlockBox(f.Root(nefergui.Class("screen"), nefergui.Inline(cssScreen)), s, p)
83+func lockView(f *nefergui.Frame, s nefergui.LockState, p promptKind, now time.Time) {
84+ UnlockBox(f.Root(nefergui.Class("screen"), nefergui.Inline(cssScreen)), s, p, now)
85+}
86+
87+// clockWake requests a redraw at every minute boundary until ctx ends, so
88+// the date line stays current. A pending request is enough: sends never block.
89+func clockWake(done <-chan struct{}, wake chan<- struct{}) {
90+ t := time.NewTimer(untilNextMinute(time.Now()))
91+ defer t.Stop()
92+ for {
93+ select {
94+ case <-done:
95+ return
96+ case now := <-t.C:
97+ select {
98+ case wake <- struct{}{}:
99+ default:
100+ }
101+ t.Reset(untilNextMinute(now))
102+ }
103+ }
104+}
105+
106+func untilNextMinute(now time.Time) time.Duration {
107+ return now.Truncate(time.Minute).Add(time.Minute).Sub(now)
61108 }
@@ -1,61 +1,108 @@
1 package visual1 package visual
2 2
3 import (3 import (
4+ "strings"
5+ "time"
6+
4 "github.com/bnema/nefergui"7 "github.com/bnema/nefergui"
5 )8 )
6 9
7-// V1 lock look: black screen, one centered monospace box with a white border,10+// Lock look: black screen, one monospace box with a grey border. The masked
8-// masked input (bullets only). No clock, avatar or widgets. The view sees only11+// input sits at the exact screen center: the date line above the box and an
9-// the typed-character count, never the secret.12+// equal-height spacer below it balance each other, and an empty spacer inside
13+// the box balances the status line under the input. The view sees only the
14+// typed-character count, never the secret.
10 const (15 const (
11 cssScreen = "display:flex;flex-direction:column;align-items:center;justify-content:center;" +16 cssScreen = "display:flex;flex-direction:column;align-items:center;justify-content:center;" +
12 "width:100%;height:100%;background-color:#000000;color:#ffffff;font-family:monospace;font-size:18px"17 "width:100%;height:100%;background-color:#000000;color:#ffffff;font-family:monospace;font-size:18px"
13- cssBox = "display:flex;flex-direction:column;align-items:center;border:2px solid #ffffff;" +18+ // cssFrame fixes the shared width so the date line ends at the box edge.
14- "padding:24px;min-width:320px;background-color:#000000"19+ cssFrame = "display:flex;flex-direction:column;align-items:stretch;width:360px"
15- cssLabel = "color:#ffffff"20+ cssClock = "color:#ffffff;text-align:right;height:24px;margin-bottom:8px"
16- // The input field: a visible white-underlined line, bullets plus a cursor.21+ // cssBalance mirrors cssClock below the box.
17- cssMask = "color:#ffffff;min-height:28px;min-width:280px;margin-top:12px;padding:4px 8px;" +22+ cssBalance = "height:24px;margin-top:8px"
18- "border-bottom:2px solid #ffffff"23+ cssBox = "display:flex;flex-direction:column;align-items:stretch;border:2px solid #bfbfbf;" +
19- cssHint = "color:#ffffff;margin-top:12px;min-height:22px"24+ "padding:24px;background-color:#000000"
25+ // The box content is 360-2*2-2*24 = 308px wide; each field is centered by
26+ // an explicit left margin: (308 - (width + 16 padding + 2 border)) / 2.
27+ // The input field: an inverted relief, bottom and right edges at 50% white.
28+ cssMask = "color:#ffffff;height:32px;line-height:32px;width:280px;padding:4px 8px;margin-left:5px;" +
29+ "border-bottom:2px solid rgba(255,255,255,0.5);border-right:2px solid rgba(255,255,255,0.5)"
30+ // cssMaskPIN: a short field with large, centered bullets.
31+ cssMaskPIN = "color:#ffffff;height:32px;line-height:32px;width:160px;padding:4px 8px;margin-left:65px;" +
32+ "font-size:24px;letter-spacing:6px;text-align:center;" +
33+ "border-bottom:2px solid rgba(255,255,255,0.5);border-right:2px solid rgba(255,255,255,0.5)"
34+ // cssHint and cssHintBalance have the same outer height.
35+ cssHint = "color:#ffffff;margin-top:12px;height:22px;text-align:center"
36+ cssHintBalance = "margin-bottom:12px;height:22px"
37+
38+ clockLayout = "Mon 02 Jan 2006 15:04"
20 )39 )
21 40
22-// labelFor is the fixed, non-secret caption for the requested credential.41+// hintFor is the line under the input: the attempt status first, then a note
23-func labelFor(p promptKind) string {42+// for prompts the bare field cannot convey. It never carries typed input.
43+func hintFor(s nefergui.LockStatus, p promptKind) string {
44+ switch s {
45+ case nefergui.LockBusy:
46+ return "..."
47+ case nefergui.LockFailed:
48+ return "Denied"
49+ }
24 switch p {50 switch p {
25- case promptPIN:
26- return "PIN"
27 case promptFallback:51 case promptFallback:
28- return "Password (PIN unavailable)"52+ return "PIN unavailable, use password"
29 case promptUnavailable:53 case promptUnavailable:
30 return "Authentication unavailable"54 return "Authentication unavailable"
31 case promptMore:55 case promptMore:
32- return "Password (again)"56+ return "Enter again"
33- case promptPassword:
34- return "Password"
35 }57 }
36- return "Locked"58+ return " "
37 }59 }
38 60
39-// hintFor is the minimal failure/busy line.61+// maskFor is the field content and style: large dots for a PIN, bullets
40-func hintFor(s nefergui.LockStatus) string {62+// plus a cursor for a password.
41- switch s {63+func maskFor(s nefergui.LockState, p promptKind) (string, string) {
42- case nefergui.LockBusy:64+ if p == promptPIN {
43- return "..."65+ return strings.Repeat("●", s.Mask), cssMaskPIN
44- case nefergui.LockFailed:
45- return "Denied"
46 }66 }
47- return " "67+ return s.Bullets() + "_", cssMask
48 }68 }
49 69
50-// UnlockBox draws the centered box.70+// UnlockBox draws the date line, the box and the balancing spacer.
51-func UnlockBox(root nefergui.Node, s nefergui.LockState, p promptKind) {71+func UnlockBox(root nefergui.Node, s nefergui.LockState, p promptKind, now time.Time) {
52- box := root.Column(nefergui.Class("box"), nefergui.Inline(cssBox))72+ frame := root.Column(nefergui.Class("frame"), nefergui.Inline(cssFrame))
53- box.Text(labelFor(p), nefergui.Key("label"), nefergui.Inline(cssLabel))73+ frame.Text(now.Format(clockLayout), nefergui.Key("clock"), nefergui.Inline(cssClock))
54- box.Text(s.Bullets()+"_", nefergui.Key("mask"), nefergui.Inline(cssMask))74+ box := frame.Column(nefergui.Class("box"), nefergui.Key("box"), nefergui.Inline(cssBox))
55- box.Text(hintFor(s.Status), nefergui.Key("hint"), nefergui.Inline(cssHint))75+ box.Spacer(nefergui.Key("hint-balance"), nefergui.Inline(cssHintBalance))
76+ mask, css := maskFor(s, p)
77+ box.Text(mask, nefergui.Key("mask"), nefergui.Inline(css))
78+ box.Text(hintFor(s.Status, p), nefergui.Key("hint"), nefergui.Inline(cssHint))
79+ frame.Spacer(nefergui.Key("balance"), nefergui.Inline(cssBalance))
56 }80 }
57 81
58 // lockView builds the whole lock screen.82 // lockView builds the whole lock screen.
59-func lockView(f *nefergui.Frame, s nefergui.LockState, p promptKind) {83+func lockView(f *nefergui.Frame, s nefergui.LockState, p promptKind, now time.Time) {
60- UnlockBox(f.Root(nefergui.Class("screen"), nefergui.Inline(cssScreen)), s, p)84+ UnlockBox(f.Root(nefergui.Class("screen"), nefergui.Inline(cssScreen)), s, p, now)
85+}
86+
87+// clockWake requests a redraw at every minute boundary until ctx ends, so
88+// the date line stays current. A pending request is enough: sends never block.
89+func clockWake(done <-chan struct{}, wake chan<- struct{}) {
90+ t := time.NewTimer(untilNextMinute(time.Now()))
91+ defer t.Stop()
92+ for {
93+ select {
94+ case <-done:
95+ return
96+ case now := <-t.C:
97+ select {
98+ case wake <- struct{}{}:
99+ default:
100+ }
101+ t.Reset(untilNextMinute(now))
102+ }
103+ }
104+}
105+
106+func untilNextMinute(now time.Time) time.Duration {
107+ return now.Truncate(time.Minute).Add(time.Minute).Sub(now)
61 }108 }
M internal/adapters/visual/view_test.go +23 -5
@@ -53,9 +53,27 @@ func TestFadeLayerIsClickThroughOverlayOnEveryOutput(t *testing.T) {
5353 require.Equal(t, all, l.Anchors)
5454 }
5555
56-func TestLabelsAndHintsNeverCarryInput(t *testing.T) {
57- require.Equal(t, "Locked", labelFor(promptNone))
58- require.Equal(t, "PIN", labelFor(promptPIN))
59- require.Equal(t, "Denied", hintFor(nefergui.LockFailed))
60- require.Equal(t, "...", hintFor(nefergui.LockBusy))
56+func TestHintsShowStatusThenPromptNotes(t *testing.T) {
57+ require.Equal(t, "Denied", hintFor(nefergui.LockFailed, promptFallback))
58+ require.Equal(t, "...", hintFor(nefergui.LockBusy, promptPIN))
59+ require.Equal(t, "PIN unavailable, use password", hintFor(nefergui.LockIdle, promptFallback))
60+ require.Equal(t, "Authentication unavailable", hintFor(nefergui.LockIdle, promptUnavailable))
61+ require.Equal(t, " ", hintFor(nefergui.LockIdle, promptPassword))
62+ require.Equal(t, " ", hintFor(nefergui.LockIdle, promptPIN))
63+}
64+
65+func TestMaskUsesCompactFieldForPIN(t *testing.T) {
66+ s := nefergui.LockState{Mask: 3}
67+ mask, css := maskFor(s, promptPIN)
68+ require.Equal(t, "●●●", mask)
69+ require.Equal(t, cssMaskPIN, css)
70+ mask, css = maskFor(s, promptFallback)
71+ require.Equal(t, "•••_", mask)
72+ require.Equal(t, cssMask, css)
73+}
74+
75+func TestUntilNextMinute(t *testing.T) {
76+ now := time.Date(2026, 10, 1, 15, 7, 45, 0, time.UTC)
77+ require.Equal(t, 15*time.Second, untilNextMinute(now))
78+ require.Equal(t, time.Minute, untilNextMinute(now.Truncate(time.Minute)))
6179 }
@@ -53,9 +53,27 @@ func TestFadeLayerIsClickThroughOverlayOnEveryOutput(t *testing.T) {
53 require.Equal(t, all, l.Anchors)53 require.Equal(t, all, l.Anchors)
54 }54 }
55 55
56-func TestLabelsAndHintsNeverCarryInput(t *testing.T) {56+func TestHintsShowStatusThenPromptNotes(t *testing.T) {
57- require.Equal(t, "Locked", labelFor(promptNone))57+ require.Equal(t, "Denied", hintFor(nefergui.LockFailed, promptFallback))
58- require.Equal(t, "PIN", labelFor(promptPIN))58+ require.Equal(t, "...", hintFor(nefergui.LockBusy, promptPIN))
59- require.Equal(t, "Denied", hintFor(nefergui.LockFailed))59+ require.Equal(t, "PIN unavailable, use password", hintFor(nefergui.LockIdle, promptFallback))
60- require.Equal(t, "...", hintFor(nefergui.LockBusy))60+ require.Equal(t, "Authentication unavailable", hintFor(nefergui.LockIdle, promptUnavailable))
61+ require.Equal(t, " ", hintFor(nefergui.LockIdle, promptPassword))
62+ require.Equal(t, " ", hintFor(nefergui.LockIdle, promptPIN))
63+}
64+
65+func TestMaskUsesCompactFieldForPIN(t *testing.T) {
66+ s := nefergui.LockState{Mask: 3}
67+ mask, css := maskFor(s, promptPIN)
68+ require.Equal(t, "●●●", mask)
69+ require.Equal(t, cssMaskPIN, css)
70+ mask, css = maskFor(s, promptFallback)
71+ require.Equal(t, "•••_", mask)
72+ require.Equal(t, cssMask, css)
73+}
74+
75+func TestUntilNextMinute(t *testing.T) {
76+ now := time.Date(2026, 10, 1, 15, 7, 45, 0, time.UTC)
77+ require.Equal(t, 15*time.Second, untilNextMinute(now))
78+ require.Equal(t, time.Minute, untilNextMinute(now.Truncate(time.Minute)))
61 }79 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 9

↑↓ moveenter openesc close