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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +108 -41
M internal/adapters/visual/lock.go +3 -1
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "io" | |
| 7 | 7 | "os" | |
| 8 | + | "time" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/neferafk/internal/adapters/visualproc" | |
| 10 | 11 | "github.com/bnema/neferafk/internal/ports" | |
| @@ -224,6 +225,7 @@ func (s *session) startLock(ctx context.Context, c ports.VisualLock) *lockRun { | |||
| 224 | 225 | done: make(chan error, 1), authDone: make(chan struct{}), locked: make(chan struct{}, 1), | |
| 225 | 226 | } | |
| 226 | 227 | go func() { defer close(l.authDone); ctl.run(lctx) }() | |
| 228 | + | go clockWake(lctx.Done(), ctl.wake) | |
| 227 | 229 | go func() { | |
| 228 | 230 | secret := nefergui.NewSecretBuffer(ports.AuthMaxSecret) | |
| 229 | 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 | 242 | return nefergui.LockConfig{ | |
| 241 | 243 | Secret: secret, | |
| 242 | 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 | 247 | OnLocked: func() { | |
| 246 | 248 | 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 @@ | |||
| 1 | 1 | package visual | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "strings" | |
| 5 | + | "time" | |
| 6 | + | ||
| 4 | 7 | "github.com/bnema/nefergui" | |
| 5 | 8 | ) | |
| 6 | 9 | ||
| 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. | |
| 10 | 15 | const ( | |
| 11 | 16 | cssScreen = "display:flex;flex-direction:column;align-items:center;justify-content:center;" + | |
| 12 | 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;" + | |
| 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" | |
| 20 | 39 | ) | |
| 21 | 40 | ||
| 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 | + | } | |
| 24 | 50 | switch p { | |
| 25 | - | case promptPIN: | |
| 26 | - | return "PIN" | |
| 27 | 51 | case promptFallback: | |
| 28 | - | return "Password (PIN unavailable)" | |
| 52 | + | return "PIN unavailable, use password" | |
| 29 | 53 | case promptUnavailable: | |
| 30 | 54 | return "Authentication unavailable" | |
| 31 | 55 | case promptMore: | |
| 32 | - | return "Password (again)" | |
| 33 | - | case promptPassword: | |
| 34 | - | return "Password" | |
| 56 | + | return "Enter again" | |
| 35 | 57 | } | |
| 36 | - | return "Locked" | |
| 58 | + | return " " | |
| 37 | 59 | } | |
| 38 | 60 | ||
| 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 | |
| 46 | 66 | } | |
| 47 | - | return " " | |
| 67 | + | return s.Bullets() + "_", cssMask | |
| 48 | 68 | } | |
| 49 | 69 | ||
| 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)) | |
| 56 | 80 | } | |
| 57 | 81 | ||
| 58 | 82 | // 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) | |
| 61 | 108 | } | |
| @@ -1,61 +1,108 @@ | |||||
| 1 | package visual | 1 | 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 only | 11 | + | // 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) { | |||
| 53 | 53 | require.Equal(t, all, l.Anchors) | |
| 54 | 54 | } | |
| 55 | 55 | ||
| 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))) | |
| 61 | 79 | } | |
| @@ -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 | } | ||