diff --git a/README.md b/README.md index 9fad42e..3f673a1 100644 --- a/README.md +++ b/README.md @@ -97,9 +97,11 @@ The dashboard is a React app in `web/`, built into the binary. Building it needs The server listens on 127.0.0.1 only. WSL2 forwards `localhost` from Windows to it, so no `.wslconfig` change is needed. Work in the sandbox with `./rootcause shell` in a WSL terminal next to the browser; in the cluster the terminal is part of the page. -The home page is an inbox of tickets with the recommended one on top. Press Ctrl+K anywhere for the command palette, Ctrl+Enter to run the check (in the terminal too), and `?` for all shortcuts. In the terminal, Tab completes; press Esc, then Tab, to move on with the keyboard. +The home page is an inbox of tickets with the recommended one on top. A browser's first visit opens a short guided intro; the "?" button at the bottom of the sidebar shows it again, with all the shortcuts. Press Ctrl+K anywhere for the command palette, Ctrl+Enter to run the check (in the terminal too), and `?` for help. In the terminal, Tab completes; press Esc, then Tab, to move on with the keyboard. -`scripts/ui-check.sh ` checks the dashboard in Windows' Chrome on this laptop: accessibility (axe-core, downloaded once into `~/.cache/rootcause` and checked against a pinned SHA-256), layout shift, the keyboard rules, the Tab order, terminal latency and the CSP, with screenshots in both languages. Point it at a server on a test database: it runs a real check. The dashboard follows the CLI: hints, checks and results from either side show up in both. A binary built without `-tags webdist` still runs `serve`, but shows a page that says how to build the dashboard. +To start over as a new learner, use "Reset all data" in the help dialog, or `rootcause reset --all` in the CLI (type RESET to confirm, or pass `--yes` in a script). It deletes your history, sessions, levels, statistics and settings, ends a session in progress and removes its sandbox, and cannot be undone. It acts only on the database the dashboard or CLI uses. + +`scripts/ui-check.sh ` checks the dashboard in Windows' Chrome on this laptop: the first-run intro, the collapsed sidebar, centred pages on wide screens, the reset dialog, accessibility (axe-core, downloaded once into `~/.cache/rootcause` and checked against a pinned SHA-256), layout shift, the keyboard rules, the Tab order, terminal latency and the CSP, with screenshots in both languages. Point it at a server on a test database: it runs a real check. With `RC_UI_WIPE=1` it ends by resetting all data through the dashboard, which empties that database. The dashboard follows the CLI: hints, checks and results from either side show up in both. A binary built without `-tags webdist` still runs `serve`, but shows a page that says how to build the dashboard. Exercises that boot systemd (such as svc-02) need a host with cgroup v2. WSL2 uses cgroup v2 by default. Check with `docker info --format '{{.CgroupVersion}}'`, which must print `2`. diff --git a/cmd/rootcause/main.go b/cmd/rootcause/main.go index 7a68215..b64697d 100644 --- a/cmd/rootcause/main.go +++ b/cmd/rootcause/main.go @@ -66,7 +66,7 @@ func runApp(ctx context.Context, cmd string, args []string) int { case "check": cmdErr = app.Check(ctx) case "reset": - cmdErr = app.Reset(ctx) + cmdErr = app.Reset(ctx, args) case "giveup": cmdErr = app.GiveUp(ctx) case "status": diff --git a/docs/SPEC.md b/docs/SPEC.md index 3b287c9..64aac9f 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -339,6 +339,7 @@ Every dbt container is labelled with its database. Before a build, containers le | `rootcause hint` | Reveals the next hint tier | | `rootcause check` | Grades the active session | | `rootcause reset` | Fresh copy of the same variant | +| `rootcause reset --all [--yes]` | Deletes all of the learner's data, as the dashboard's "Reset all data" does: asks for the confirmation word (RESET, or NULLSTILL in Norwegian) unless `--yes` is given | | `rootcause giveup` | Ends the session and shows the walkthrough (after hint 3) | | `rootcause status` | Topic levels, active session, review queue | | `rootcause list` | All exercises with best stars | @@ -350,14 +351,18 @@ Every dbt container is labelled with its database. Before a build, containers le Rebuilt in Phase 10 as a calm workbench: flat panels divided by hairlines, nearly square corners, Inter for text and JetBrains Mono for ids, commands and the terminal, a teal accent, light and dark (following the system unless switched). -- Sidebar: Inbox, Exercises, History, Stats and the session in progress; the five topic level rings; the EN/NO and theme switches; the keyboard shortcuts. It collapses to icons (remembered per browser), and becomes a top bar on a phone. -- Inbox (home): the tickets as a helpdesk inbox, one row each with title and sender, topic, difficulty, a status tag (next, due, new, tried, solved with best stars) and the exercise id. The recommended ticket is pinned first, with why it was picked: the rule, the last result and date, and what would change that. A session in progress is pinned above everything. A first visit shows one short tip, which can be hidden. +- Sidebar: Inbox, Exercises, History, Stats and the session in progress; the five topic level rings; the EN/NO and theme switches; a "?" button for help. It collapses (remembered per browser) to a 64 px rail with only the mark, the expand toggle, the page icons, one language button, one theme button and "?". The current page's indicator sits at the sidebar's left edge in both states. The width animates only after the learner toggles it, never on load, and changes at once under reduced motion. On a phone it becomes a top bar. +- Help ("?" button or key): the keyboard shortcuts and the single-key switch, "Show the intro again", and a Data section with "Reset all data". +- Intro: a browser's first visit (no stored preferences, whatever the database holds) opens a four-step guided intro in a dialog: the inbox and why a ticket is picked, the session (ticket left, terminal right, Run check), hints and stars, levels and stats. Back, Next and Skip; Esc skips. Finishing or skipping is remembered per browser. +- Reset all data: a dialog that says what goes (history, sessions, topic levels, statistics and settings) and that it cannot be undone. Its button works only once the learner has typed the confirmation word (RESET, or NULLSTILL in Norwegian). The reset ends a session in progress and destroys its sandbox, deletes the sessions and events, the statistics (the analytics schema, which then reads as never built) and the profile language, in one transaction, then rebuilds the statistics where the server builds them. The browser's stored preferences are cleared and the app loads fresh, with the intro. It touches only the database the server uses; the exercise catalog and the migrations stay. +- Inbox (home): the tickets as a helpdesk inbox, one row each with title and sender, topic, difficulty, a status tag (next, due, new, tried, solved with best stars) and the exercise id. The recommended ticket is pinned first, with why it was picked: the rule, the last result and date, and what would change that. A session in progress is pinned above everything. "Why this ticket" opens with a short height and fade animation. - Session start: the engine's real steps (build the image, start the machine, set up, plant the fault) tick off as they happen. Only the exercise id and the step reach the browser. - Session: the ticket and hints on the left, the work on the right: a check bar (Run check, Show the next hint with the stars it leaves, Reset, an optional elapsed-time timer, and Give up behind a menu, each destructive action confirmed in place), the last check's result in one line, the terminal (v2) or the `rootcause shell` command (v1), and a check log. A solve plays the app's one expressive animation, under 1.2 s, skippable, never taking focus from the terminal. The terminal closes when its session ends. - Exercises: the catalog by topic with difficulty, best stars and when it was last played. - History: finished sessions with outcome, stars, hints, checks and time, filtered by topic and outcome. - Stats: the topic rings with progress toward the next level, solves per day (one-axis column chart with a data table), and per exercise the sessions, solve rate, hints, time and calibration flag, with "rebuild now" where the server builds the marts itself. -- Keyboard: Ctrl+K opens a command palette; Ctrl+Enter runs the check, from the terminal too; `g` then `i`, `e`, `h` or `s` goes to a page, `[` folds the sidebar and `?` lists the shortcuts. The single-key ones can be switched off and never fire while typing in a field, a dialog or the terminal. The terminal keeps Tab for completion; Esc, then Tab, leaves it. +- Wide screens: the page column is centred, up to 1440 px (the inbox up to 1280 px), with prose kept to its measure; the Stats rings sit in one row and the chart fills the column. +- Keyboard: Ctrl+K opens a command palette; Ctrl+Enter runs the check, from the terminal too; `g` then `i`, `e`, `h` or `s` goes to a page, `[` folds the sidebar and `?` opens help. The single-key ones can be switched off and never fire while typing in a field, a dialog or the terminal. The terminal keeps Tab for completion; Esc, then Tab, leaves it. - EN/NO switch. It sets the profile language, the same one `rootcause lang` sets. - Accessibility is a floor: WCAG 2.2 AA, every task by keyboard alone, reduced motion respected, layouts that reflow at 320 px and at 200% zoom. @@ -365,12 +370,12 @@ Implementation: - The app (Vite, React, TypeScript, plain CSS, hash routing) lives in `web/`. `scripts/build-web.sh` builds it into `web/dist` with `npm ci` from the committed lockfile. `go build -tags webdist` embeds it. Without the tag, `/` serves a translated page that says how to build it. - `internal/server` is a JSON API over the same engine, selector and store as the CLI. Both front ends call the shared steps in `internal/play` (next pick and seed, exercise texts, statistics rebuild). Exercise texts are filled server-side in the learner's language. UI strings come from `locales/*.json` through `/api/i18n/{lang}`, so there is one copy of every string. -- Endpoints: `GET /api/overview` (with the start in progress and each topic's progress toward the next level), `/api/exercises` (with the ticket header and the latest result), `/api/history`, `/api/stats`, `/api/session` (the active session, 204 if none), `/api/sessions/{id}`, `/api/i18n/{lang}`; `POST /api/start` (`{"exercise": ""}` starts the recommended one), `/api/hint`, `/api/check`, `/api/reset`, `/api/giveup`, `/api/stats/rebuild` (one rebuild at a time, 409 while one runs, 404 where a CronJob builds the marts); `PUT /api/lang`; `GET /api/terminal` (WebSocket, v2). Actions name the session the page shows and are refused if it is no longer the active one. +- Endpoints: `GET /api/overview` (with the start in progress and each topic's progress toward the next level), `/api/exercises` (with the ticket header and the latest result), `/api/history`, `/api/stats`, `/api/session` (the active session, 204 if none), `/api/sessions/{id}`, `/api/i18n/{lang}`; `POST /api/start` (`{"exercise": ""}` starts the recommended one), `/api/hint`, `/api/check`, `/api/reset`, `/api/giveup`, `/api/stats/rebuild` (one rebuild at a time, 409 while one runs, 404 where a CronJob builds the marts), `/api/data/reset` (reset all data; needs `{"confirm": "RESET"}`, 400 without it, 409 while another action such as a session start runs); `PUT /api/lang`; `GET /api/terminal` (WebSocket, v2). Actions name the session the page shows and are refused if it is no longer the active one. - Before a session exists, the inbox and the catalog show each ticket's header filled with the first value of each variant, a fixed display choice. A session shows its own variant. - The dashboard follows the CLI by polling: the open session every 2 seconds, the overview every few seconds and on focus. The two front ends share only the database. - The session view never contains the seed, variant values or derived values. Hints appear once revealed, and the walkthrough once the session is solved or given up. - Everything the dashboard needs is in the binary: the app, its fonts (SIL OFL, licences next to the files) and its icons. It loads nothing from elsewhere, and the CSP did not change for it. The terminal's code loads only on a session page. -- `scripts/ui-check.sh` checks the dashboard in a real browser on the development laptop: axe-core accessibility rules, no layout shift when data arrives, the keyboard rules, the Tab order, terminal typing latency, the CSP, and screenshots in both languages at 1280, 390 and 320 px. It is not part of CI. +- `scripts/ui-check.sh` checks the dashboard in a real browser on the development laptop: the intro on a first visit (and that Esc skips it and help restarts it), the collapsed rail (no overflow, no rings, the indicator inside the rail with a gap), centred pages at 1280, 1440, 1920 and 2560 px and at 1920 px with 75% zoom, the reset dialog by keyboard, axe-core accessibility rules, no layout shift when data arrives, the keyboard rules, the Tab order, terminal typing latency, the CSP, and screenshots in both languages at 1280, 390 and 320 px and at 1920 px (100% and 75%). With `RC_UI_WIPE=1` it ends by resetting all data through the dialog. It is not part of CI. ## 12. Languages @@ -553,6 +558,8 @@ Done when: a merged change reaches the cluster without manual kubectl. Done when: `scripts/ui-check.sh` passes in both languages: accessibility, no layout shift, reflow at 320 px and at 200% zoom, the keyboard rules (shortcuts, the palette, the check from the terminal, a way out of the terminal), the Tab order, terminal latency unchanged, CSP unchanged. +Phase 10.1, from the owner's feedback on v0.10.0: the collapsed rail, its indicator and animation, the help button, the guided intro, centred wide layouts, and "Reset all data" (dashboard and `rootcause reset --all`). Done when `scripts/ui-check.sh` also passes its intro, rail, wide-screen and reset checks. + ## 19. Public readiness checklist The owner decides when the repo goes public. Ready means: diff --git a/internal/cli/app.go b/internal/cli/app.go index bdf5b7a..97d178b 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -8,6 +8,7 @@ import ( "context" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -32,6 +33,7 @@ type App struct { bundle *i18n.Bundle learner store.Learner out *os.File + in io.Reader // where confirmations are read from } // NewApp builds the app for commands that need the database. It assumes @@ -88,7 +90,7 @@ func NewApp(ctx context.Context) (*App, error) { // Best-effort: a reap error must not block the command. _, _ = session.NewReaper(eng, st, 0).ReapOnce(ctx) - return &App{root: root, dsn: dsn, st: st, eng: eng, runtime: rtName, bundle: bundle, learner: learner, out: os.Stdout}, nil + return &App{root: root, dsn: dsn, st: st, eng: eng, runtime: rtName, bundle: bundle, learner: learner, out: os.Stdout, in: os.Stdin}, nil } // EnvDBWait is how many seconds NewApp keeps trying to reach the database diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 197aa69..dc654fb 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -1,6 +1,7 @@ package cli import ( + "bufio" "context" "errors" "fmt" @@ -147,8 +148,28 @@ func (a *App) Check(ctx context.Context) error { return nil } -// Reset gives a fresh copy of the same variant. -func (a *App) Reset(ctx context.Context) error { +// Reset gives a fresh copy of the same variant. With --all it deletes all of +// the learner's data instead (see WipeAll). +func (a *App) Reset(ctx context.Context, args []string) error { + all, yes := false, false + for _, arg := range args { + switch arg { + case "--all": + all = true + case "--yes": + yes = true + default: + a.say("reset.usage", nil) + return nil + } + } + if all { + return a.WipeAll(ctx, yes) + } + if yes { + a.say("reset.usage", nil) + return nil + } sess, ok, err := a.active(ctx) if err != nil { return err @@ -164,6 +185,32 @@ func (a *App) Reset(ctx context.Context) error { return nil } +// WipeAll deletes all of the learner's data, the same way the dashboard's +// "Reset all data" does (session.Engine.WipeAll): the active session and its +// sandbox, the history, the levels, the statistics and the settings. Unless +// yes is set, the learner must type the confirmation word first. The +// statistics are rebuilt afterwards. +func (a *App) WipeAll(ctx context.Context, yes bool) error { + if !yes { + word := a.t("wipe.word", nil) + a.say("wipe.warning", nil) + fmt.Fprint(a.out, a.t("wipe.prompt", map[string]string{"word": word})) + line, _ := bufio.NewReader(a.in).ReadString('\n') + if strings.TrimSpace(line) != word { + a.say("wipe.cancelled", nil) + return nil + } + } + n, err := a.eng.WipeAll(ctx, a.learner.ID) + if err != nil { + return err + } + a.say("wipe.done", map[string]string{"sandboxes": fmt.Sprint(n)}) + a.learner.Lang = "en" // the wipe resets the language to the default + a.refreshAfterSession(ctx) + return nil +} + // GiveUp ends the session and shows the walkthrough (after hint 3). func (a *App) GiveUp(ctx context.Context) error { sess, ok, err := a.active(ctx) diff --git a/internal/runtime/scripted/scripted.go b/internal/runtime/scripted/scripted.go index d505488..d598582 100644 --- a/internal/runtime/scripted/scripted.go +++ b/internal/runtime/scripted/scripted.go @@ -19,6 +19,7 @@ type Runtime struct { checkPasses bool feedback string buildHook func() + live map[string]runtime.Sandbox // created and not yet destroyed, by id } // SetBuildHook makes BuildImage call hook first, so a test can hold a start @@ -36,8 +37,16 @@ func (r *Runtime) SetCheck(passes bool, feedback string) { r.checkPasses, r.feedback = passes, feedback } +// Create records the sandbox as live until Destroy. func (r *Runtime) Create(_ context.Context, spec runtime.SandboxSpec) (runtime.Sandbox, error) { - return runtime.Sandbox{ID: "fake-" + spec.Name, Name: spec.Name, Labels: spec.Labels}, nil + b := runtime.Sandbox{ID: "fake-" + spec.Name, Name: spec.Name, Image: spec.Image, Labels: spec.Labels} + r.mu.Lock() + defer r.mu.Unlock() + if r.live == nil { + r.live = make(map[string]runtime.Sandbox) + } + r.live[b.ID] = b + return b, nil } func (r *Runtime) Exec(_ context.Context, _ string, cmd []string, _ io.Reader) (runtime.ExecResult, error) { @@ -57,8 +66,26 @@ func (r *Runtime) Exec(_ context.Context, _ string, cmd []string, _ io.Reader) ( func (r *Runtime) CopyIn(context.Context, string, string, []byte) error { return nil } func (r *Runtime) Restart(context.Context, string) error { return nil } -func (r *Runtime) Destroy(context.Context, string) error { return nil } -func (r *Runtime) List(context.Context) ([]runtime.Sandbox, error) { return nil, nil } + +// Destroy forgets the sandbox. An unknown id is not an error, as in Docker's +// forced removal. +func (r *Runtime) Destroy(_ context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.live, id) + return nil +} + +// List returns the live sandboxes. +func (r *Runtime) List(context.Context) ([]runtime.Sandbox, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]runtime.Sandbox, 0, len(r.live)) + for _, b := range r.live { + out = append(out, b) + } + return out, nil +} // BuildImage calls the build hook, if set, and succeeds. func (r *Runtime) BuildImage(context.Context, string, string) error { diff --git a/internal/server/api.go b/internal/server/api.go index b2d6078..e4c9531 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -44,6 +44,7 @@ func (s *Server) routes(mux *http.ServeMux) { mux.HandleFunc("POST /api/check", s.handleCheck) mux.HandleFunc("POST /api/reset", s.handleReset) mux.HandleFunc("POST /api/giveup", s.handleGiveUp) + mux.HandleFunc("POST /api/data/reset", s.handleWipe) mux.HandleFunc("PUT /api/lang", s.handleSetLang) mux.HandleFunc("GET /api/i18n/{lang}", s.handleStrings) mux.HandleFunc("GET "+TerminalPath, s.handleTerminal) @@ -693,6 +694,44 @@ func (s *Server) handleGiveUp(w http.ResponseWriter, r *http.Request) { s.writeSessionByID(w, ctx, lang, sess.ID, nil) } +// WipeConfirm is the value POST /api/data/reset needs in its confirm field. +// The dashboard sends it only after the learner typed the confirmation word. +const WipeConfirm = "RESET" + +type wipeJSON struct { + Sandboxes int `json:"sandboxes"` // sandboxes destroyed +} + +// handleWipe deletes all of the learner's data (session.Engine.WipeAll): the +// active session and its sandbox, the history, the levels, the statistics +// and the settings. One runs at a time, and never while another action, a +// session start among them, is running: then it answers 409. Afterwards the +// statistics rebuild where the server builds them; in the cluster they read +// as never built until the CronJob runs. +func (s *Server) handleWipe(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + lang := s.lang(ctx) + var body struct { + Confirm string `json:"confirm"` + } + if err := decode(r, &body); err != nil || body.Confirm != WipeConfirm { + s.fail(w, http.StatusBadRequest, lang, "web.wipe.confirm_required", nil) + return + } + if !s.actions.TryLock() { + s.fail(w, http.StatusConflict, lang, "web.wipe.busy", nil) + return + } + defer s.actions.Unlock() + n, err := s.cfg.Engine.WipeAll(ctx, s.cfg.LearnerID) + if err != nil { + s.failErr(w, lang, err) + return + } + s.stats.trigger() + writeJSON(w, http.StatusOK, wipeJSON{Sandboxes: n}) +} + // --- helpers ----------------------------------------------------------------- // deps is the selector's input, the same as rootcause next uses. refresh nil diff --git a/internal/server/server.go b/internal/server/server.go index 1c4c6f5..3462a93 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -79,7 +79,7 @@ type Server struct { stats *refresher // actions serializes the session actions (start, hint, check, reset, - // give up), so a double click cannot run two at once. + // give up) and the data reset, so a double click cannot run two at once. actions sync.Mutex // starting is the session start in progress, for the overview; nil when diff --git a/internal/server/server_test.go b/internal/server/server_test.go index c685170..ca02e8d 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -198,6 +198,8 @@ func TestGuard(t *testing.T) { {"stats, no token", "GET", "/api/stats", "", "", 403}, {"rebuild, no token", "POST", "/api/stats/rebuild", "", "", 403}, {"rebuild, rebinding host with token", "POST", "/api/stats/rebuild", "evil.example:" + r.port, r.token, 403}, + {"data reset, no token", "POST", "/api/data/reset", "", "", 403}, + {"data reset, rebinding host with token", "POST", "/api/data/reset", "evil.example:" + r.port, r.token, 403}, {"api, token", "GET", "/api/i18n/en", "", r.token, 200}, {"api, localhost host", "GET", "/api/i18n/no", "localhost:" + r.port, r.token, 200}, {"api, upper-case host", "GET", "/api/i18n/no", "LOCALHOST:" + r.port, r.token, 200}, @@ -247,7 +249,12 @@ func newWorld(t *testing.T, assets bool) *world { // starts. func newWorldWith(t *testing.T, assets bool, change func(*server.Config)) *world { t.Helper() - dsn := testenv.DSN(t) + return newWorldOn(t, testenv.DSN(t), assets, change) +} + +// newWorldOn is newWorldWith on the database in dsn. +func newWorldOn(t *testing.T, dsn string, assets bool, change func(*server.Config)) *world { + t.Helper() ctx := context.Background() st := testenv.Store(t, dsn) bundle, err := i18n.Load(locales.FS) diff --git a/internal/server/wipe_test.go b/internal/server/wipe_test.go new file mode 100644 index 0000000..5ba8ab7 --- /dev/null +++ b/internal/server/wipe_test.go @@ -0,0 +1,147 @@ +package server_test + +import ( + "testing" + "time" + + "github.com/DIZAMU/RootCause/internal/play" + "github.com/DIZAMU/RootCause/internal/server" + "github.com/DIZAMU/RootCause/internal/testenv" +) + +// TestWipeOverAPI: "Reset all data" needs the confirmation, is refused while +// a session starts, and then deletes the sessions, events, statistics and +// settings, destroys the sandboxes, and leaves a fresh learner. The wipe +// empties whole tables, so the test runs in a database of its own. +func TestWipeOverAPI(t *testing.T) { + dsn := testenv.OwnDatabase(t, "wipe") + w := newWorldOn(t, dsn, true, func(c *server.Config) { c.CanRebuild = true }) + rows, err := play.Catalog(w.exDir) + if err != nil { + t.Fatal(err) + } + if err := w.st.SyncExercises(w.ctx, rows); err != nil { + t.Fatal(err) + } + pool := w.st.Pool() + count := func(sql string) int { + t.Helper() + var n int + if err := pool.QueryRow(w.ctx, sql).Scan(&n); err != nil { + t.Fatalf("%s: %v", sql, err) + } + return n + } + + // A start in progress holds the wipe off. + hold, entered := make(chan struct{}), make(chan struct{}) + w.rt.SetBuildHook(func() { + close(entered) + <-hold + }) + started := make(chan struct{}) + go func() { + defer close(started) + res := w.do("POST", "/api/start", "", map[string]string{"exercise": "perm-01-shared-folder"}, w.token) + if res.status != 200 { + t.Errorf("POST /api/start: status %d: %s", res.status, res.body) + } + }() + select { + case <-entered: + case <-time.After(10 * time.Second): + t.Fatal("the start did not reach the image build") + } + w.api("POST", "/api/data/reset", map[string]string{"confirm": server.WipeConfirm}, 409, nil) + close(hold) + <-started + w.rt.SetBuildHook(nil) + + var ov struct { + Active *struct{ ID string } + } + w.api("GET", "/api/overview", nil, 200, &ov) + if ov.Active == nil { + t.Fatal("no active session after the start") + } + w.api("POST", "/api/hint", map[string]string{"session": ov.Active.ID}, 200, nil) + w.api("PUT", "/api/lang", map[string]string{"lang": "no"}, 204, nil) + for _, sql := range []string{ + `CREATE SCHEMA analytics`, + `CREATE TABLE analytics.mart_build_info (built_at timestamptz)`, + `INSERT INTO analytics.mart_build_info VALUES (now())`, + } { + if _, err := pool.Exec(w.ctx, sql); err != nil { + t.Fatalf("%s: %v", sql, err) + } + } + boxes, _ := w.rt.List(w.ctx) + if len(boxes) == 0 { + t.Fatal("the session has no live sandbox") + } + + // Without the confirmation nothing happens. + w.api("POST", "/api/data/reset", nil, 400, nil) + w.api("POST", "/api/data/reset", map[string]string{}, 400, nil) + w.api("POST", "/api/data/reset", map[string]string{"confirm": "reset"}, 400, nil) + if n := count(`SELECT count(*) FROM session`); n != 1 { + t.Fatalf("a refused wipe changed the sessions: %d", n) + } + + drain := func() { + for { + select { + case <-w.builds: + default: + return + } + } + } + drain() + var out struct{ Sandboxes int } + w.api("POST", "/api/data/reset", map[string]string{"confirm": server.WipeConfirm}, 200, &out) + if out.Sandboxes != len(boxes) { + t.Errorf("sandboxes destroyed = %d, want %d", out.Sandboxes, len(boxes)) + } + if left, _ := w.rt.List(w.ctx); len(left) != 0 { + t.Errorf("sandboxes left after the wipe: %v", left) + } + for sql, want := range map[string]int{ + `SELECT count(*) FROM session`: 0, + `SELECT count(*) FROM event`: 0, + `SELECT count(*) FROM information_schema.schemata WHERE schema_name = 'analytics'`: 0, + `SELECT count(*) FROM exercise`: len(rows), + } { + if n := count(sql); n != want { + t.Errorf("%s = %d, want %d", sql, n, want) + } + } + var lang string + if err := pool.QueryRow(w.ctx, `SELECT lang FROM learner WHERE id = $1`, w.learner).Scan(&lang); err != nil { + t.Fatalf("the learner is gone: %v", err) + } + if lang != "en" { + t.Errorf("lang = %q after the wipe, want the default en", lang) + } + select { + case <-w.builds: + case <-time.After(10 * time.Second): + t.Error("the statistics were not rebuilt after the wipe") + } + + // The fresh state: no session, statistics never built, and a new session + // can start. + var fresh struct { + Lang string + Active *struct{ ID string } + Stats struct { + BuiltAt *time.Time `json:"built_at"` + } + } + w.api("GET", "/api/overview", nil, 200, &fresh) + if fresh.Active != nil || fresh.Stats.BuiltAt != nil || fresh.Lang != "en" { + t.Errorf("overview after the wipe = %+v, want no session, never built, en", fresh) + } + w.api("GET", "/api/history", nil, 200, nil) + w.api("POST", "/api/start", map[string]string{"exercise": "disk-01-stopped-saving"}, 200, nil) +} diff --git a/internal/session/engine.go b/internal/session/engine.go index abfb3c3..5bd2c2e 100644 --- a/internal/session/engine.go +++ b/internal/session/engine.go @@ -642,6 +642,18 @@ func (e *Engine) Expire(ctx context.Context, sess store.Session) (*LevelChange, return e.applyLevel(ctx, sess.LearnerID, ex.Topic, sess.ID) } +// WipeAll deletes all of the learner's data (store.WipeLearnerData), the +// active session included, then destroys every session sandbox this database +// started, which after the wipe are all orphans. It returns how many +// sandboxes it destroyed. The dashboard's "Reset all data" and +// rootcause reset --all both call it. +func (e *Engine) WipeAll(ctx context.Context, learnerID int) (int, error) { + if err := e.st.WipeLearnerData(ctx, learnerID); err != nil { + return 0, err + } + return NewReaper(e, e.st, 0).ReclaimOrphans(ctx) +} + // Shell opens an interactive shell in the session's sandbox (v1). func (e *Engine) Shell(ctx context.Context, sessionID string) error { sess, err := e.st.SessionByID(ctx, sessionID) diff --git a/internal/store/wipe.go b/internal/store/wipe.go new file mode 100644 index 0000000..96f7c22 --- /dev/null +++ b/internal/store/wipe.go @@ -0,0 +1,47 @@ +package store + +import ( + "context" + "fmt" +) + +// WipeLearnerData deletes everything the learner has done, in one +// transaction: every session and event, the statistics (the analytics schema +// dbt builds, so the marts read as never built), and the learner's settings +// (the language goes back to the default). The learner row keeps its id, so +// a running dashboard and the CLI keep working. The exercise catalog and the +// migrations stay. It only touches the database the store is connected to. +// +// It holds the learner and stats advisory locks, so it waits for a dbt build +// the CLI or the dashboard is running and cannot interleave with one. +func (s *Store) WipeLearnerData(ctx context.Context, learnerID int) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin: %w", err) + } + defer tx.Rollback(ctx) + for _, key := range []int64{learnerLockKey, statsLockKey} { + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, key); err != nil { + return fmt.Errorf("wipe: lock: %w", err) + } + } + for _, stmt := range []string{ + `TRUNCATE event, session RESTART IDENTITY`, + `DROP SCHEMA IF EXISTS analytics CASCADE`, + } { + if _, err := tx.Exec(ctx, stmt); err != nil { + return fmt.Errorf("wipe: %w", err) + } + } + tag, err := tx.Exec(ctx, `UPDATE learner SET lang = DEFAULT, created_at = now() WHERE id = $1`, learnerID) + if err != nil { + return fmt.Errorf("wipe: reset learner: %w", err) + } + if tag.RowsAffected() != 1 { + return fmt.Errorf("wipe: no learner %d", learnerID) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit: %w", err) + } + return nil +} diff --git a/locales/en.json b/locales/en.json index 70ae205..5eba303 100644 --- a/locales/en.json +++ b/locales/en.json @@ -31,7 +31,7 @@ "lang.set": "Interface language set to {lang}.", "lang.usage": "Usage: rootcause lang en|no", "help.unknown_command": "Unknown command: {command}", - "help.usage": "rootcause - hands-on IT troubleshooting practice\n\nPlay:\n init Start Postgres, run migrations, create the local profile\n next Start the recommended next exercise\n start Start a specific exercise\n ticket Show the active ticket again\n shell Open a shell in the active sandbox\n hint Reveal the next hint\n check Grade the active session\n reset Fresh copy of the same problem\n giveup End the session and show the walkthrough (after hint 3)\n status Active session, topic levels, review queue and best stars\n list All exercises with best stars\n stats [--refresh] Statistics per exercise (--refresh rebuilds them first)\n serve [--port N] Start the local dashboard on http://localhost:7300\n lang en|no Set the interface language\n\nDeveloper:\n validate Validate an exercise definition\n vars --seed Print a seed's variant values\n selftest --seed Prove an exercise end to end in real sandboxes\n", + "help.usage": "rootcause - hands-on IT troubleshooting practice\n\nPlay:\n init Start Postgres, run migrations, create the local profile\n next Start the recommended next exercise\n start Start a specific exercise\n ticket Show the active ticket again\n shell Open a shell in the active sandbox\n hint Reveal the next hint\n check Grade the active session\n reset [--all] Fresh copy of the same problem (--all: delete all your data)\n giveup End the session and show the walkthrough (after hint 3)\n status Active session, topic levels, review queue and best stars\n list All exercises with best stars\n stats [--refresh] Statistics per exercise (--refresh rebuilds them first)\n serve [--port N] Start the local dashboard on http://localhost:7300\n lang en|no Set the interface language\n\nDeveloper:\n validate Validate an exercise definition\n vars --seed Print a seed's variant values\n selftest --seed Prove an exercise end to end in real sandboxes\n", "error.generic": "Something went wrong: {detail}", "error.detail": "Details: {detail}", "error.no_root": "Cannot find the RootCause folder. Run rootcause from inside the repo, or set RC_ROOT to its path.", @@ -205,8 +205,6 @@ "web.level.progress_other": "{topic}: level {level}. {count} more strong solves to reach level {next}.", "web.level.top": "{topic}: level {level}, the top level.", "web.inbox.title": "Inbox", - "web.inbox.intro": "Each ticket is a broken server. Open it, fix the fault in the terminal, then run the check. Hints are there when you need them.", - "web.inbox.intro_dismiss": "Hide this tip", "web.inbox.in_progress": "In progress", "web.inbox.resume": "Resume", "web.inbox.open_ticket": "Open ticket", @@ -308,5 +306,42 @@ "web.level.next_other": "{count} more strong solves to reach level {next}.", "web.level.next_top": "The top level.", "web.stats.flag.too_few": "Fewer than 10 sessions, too few for a flag.", - "web.terminal.leave": "Tab completes in the terminal. To leave it with the keyboard, press Esc, then Tab." + "web.terminal.leave": "Tab completes in the terminal. To leave it with the keyboard, press Esc, then Tab.", + "reset.usage": "Usage: rootcause reset (a fresh copy of the same problem), or rootcause reset --all [--yes] (delete all your data)", + "wipe.word": "RESET", + "wipe.warning": "This deletes all your RootCause data: history, sessions, topic levels, statistics and settings. A session in progress ends and its sandbox is removed. It cannot be undone.", + "wipe.prompt": "Type {word} to confirm: ", + "wipe.cancelled": "Nothing was deleted.", + "wipe.done": "All data was deleted ({sandboxes} sandboxes removed). You start fresh.", + "web.cancel": "Cancel", + "web.lang.switch": "Language {current}, switch to {other}", + "web.lang.name.en": "English", + "web.lang.name.no": "Norsk", + "web.theme.switch": "Theme {current}, switch to {next}", + "web.help.title": "Help and shortcuts", + "web.help.intro_title": "Getting started", + "web.help.intro": "Show the intro again", + "web.help.data_title": "Data", + "web.help.data_body": "Delete everything RootCause has stored about you and start fresh.", + "web.wipe.open": "Reset all data", + "web.wipe.title": "Reset all RootCause data?", + "web.wipe.body": "This deletes your history, sessions, topic levels, statistics and settings. A session in progress ends and its sandbox is removed. It cannot be undone.", + "web.wipe.type": "Type {word} to confirm", + "web.wipe.word": "RESET", + "web.wipe.confirm": "Reset all data", + "web.wipe.confirm_required": "Type the confirmation word to reset all data.", + "web.wipe.busy": "Another action is running. Try again when it has finished.", + "web.intro.step": "Step {n} of {total}", + "web.intro.skip": "Skip", + "web.intro.back": "Back", + "web.intro.next": "Next", + "web.intro.done": "Get started", + "web.intro.inbox.title": "Your inbox", + "web.intro.inbox.body": "Each ticket is a Linux server with a fault. The one at the top is picked for you: open \"Why this ticket\" to see why.", + "web.intro.session.title": "Ticket left, terminal right", + "web.intro.session.body": "Opening a ticket starts a sandbox. Read the ticket, find and fix the fault in the terminal, then run the check (Ctrl+Enter).", + "web.intro.hints.title": "Hints and stars", + "web.intro.hints.body": "Stuck? Reveal a hint. A solve with no hints earns 3 stars, with one or two hints 2, and with all three 1. After the third hint you can give up and read the walkthrough.", + "web.intro.levels.title": "Levels and stats", + "web.intro.levels.body": "Each topic has a level from 1 to 3. Solves with 2 or 3 stars move it up and giving up can move it down. Stats shows how you are doing." } diff --git a/locales/no.json b/locales/no.json index eb8af56..42425b5 100644 --- a/locales/no.json +++ b/locales/no.json @@ -31,7 +31,7 @@ "lang.set": "Grensesnittspråk satt til {lang}.", "lang.usage": "Bruk: rootcause lang en|no", "help.unknown_command": "Ukjent kommando: {command}", - "help.usage": "rootcause - praktisk øving i IT-feilsøking\n\nØving:\n init Start Postgres, kjør migreringer, lag den lokale profilen\n next Start den anbefalte neste øvelsen\n start <øvelse-id> Start en bestemt øvelse\n ticket Vis den aktive saken igjen\n shell Åpne et skall i den aktive sandkassen\n hint Vis neste hint\n check Vurder den aktive økten\n reset Ny kopi av samme problem\n giveup Avslutt økten og vis gjennomgangen (etter hint 3)\n status Aktiv økt, nivå per emne, repetisjonskø og beste stjerner\n list Alle øvelser med beste stjerner\n stats [--refresh] Statistikk per øvelse (--refresh bygger den på nytt først)\n serve [--port N] Start det lokale dashbordet på http://localhost:7300\n lang en|no Velg språk for grensesnittet\n\nUtvikler:\n validate Valider en øvelsesdefinisjon\n vars --seed Vis variantverdiene for en seed\n selftest --seed Bevis at en øvelse virker i ekte sandkasser\n", + "help.usage": "rootcause - praktisk øving i IT-feilsøking\n\nØving:\n init Start Postgres, kjør migreringer, lag den lokale profilen\n next Start den anbefalte neste øvelsen\n start <øvelse-id> Start en bestemt øvelse\n ticket Vis den aktive saken igjen\n shell Åpne et skall i den aktive sandkassen\n hint Vis neste hint\n check Vurder den aktive økten\n reset [--all] Ny kopi av samme problem (--all: slett alle dataene dine)\n giveup Avslutt økten og vis gjennomgangen (etter hint 3)\n status Aktiv økt, nivå per emne, repetisjonskø og beste stjerner\n list Alle øvelser med beste stjerner\n stats [--refresh] Statistikk per øvelse (--refresh bygger den på nytt først)\n serve [--port N] Start det lokale dashbordet på http://localhost:7300\n lang en|no Velg språk for grensesnittet\n\nUtvikler:\n validate Valider en øvelsesdefinisjon\n vars --seed Vis variantverdiene for en seed\n selftest --seed Bevis at en øvelse virker i ekte sandkasser\n", "error.generic": "Noe gikk galt: {detail}", "error.detail": "Detaljer: {detail}", "error.no_root": "Finner ikke RootCause-mappen. Kjør rootcause fra repoet, eller sett RC_ROOT til stien.", @@ -205,8 +205,6 @@ "web.level.progress_other": "{topic}: nivå {level}. {count} sterke løsninger til for å nå nivå {next}.", "web.level.top": "{topic}: nivå {level}, det høyeste nivået.", "web.inbox.title": "Innboks", - "web.inbox.intro": "Hver sak er en server med en feil. Åpne den, rett feilen i terminalen og kjør sjekken. Hintene er der når du trenger dem.", - "web.inbox.intro_dismiss": "Skjul dette tipset", "web.inbox.in_progress": "Pågår", "web.inbox.resume": "Fortsett", "web.inbox.open_ticket": "Åpne saken", @@ -308,5 +306,42 @@ "web.level.next_other": "{count} sterke løsninger til for å nå nivå {next}.", "web.level.next_top": "Høyeste nivå.", "web.stats.flag.too_few": "Færre enn 10 økter, for få for et flagg.", - "web.terminal.leave": "Tab fullfører i terminalen. For å gå ut av den med tastaturet, trykk Esc og så Tab." + "web.terminal.leave": "Tab fullfører i terminalen. For å gå ut av den med tastaturet, trykk Esc og så Tab.", + "reset.usage": "Bruk: rootcause reset (ny kopi av samme problem), eller rootcause reset --all [--yes] (slett alle dataene dine)", + "wipe.word": "NULLSTILL", + "wipe.warning": "Dette sletter alle RootCause-dataene dine: historikk, økter, nivåer, statistikk og innstillinger. En pågående økt avsluttes, og sandkassen fjernes. Det kan ikke angres.", + "wipe.prompt": "Skriv {word} for å bekrefte: ", + "wipe.cancelled": "Ingenting ble slettet.", + "wipe.done": "Alle data ble slettet ({sandboxes} sandkasser fjernet). Du starter på nytt.", + "web.cancel": "Avbryt", + "web.lang.switch": "Språk {current}, bytt til {other}", + "web.lang.name.en": "English", + "web.lang.name.no": "Norsk", + "web.theme.switch": "Tema {current}, bytt til {next}", + "web.help.title": "Hjelp og hurtigtaster", + "web.help.intro_title": "Kom i gang", + "web.help.intro": "Vis introduksjonen igjen", + "web.help.data_title": "Data", + "web.help.data_body": "Slett alt RootCause har lagret om deg, og start helt på nytt.", + "web.wipe.open": "Nullstill alle data", + "web.wipe.title": "Nullstille alle RootCause-data?", + "web.wipe.body": "Dette sletter historikken, øktene, nivåene, statistikken og innstillingene dine. En pågående økt avsluttes, og sandkassen fjernes. Det kan ikke angres.", + "web.wipe.type": "Skriv {word} for å bekrefte", + "web.wipe.word": "NULLSTILL", + "web.wipe.confirm": "Nullstill alle data", + "web.wipe.confirm_required": "Skriv bekreftelsesordet for å nullstille alle data.", + "web.wipe.busy": "En annen handling kjører. Prøv igjen når den er ferdig.", + "web.intro.step": "Steg {n} av {total}", + "web.intro.skip": "Hopp over", + "web.intro.back": "Tilbake", + "web.intro.next": "Neste", + "web.intro.done": "Kom i gang", + "web.intro.inbox.title": "Innboksen din", + "web.intro.inbox.body": "Hver sak er en Linux-server med en feil. Den øverste er valgt for deg: åpne «Hvorfor denne saken» for å se hvorfor.", + "web.intro.session.title": "Saken til venstre, terminalen til høyre", + "web.intro.session.body": "Når du åpner en sak, starter en sandkasse. Les saken, finn og rett feilen i terminalen, og kjør sjekken (Ctrl+Enter).", + "web.intro.hints.title": "Hint og stjerner", + "web.intro.hints.body": "Står du fast? Vis et hint. Løser du uten hint, får du 3 stjerner, med ett eller to hint 2 og med alle tre 1. Etter det tredje hintet kan du gi opp og lese gjennomgangen.", + "web.intro.levels.title": "Nivåer og statistikk", + "web.intro.levels.body": "Hvert emne har et nivå fra 1 til 3. Løsninger med 2 eller 3 stjerner løfter det, og å gi opp kan senke det. Statistikk viser hvordan det går." } diff --git a/scripts/ui-check.sh b/scripts/ui-check.sh index 5aa4114..49fc52f 100755 --- a/scripts/ui-check.sh +++ b/scripts/ui-check.sh @@ -2,7 +2,8 @@ # Checks the dashboard in a real browser (spec section 11): accessibility # (axe-core, WCAG 2.2 AA rules), layout shift on data arrival, keyboard rules # (shortcuts never reach the terminal or a field, the palette is a modal), -# the Tab order, terminal typing latency, the CSP header, and screenshots. +# the Tab order, terminal typing latency, the CSP header, the first-run intro, +# the collapsed sidebar, wide screens, the reset dialog, and screenshots. # A developer tool for this laptop, not CI: it runs Windows' Chrome headless # with a throwaway profile, driven over the DevTools protocol on Windows' # 127.0.0.1 by Windows' Node. scripts/ui/*.mjs use Node's built-ins only. @@ -11,7 +12,9 @@ # # The server at base-url must use a test database: the keyboard check runs # a real check on the active session. For the terminal checks it runs with -# RC_RUNTIME=kubernetes. Exit 0 only if every check passes. +# RC_RUNTIME=kubernetes. With RC_UI_WIPE=1 it ends by resetting all data +# through the dashboard, which empties that test database. Exit 0 only if +# every check passes. set -euo pipefail [[ $# -ge 3 && $# -le 4 ]] || { echo "usage: ui-check.sh [out-dir]" >&2; exit 2; } @@ -49,11 +52,19 @@ if ! echo "$AXE_SHA256 $axe" | sha256sum --check --status 2>/dev/null; then fi cp "$axe" "$out/axe.min.js" +cdp() { win -e "fetch('http://127.0.0.1:$port/json/version').then(r => r.json()).then(j => { $1 }).catch(() => process.exit(1))"; } +# A browser still on the port would be driven instead of a fresh one, with +# its stored preferences. +if cdp "console.log(j.Browser)" >/dev/null 2>&1; then + echo "ui-check: something already answers on 127.0.0.1:$port; close it first" >&2 + exit 1 +fi # A throwaway headless Chrome; closed again on exit. profile="$(cd /tmp && cmd.exe /c 'echo %TEMP%' 2>/dev/null | tr -d '\r')\\rootcause-ui-check" +# Emptied first: the intro check needs a browser that stored nothing. +rm -rf -- "$(wslpath -u "$profile")" "$chrome" --headless=new --remote-debugging-port="$port" --remote-debugging-address=127.0.0.1 \ --user-data-dir="$profile" --no-first-run --no-default-browser-check --disable-extensions --hide-scrollbars about:blank >/dev/null 2>&1 & -cdp() { win -e "fetch('http://127.0.0.1:$port/json/version').then(r => r.json()).then(j => { $1 }).catch(() => process.exit(1))"; } close_chrome() { cdp "const ws = new WebSocket(j.webSocketDebuggerUrl); ws.onopen = () => { ws.send(JSON.stringify({ id: 1, method: 'Browser.close' })); setTimeout(() => process.exit(0), 300); };" >/dev/null 2>&1 || true } @@ -85,6 +96,11 @@ want="$(awk '/^const ContentSecurityPolicy = / { getline next_line; print $0 nex sed 's/^const ContentSecurityPolicy = "//; s/" +[[:space:]]*"//; s/"$//')" if [[ "$csp" == "$want" ]]; then echo "PASS CSP is the server's constant: $csp"; else echo "FAIL CSP '$csp', the constant says '$want'"; failures=$((failures + 1)); fi +# First, in the fresh profile: the intro needs a browser that stored nothing. +echo "== shell: intro, sidebar rail, wide screens, reset dialog" +win "$(winpath "$root/scripts/ui/shell.mjs")" "$port" "$base" "$(winpath "$out/axe.min.js")" >"$out/shell.log" || true +report "$out/shell.log" + pages="inbox=#/ exercises=#/exercises history=#/history stats=#/stats session=#/session/$active finished=#/session/$finished" plan() { # plan python3 - "$1" "$2" "$3" "$base" "$pages" <<'PY' @@ -92,12 +108,18 @@ import json, sys kind, lang, scheme, base, pages = sys.argv[1:6] # 640 x 400 is a 1280 x 800 window at 200% zoom. sizes = [(1280, 800), (640, 400), (390, 844), (320, 700)] +# 1920 x 1080 at 100%, and at 75% zoom (a 2560 x 1440 CSS viewport). +wide = [("1920", 1920, 1080, 1), ("1920-75", 2560, 1440, 0.75)] items = [] for p in pages.split(): name, route = p.split("=", 1) for w, h in sizes: items.append({"name": f"{name}-{lang}-{scheme}-{w}", "url": f"{base}/{route}", "width": w, "height": h, "scheme": scheme, "wait": 4000, "waitFor": "main", "fullPage": True}) + if kind == "shots": + for label, w, h, scale in wide: + items.append({"name": f"{name}-{lang}-{scheme}-{label}", "url": f"{base}/{route}", "width": w, "height": h, + "scale": scale, "scheme": scheme, "wait": 4000, "waitFor": "main"}) if kind == "audit" and lang == "en": items.append({"name": f"{name}-{lang}-{scheme}-1280-reduced", "url": f"{base}/{route}", "width": 1280, "height": 800, "scheme": scheme, "motion": "reduce", "wait": 4000}) @@ -147,6 +169,14 @@ else failures=$((failures + 1)) fi +# Last, and only with RC_UI_WIPE=1: really reset all data through the dialog. +# It empties the test database, so the fixture sessions are gone after it. +if [[ "${RC_UI_WIPE:-}" == 1 ]]; then + echo "== reset all data, for real" + win "$(winpath "$root/scripts/ui/shell.mjs")" "$port" "$base" "$(winpath "$out/axe.min.js")" wipe >"$out/wipe.log" || true + report "$out/wipe.log" +fi + echo echo "screenshots and logs: $out" if [[ "$failures" -gt 0 ]]; then diff --git a/scripts/ui/keys.mjs b/scripts/ui/keys.mjs index a663c12..6b45dbc 100644 --- a/scripts/ui/keys.mjs +++ b/scripts/ui/keys.mjs @@ -98,8 +98,8 @@ check("U1 'gi' outside fields goes to the inbox", (await ev("location.hash")) == // U1 off switch: '?' opens the shortcuts; turning single keys off stops 'g h'. await type("?"); await sleep(300); -check("'?' opens the shortcut list", await ev("!!document.querySelector('dialog.dialog-shortcuts[open]')")); -await ev("document.querySelector('dialog.dialog-shortcuts input[type=checkbox]').click()"); +check("'?' opens the shortcut list", await ev("!!document.querySelector('dialog.dialog-help[open]')")); +await ev("document.querySelector('dialog.dialog-help input[type=checkbox]').click()"); await key("Escape", "Escape", 27); await sleep(300); await ev("document.activeElement?.blur()"); @@ -108,7 +108,7 @@ await sleep(400); check("U1 with single keys off, 'g h' does nothing", (await ev("location.hash")) === "#/", await ev("location.hash")); const pref = await ev("localStorage.getItem('rootcause.prefs')"); check("U1 the off switch is remembered", (pref ?? "").includes('"shortcuts":false'), pref); -await ev("localStorage.removeItem('rootcause.prefs')"); +await ev("localStorage.setItem('rootcause.prefs', JSON.stringify({ introDone: true }))"); console.log(results.join("\n")); await fetch(`http://127.0.0.1:${port}/json/close/${target.id}`); diff --git a/scripts/ui/shell.mjs b/scripts/ui/shell.mjs new file mode 100644 index 0000000..5328812 --- /dev/null +++ b/scripts/ui/shell.mjs @@ -0,0 +1,245 @@ +// App shell checks over CDP: the first-run intro, the collapsed sidebar rail, +// the layout on wide screens, and the "Reset all data" dialog. +// node shell.mjs [wipe] +// Run it first in a fresh browser profile: the intro check needs a browser +// with no stored preferences. It leaves the intro finished. With "wipe" it +// only resets all data through the dialog, for real, and checks the fresh +// state after. +import { readFileSync } from "node:fs"; + +const [port, base, axePath, wipe] = process.argv.slice(2); +const axe = readFileSync(axePath, "utf8"); +const target = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" })).json(); +const ws = new WebSocket(target.webSocketDebuggerUrl); +await new Promise((r) => (ws.onopen = r)); +let id = 0; +const pending = new Map(); +ws.onmessage = (e) => { + const m = JSON.parse(e.data); + if (m.id && pending.has(m.id)) { + pending.get(m.id)(m); + pending.delete(m.id); + } +}; +const send = (method, params = {}) => new Promise((r) => { const n = ++id; pending.set(n, r); ws.send(JSON.stringify({ id: n, method, params })); }); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const ev = async (expression) => { + const m = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }); + if (m.result?.exceptionDetails) throw new Error(JSON.stringify(m.result.exceptionDetails).slice(0, 400)); + return m.result?.result?.value; +}; +const key = async (k, code, vk, modifiers = 0, text) => { + await send("Input.dispatchKeyEvent", { type: "keyDown", key: k, code, windowsVirtualKeyCode: vk, modifiers, text }); + await send("Input.dispatchKeyEvent", { type: "keyUp", key: k, code, windowsVirtualKeyCode: vk, modifiers }); + await sleep(60); +}; +const esc = () => key("Escape", "Escape", 27); +const enter = () => key("Enter", "Enter", 13, 0, "\r"); +const tab = () => key("Tab", "Tab", 9); +const type = async (s) => { + for (const ch of s) await key(ch, `Key${ch.toUpperCase()}`, ch.toUpperCase().charCodeAt(0), 0, ch); +}; +const results = []; +const check = (name, ok, detail = "") => results.push(`${ok ? "PASS" : "FAIL"} ${name}${detail !== "" ? `: ${detail}` : ""}`); +const size = (width, height, scale = 1, motion = "no-preference") => + Promise.all([ + send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: scale, mobile: false }), + send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-reduced-motion", value: motion }] }), + ]); +const load = async (hash) => { + await send("Page.navigate", { url: "about:blank" }); + await sleep(150); + await send("Page.navigate", { url: `${base}/${hash}` }); + for (let i = 0; i < 60 && !(await ev("!!document.querySelector('main .page-head')")); i++) await sleep(200); + await sleep(1200); +}; +const axeRun = async () => { + if (!(await ev("typeof axe !== 'undefined'"))) await ev(axe); + return ev(`axe.run(document, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"] } }).then(r => r.violations.map(v => v.id + " @ " + (v.nodes[0]?.target?.join(" ") ?? "")))`); +}; +const introOpen = () => ev("!!document.querySelector('dialog.dialog-intro[open]')"); +const step = () => ev("document.querySelector('dialog.dialog-intro[open] .intro-text .label')?.innerText ?? ''"); + +await send("Page.enable"); +await size(1280, 800); + +let word = ""; +try { +if (wipe !== "wipe") { +// --- the intro: a browser with no stored preferences, on a database with +// history (the fixture has an active and a finished session). +await load("#/"); +check("intro opens on the first visit", await introOpen()); +check("intro: focus starts on Next", await ev("document.activeElement?.classList.contains('intro-next')"), await ev("document.activeElement?.className")); +const introAxe = await axeRun(); +check("intro: axe violations with the intro open", introAxe.length === 0, introAxe.join("; ") || "0"); +const s1 = await step(); +await enter(); +await sleep(400); +const s2 = await step(); +check("intro: Enter on Next moves to step 2", s1 !== s2 && s2.includes("2"), `${s1} -> ${s2}`); +check("intro: focus stays on Next after a step", await ev("document.activeElement?.classList.contains('intro-next')")); +await ev("document.querySelector('dialog.dialog-intro .intro-back').click()"); +await sleep(400); +check("intro: Back returns to step 1, focus on Next", (await step()) === s1 && (await ev("document.activeElement?.classList.contains('intro-next')")), await step()); +let inside = true; +for (let i = 0; i < 6; i++) { + await tab(); + // Past the last button Chrome may hand focus to its own UI (body here), + // never to the page behind the dialog, which is inert. + inside &&= await ev("!!document.activeElement?.closest('dialog.dialog-intro') || document.activeElement === document.body || !document.activeElement"); +} +check("intro: Tab never reaches the page behind", inside); +for (let i = 0; i < 3; i++) await ev("document.querySelector('dialog.dialog-intro .intro-next')?.click()"); +await sleep(400); +check("intro: the last step offers Get started", await ev("!!document.querySelector('dialog.dialog-intro form .intro-next')")); +await esc(); +await sleep(500); +check("intro: Esc skips it", !(await introOpen())); +check("intro: finishing is remembered", ((await ev("localStorage.getItem('rootcause.prefs')")) ?? "").includes('"introDone":true')); +await load("#/"); +check("intro: does not open again after a reload", !(await introOpen())); +await ev("document.querySelector('.sidebar-help').focus()"); +await enter(); +await sleep(400); +check("help: the ? button opens the help dialog", await ev("!!document.querySelector('dialog.dialog-help[open]')")); +const helpAxe = await axeRun(); +check("help: axe violations with help open", helpAxe.length === 0, helpAxe.join("; ") || "0"); +await ev("document.querySelector('dialog.dialog-help .help-intro').focus()"); +await enter(); +await sleep(500); +check("help: Show the intro again opens it at step 1", (await introOpen()) && (await step()) === s1, await step()); +await esc(); +await sleep(500); +check("help: after the intro, focus is back on the ? button", await ev("document.activeElement?.classList.contains('sidebar-help')"), await ev("document.activeElement?.className")); + +// --- the sidebar. Indicator: in the gutter at the sidebar's left edge, with +// clear space before the item's icon. +const railFacts = `(() => { + const side = document.querySelector('.sidebar').getBoundingClientRect(); + const sb = document.querySelector('.sidebar'); + const cur = document.querySelector('.nav-item.is-current'); + const bar = getComputedStyle(cur, '::before'); + const item = cur.getBoundingClientRect(); + const barLeft = item.left + parseFloat(bar.left); + const barRight = barLeft + parseFloat(bar.width); + const icon = cur.querySelector('.icon').getBoundingClientRect(); + const levels = document.querySelector('.sidebar-levels'); + const ringsShown = [...document.querySelectorAll('.sidebar .ring')].filter((r) => r.getBoundingClientRect().width > 0).length; + const foot = [...document.querySelectorAll('.sidebar-foot > *')].map((el) => { const r = el.getBoundingClientRect(); return r.left >= side.left - 0.5 && r.right <= side.right + 0.5 && r.width > 0; }); + const wide = [...sb.querySelectorAll('*')].filter((el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.right > side.right + 0.5; }).map((el) => el.tagName.toLowerCase() + '.' + String(el.className.baseVal ?? el.className).split(' ')[0] + ':' + Math.round(el.getBoundingClientRect().right - side.right)).slice(0, 6); + return { width: Math.round(side.width), overflowX: sb.scrollWidth > sb.clientWidth, wide, overflowY: sb.scrollHeight > sb.clientHeight, + barInside: barLeft >= side.left - 0.5 && barRight <= side.left + 12, gap: Math.round(icon.left - barRight), ringsShown, + levelsShown: !!levels && getComputedStyle(levels).display !== 'none', footInside: foot.every(Boolean), foot: foot.length, + transition: getComputedStyle(document.querySelector('.app')).transitionProperty, duration: getComputedStyle(document.querySelector('.app')).transitionDuration }; +})()`; +await load("#/"); +const open = await ev(railFacts); +check("sidebar: no transition on the first paint", !open.transition.includes("grid-template-columns"), open.transition); +check("sidebar open: indicator at the left edge with a gap before the icon", open.barInside && open.gap >= 8, `gap ${open.gap}px`); +await ev("document.querySelector('.sidebar-toggle').click()"); +await sleep(600); +const rail = await ev(railFacts); +check("rail: 64 px wide", rail.width === 64, `${rail.width}px`); +check("rail: no overflow, no scrollbars", !rail.overflowX && !rail.overflowY, JSON.stringify({ x: rail.overflowX, y: rail.overflowY, past: rail.wide })); +check("rail: no level rings", !rail.levelsShown && rail.ringsShown === 0, `${rail.ringsShown} shown`); +check("rail: language, theme and help fit inside", rail.footInside && rail.foot === 3, `${rail.foot} controls`); +check("rail: indicator at the left edge with a gap before the icon", rail.barInside && rail.gap >= 8, `gap ${rail.gap}px`); +check("rail: the width animates after a toggle", rail.transition.includes("grid-template-columns") && rail.duration !== "0s", `${rail.transition} ${rail.duration}`); +const names = await ev("[...document.querySelectorAll('.sidebar a, .sidebar button')].filter((e) => e.getBoundingClientRect().width > 0).map((e) => (e.getAttribute('aria-label') || e.innerText).trim() && e.title ? 'ok' : (e.getAttribute('aria-label') || e.innerText).trim() ? 'notitle' : 'noname')"); +check("rail: every control has a name and a title", names.every((n) => n === "ok"), names.join(",")); +const railAxe = await axeRun(); +check("rail: axe violations", railAxe.length === 0, railAxe.join("; ") || "0"); +await size(1280, 800, 1, "reduce"); +await ev("document.querySelector('.sidebar-toggle').click()"); +await sleep(100); +const reduced = await ev(railFacts); +check("sidebar: instant under reduced motion", reduced.duration.split(",").every((d) => parseFloat(d) === 0), reduced.duration); +await size(1280, 800); +check("sidebar: expanded again shows the rings", reduced.levelsShown && reduced.ringsShown > 0, `${reduced.ringsShown}`); + +// --- wide screens: the page column is centred in main, with no empty band +// on one side; the Stats rings sit in one row. +for (const [w, h, label, scale = 1] of [[1280, 800, "1280"], [1440, 900, "1440"], [1920, 1080, "1920"], [2560, 1440, "2560"], [2560, 1440, "1920 at 75%", 0.75]]) { + await size(w, h, scale); + for (const page of ["#/", "#/exercises", "#/history", "#/stats"]) { + await load(page); + const f = await ev(`(() => { const m = document.querySelector('main').getBoundingClientRect(); const p = document.querySelector('main .page').getBoundingClientRect(); const tops = [...document.querySelectorAll('.rings-large li')].map((li) => Math.round(li.getBoundingClientRect().top)); return { left: Math.round(p.left - m.left), right: Math.round(m.right - p.right), width: Math.round(p.width), rows: new Set(tops).size, overflow: document.documentElement.scrollWidth > innerWidth }; })()`); + const ok = Math.abs(f.left - f.right) <= 2 && !f.overflow && (page !== "#/stats" || f.rows <= 1); + check(`wide ${label} ${page}: centred`, ok, `band left ${f.left} right ${f.right}, page ${f.width}px${page === "#/stats" ? `, ring rows ${f.rows}` : ""}`); + } +} +await size(1280, 800); + +// --- the reset dialog, by keyboard. It is not confirmed here unless "wipe". +await load("#/"); +await ev("document.querySelector('.sidebar-help').click()"); +await sleep(400); +await ev("document.querySelector('dialog.dialog-help .help-wipe').focus()"); +await enter(); +await sleep(400); +check("reset: Enter on Reset all data opens the dialog", await ev("!!document.querySelector('dialog.dialog-wipe[open]')")); +check("reset: focus is in the confirmation field", await ev("document.activeElement?.id === 'wipe-word'"), await ev("document.activeElement?.id")); +const disabled = () => ev("document.querySelector('dialog.dialog-wipe .wipe-confirm').disabled"); +check("reset: the button is disabled while the field is empty", await disabled()); +word = await ev("document.querySelector('dialog.dialog-wipe label').innerText.match(/[A-ZÆØÅ]{4,}/)?.[0] ?? ''"); +await type(word.slice(0, -1)); +check("reset: still disabled one letter short", await disabled(), word.slice(0, -1)); +await enter(); +await sleep(300); +check("reset: Enter with the wrong word does nothing", await ev("!!document.querySelector('dialog.dialog-wipe[open]')")); +await type(word.slice(-1)); +check(`reset: enabled once ${word} is typed`, !(await disabled())); +const wipeAxe = await axeRun(); +check("reset: axe violations with the dialog open", wipeAxe.length === 0, wipeAxe.join("; ") || "0"); +await esc(); +await sleep(400); +check("reset: Esc closes it, help stays open", (await ev("!document.querySelector('dialog.dialog-wipe[open]')")) && (await ev("!!document.querySelector('dialog.dialog-help[open]')"))); +check("reset: focus is back on Reset all data", await ev("document.activeElement?.classList.contains('help-wipe')"), await ev("document.activeElement?.className")); +await enter(); +await sleep(300); +await ev("document.querySelector('dialog.dialog-wipe .wipe-cancel').click()"); +await sleep(400); +check("reset: Cancel closes it and returns focus", (await ev("!document.querySelector('dialog.dialog-wipe[open]')")) && (await ev("document.activeElement?.classList.contains('help-wipe')"))); +await esc(); +await sleep(300); + +} + +if (wipe === "wipe") { + await load("#/"); + await ev("document.querySelector('.sidebar-help').click()"); + await sleep(300); + await ev("document.querySelector('dialog.dialog-help .help-wipe').click()"); + await sleep(300); + word = await ev("document.querySelector('dialog.dialog-wipe label').innerText.match(/[A-ZÆØÅ]{4,}/)?.[0] ?? ''"); + await ev("document.getElementById('wipe-word').focus()"); + await type(word); + const wipedAt = Date.now(); + await enter(); + for (let i = 0; i < 60 && !(await introOpen()); i++) await sleep(250); + check("reset: after the reset the app reloads fresh and the intro opens", await introOpen()); + const get = (path) => ev(`fetch('${path}', { headers: { 'X-RootCause-Token': document.querySelector('meta[name=rootcause-token]').content } }).then((r) => r.json())`); + let ov; + for (let i = 0; i < 20 && !ov?.stats; i++) { + ov = await get("/api/overview").catch(() => undefined); + if (!ov?.stats) await sleep(250); + } + // Where the server builds the statistics they are rebuilt at once, so + // built_at is either never (the cluster) or after the reset. + const builtAt = ov?.stats?.built_at; + check("reset: no session, statistics never built or rebuilt since", ov?.active === null && (builtAt === null || Date.parse(builtAt) >= wipedAt - 1000), JSON.stringify({ active: ov?.active, built_at: builtAt })); + const history = await get("/api/history"); + check("reset: the history is empty", Array.isArray(history) && history.length === 0, `${history?.length} rows`); + check("reset: stored preferences were cleared", (await ev("localStorage.getItem('rootcause.prefs')")) === null); +} + +} catch (e) { + check("shell checks ran to the end", false, String(e).slice(0, 300)); +} + +// Leave the intro finished for the checks that run after this one. +await ev("localStorage.setItem('rootcause.prefs', JSON.stringify({ introDone: true }))"); +console.log(results.join("\n")); +await fetch(`http://127.0.0.1:${port}/json/close/${target.id}`); +ws.close(); diff --git a/scripts/ui/shots.mjs b/scripts/ui/shots.mjs index 87d5d2e..82bbf55 100644 --- a/scripts/ui/shots.mjs +++ b/scripts/ui/shots.mjs @@ -3,7 +3,7 @@ // --remote-debugging-port on 127.0.0.1. No npm packages. // // node shots.mjs -// plan.json: [{name, url, width, height, scheme, motion, waitFor, wait, fullPage}] +// plan.json: [{name, url, width, height, scale, scheme, motion, waitFor, wait, fullPage}] import { writeFileSync, readFileSync } from "node:fs"; import { join } from "node:path"; @@ -46,7 +46,7 @@ await send("Log.enable"); const results = []; for (const s of plan) { consoleErrors.length = 0; - await send("Emulation.setDeviceMetricsOverride", { width: s.width, height: s.height, deviceScaleFactor: 1, mobile: s.width < 600 }); + await send("Emulation.setDeviceMetricsOverride", { width: s.width, height: s.height, deviceScaleFactor: s.scale ?? 1, mobile: s.width < 600 }); await send("Emulation.setEmulatedMedia", { features: [ { name: "prefers-color-scheme", value: s.scheme ?? "light" }, { name: "prefers-reduced-motion", value: s.motion ?? "no-preference" }, diff --git a/web/src/App.tsx b/web/src/App.tsx index eb33b86..c48b22a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,7 +3,8 @@ import { api, type Lang, type Strings } from "./api"; import { usePoll, useRoute, useTheme } from "./hooks"; import { I18nContext, makeI18n } from "./i18n"; import { usePrefs } from "./prefs"; -import { Palette, ShortcutsDialog, type PaletteItem } from "./shell/Overlays"; +import { Intro } from "./shell/Intro"; +import { HelpDialog, Palette, type PaletteItem } from "./shell/Overlays"; import { paletteEvent, useShortcuts } from "./shell/keyboard"; import { Sidebar } from "./shell/Sidebar"; import { LiveRegionProvider } from "./ui/kit"; @@ -30,6 +31,15 @@ export function App() { const [startingHere, setStartingHere] = useState(false); const [palette, setPalette] = useState(false); const [help, setHelp] = useState(false); + // The sidebar animates only once the learner toggles it, never on load. + const [sidebarMotion, setSidebarMotion] = useState(false); + const setCollapsed = useCallback( + (c: boolean) => { + setSidebarMotion(true); + setPrefs({ sidebarCollapsed: c }); + }, + [setPrefs], + ); const overview = usePoll(api.overview, startingHere ? startingPollMs : overviewPollMs, lang ?? ""); @@ -66,7 +76,7 @@ export function App() { window.location.hash = hash; }, []), help: useCallback(() => setHelp(true), []), - toggleSidebar: useCallback(() => setPrefs({ sidebarCollapsed: !prefs.sidebarCollapsed }), [setPrefs, prefs.sidebarCollapsed]), + toggleSidebar: useCallback(() => setCollapsed(!prefs.sidebarCollapsed), [setCollapsed, prefs.sidebarCollapsed]), }); useEffect(() => { const open = () => setPalette(true); @@ -98,10 +108,10 @@ export function App() { return [ { id: "act-lang", icon: "flag", label: t("web.palette.switch_lang"), hint: lang === "no" ? "EN" : "NO", run: () => setLang(lang === "no" ? "en" : "no") }, { id: "act-theme", icon: theme === "dark" ? "sun" : "moon", label: t("web.palette.switch_theme"), run: () => setTheme(theme === "dark" ? "light" : "dark") }, - { id: "act-sidebar", icon: "panel", label: t("web.shortcuts.sidebar"), hint: "[", run: () => setPrefs({ sidebarCollapsed: !prefs.sidebarCollapsed }) }, - { id: "act-help", icon: "keyboard", label: t("web.shortcuts.title"), hint: "?", run: () => setHelp(true) }, + { id: "act-sidebar", icon: "panel", label: t("web.shortcuts.sidebar"), hint: "[", run: () => setCollapsed(!prefs.sidebarCollapsed) }, + { id: "act-help", icon: "help", label: t("web.help.title"), hint: "?", run: () => setHelp(true) }, ]; - }, [i18n, lang, theme, setLang, setTheme, setPrefs, prefs.sidebarCollapsed]); + }, [i18n, lang, theme, setLang, setTheme, setCollapsed, prefs.sidebarCollapsed]); if (!i18n) { // No strings yet, so nothing translated can be shown. A failure here @@ -121,15 +131,15 @@ export function App() { {i18n.t("web.skip")} -
+
setPrefs({ sidebarCollapsed: c })} - onShortcuts={() => setHelp(true)} + onCollapse={setCollapsed} + onHelp={() => setHelp(true)} />
{offline && ( @@ -143,7 +153,7 @@ export function App() {
)} {route.name === "home" && ( - + )} {route.name === "exercises" && ( @@ -163,7 +173,17 @@ export function App() { )}
- setHelp(false)} singleKeys={prefs.shortcuts} onSingleKeys={(on) => setPrefs({ shortcuts: on })} /> + setHelp(false)} + singleKeys={prefs.shortcuts} + onSingleKeys={(on) => setPrefs({ shortcuts: on })} + onIntro={() => { + setHelp(false); + setPrefs({ introDone: false }); + }} + /> + setPrefs({ introDone: true })} /> setPalette(false)} overview={overview.data} actions={paletteActions} /> diff --git a/web/src/api.ts b/web/src/api.ts index eadc1f1..46ef8c2 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -154,4 +154,6 @@ export const api = { check: (session: string) => request("POST", "/api/check", { session }), reset: (session: string) => request("POST", "/api/reset", { session }), giveUp: (session: string) => request("POST", "/api/giveup", { session }), + // wipe deletes all of the learner's data; the server needs confirm "RESET". + wipe: () => request<{ sandboxes: number }>("POST", "/api/data/reset", { confirm: "RESET" }), }; diff --git a/web/src/prefs.ts b/web/src/prefs.ts index b6a6f17..76c2ad5 100644 --- a/web/src/prefs.ts +++ b/web/src/prefs.ts @@ -7,12 +7,24 @@ export interface Prefs { sidebarCollapsed: boolean; shortcuts: boolean; // the single-key shortcuts (g i, ?), WCAG 2.1.4 timer: boolean; // show the elapsed time in a session - introDismissed: boolean; + introDone: boolean; // the guided intro was finished or skipped } -const defaults: Prefs = { sidebarCollapsed: false, shortcuts: true, timer: false, introDismissed: false }; +const defaults: Prefs = { sidebarCollapsed: false, shortcuts: true, timer: false, introDone: false }; const key = "rootcause.prefs"; +// clearPrefs removes every preference this app stored, for "Reset all data". +export function clearPrefs() { + try { + for (let i = window.localStorage.length - 1; i >= 0; i--) { + const k = window.localStorage.key(i); + if (k?.startsWith("rootcause.")) window.localStorage.removeItem(k); + } + } catch { + // blocked: nothing was stored + } +} + function read(): Prefs { try { const raw = window.localStorage.getItem(key); diff --git a/web/src/shell/Intro.tsx b/web/src/shell/Intro.tsx new file mode 100644 index 0000000..c9af50f --- /dev/null +++ b/web/src/shell/Intro.tsx @@ -0,0 +1,153 @@ +import { useEffect, useRef, useState } from "react"; +import { useI18n } from "../i18n"; +import { Icon } from "../ui/icons"; +import { Dialog } from "./Dialog"; + +const steps = ["inbox", "session", "hints", "levels"] as const; +type Step = (typeof steps)[number]; + +// Intro is the guided first-run tour: four steps in a modal dialog, with +// Back, Next and Skip. It opens on a browser's first visit (no stored +// preferences) and again from the help dialog. Esc, Skip and the last step's +// button all end it, and end calls onDone. +export function Intro({ open, onDone }: { open: boolean; onDone: () => void }) { + const { t } = useI18n(); + const [index, setIndex] = useState(0); + const next = useRef(null); + // Dialog (a child, so its effect runs first) has opened the dialog by now; + // start on Next rather than on Skip, the first button. + useEffect(() => { + if (!open) return; + setIndex(0); + next.current?.focus(); + }, [open]); + // After Back or Next, focus is on the primary button of the new step (the + // Back button is gone on the first step). + const moved = useRef(false); + useEffect(() => { + if (moved.current) next.current?.focus(); + moved.current = false; + }, [index]); + const step = steps[index] ?? "inbox"; + const last = index === steps.length - 1; + const go = (i: number) => { + moved.current = true; + setIndex(i); + }; + return ( + +
+ +
+

+ {t("web.intro.step", { n: index + 1, total: steps.length })} +

+

{t(`web.intro.${step}.title`)}

+

{t(`web.intro.${step}.body`)}

+
+ +
+
+ +
+ + {index > 0 && ( + + )} + {last ? ( +
+ +
+ ) : ( + + )} +
+
+
+ ); +} + +// Art is each step's small drawing of the screen it is about, in the theme's +// colours. +function Art({ step }: { step: Step }) { + switch (step) { + case "inbox": + return ( + + + + + + + {[52, 82].map((y) => ( + + + + + + + ))} + + ); + case "session": + return ( + + + + {[38, 46, 54, 62].map((y) => ( + + ))} + + + + + + + ); + case "hints": + return ( + + {[0, 1, 2].map((i) => ( + + ))} + {[0, 1, 2].map((i) => ( + + ))} + + + ); + default: + return ( + + {[66, 33, 100].map((fill, i) => ( + + + + + ))} + {[8, 14, 10, 20, 16, 24].map((h, i) => ( + + ))} + + ); + } +} diff --git a/web/src/shell/Overlays.tsx b/web/src/shell/Overlays.tsx index 2c3c5b8..330171c 100644 --- a/web/src/shell/Overlays.tsx +++ b/web/src/shell/Overlays.tsx @@ -2,13 +2,29 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { api, type ExerciseRow, type Overview } from "../api"; import { sessionHref } from "../hooks"; import { useI18n } from "../i18n"; +import { clearPrefs } from "../prefs"; import { Icon, type IconName } from "../ui/icons"; +import { Banner, Button } from "../ui/kit"; import { Dialog } from "./Dialog"; -// ShortcutsDialog lists the keyboard shortcuts and switches the single-key -// ones on or off (WCAG 2.1.4). -export function ShortcutsDialog({ open, onClose, singleKeys, onSingleKeys }: { open: boolean; onClose: () => void; singleKeys: boolean; onSingleKeys: (on: boolean) => void }) { +// HelpDialog is the "?" overlay: the keyboard shortcuts and the switch for +// the single-key ones (WCAG 2.1.4), a way to see the intro again, and the +// "Reset all data" control, which opens WipeDialog on top of it. +export function HelpDialog({ + open, + onClose, + singleKeys, + onSingleKeys, + onIntro, +}: { + open: boolean; + onClose: () => void; + singleKeys: boolean; + onSingleKeys: (on: boolean) => void; + onIntro: () => void; +}) { const { t } = useI18n(); + const [wipe, setWipe] = useState(false); const rows: [string, string][] = [ ["Ctrl K", "web.shortcuts.palette"], ["Ctrl Enter", "web.shortcuts.check"], @@ -20,36 +36,131 @@ export function ShortcutsDialog({ open, onClose, singleKeys, onSingleKeys }: { o ["?", "web.shortcuts.help"], ]; return ( - -
-
-

{t("web.shortcuts.title")}

-
- -
-
- - - {rows.map(([keys, label]) => ( - - - - - ))} - -
- {keys.split(" ").map((k) => ( - {k} - ))} - {t(label)}
- -

{t("web.shortcuts.note")}

-
+ <> + +
+
+

{t("web.help.title")}

+
+ +
+
+
+

{t("web.shortcuts.title")}

+ + + {rows.map(([keys, label]) => ( + + + + + ))} + +
+ {keys.split(" ").map((k) => ( + {k} + ))} + {t(label)}
+ +

{t("web.shortcuts.note")}

+
+
+

{t("web.help.intro_title")}

+
+ +
+
+
+

{t("web.help.data_title")}

+

{t("web.help.data_body")}

+
+ +
+
+
+
+ setWipe(false)} /> + + ); +} + +// WipeDialog confirms "Reset all data": the learner types the confirmation +// word before the button works. On success it clears this browser's stored +// preferences and loads the app fresh, so the intro opens again. +function WipeDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + const { t } = useI18n(); + const [typed, setTyped] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const word = t("web.wipe.word"); + useEffect(() => { + if (!open) return; + setTyped(""); + setError(""); + }, [open]); + const ready = typed.trim() === word && !busy; + const run = () => { + if (!ready) return; + setBusy(true); + setError(""); + api.wipe().then( + () => { + clearPrefs(); + window.location.replace(window.location.pathname); + }, + (e: unknown) => { + setBusy(false); + setError(e instanceof Error ? e.message : String(e)); + }, + ); + }; + return ( + +
{ + e.preventDefault(); + run(); + }} + > +

{t("web.wipe.title")}

+

{t("web.wipe.body")}

+
+ + setTyped(e.target.value)} + /> +
+ {error && {error}} +
+ + + +
+
); } diff --git a/web/src/shell/Sidebar.tsx b/web/src/shell/Sidebar.tsx index 5e32946..03793d8 100644 --- a/web/src/shell/Sidebar.tsx +++ b/web/src/shell/Sidebar.tsx @@ -11,7 +11,7 @@ interface Props { onTheme: (t: Theme) => void; collapsed: boolean; onCollapse: (collapsed: boolean) => void; - onShortcuts: () => void; + onHelp: () => void; } const pages: { name: Route["name"]; href: string; icon: IconName; label: string }[] = [ @@ -21,17 +21,24 @@ const pages: { name: Route["name"]; href: string; icon: IconName; label: string { name: "stats", href: "#/stats", icon: "stats", label: "web.nav.stats" }, ]; +const themes: Theme[] = ["system", "light", "dark"]; +const themeIcons: Record = { system: "monitor", light: "sun", dark: "moon" }; + // Sidebar is the app's navigation (spec section 11): the pages, the active -// session, the topic level rings, and the language and theme switches. It -// collapses to icons on a desktop and becomes a top bar on a phone. -export function Sidebar({ route, overview, theme, onTheme, collapsed, onCollapse, onShortcuts }: Props) { +// session, the topic level rings, the language and theme switches, and the +// help button. On a desktop it collapses to a rail of icons: the rings go, +// and the two switches become one button each that moves to the next value. +// On a phone it becomes a top bar. +export function Sidebar({ route, overview, theme, onTheme, collapsed, onCollapse, onHelp }: Props) { const { t, lang, setLang } = useI18n(); const levelLabel = useLevelLabel(); const active = overview?.active; + const otherLang: Lang = lang === "no" ? "en" : "no"; + const nextTheme = themes[(themes.indexOf(theme) + 1) % themes.length] ?? "system"; return ( diff --git a/web/src/styles.css b/web/src/styles.css index afabacc..875c3f4 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -133,6 +133,9 @@ pre, /* --- layout ------------------------------------------------------------- */ +/* The sidebar is 232 px wide, or a 64 px rail when collapsed: 12 px either + side of a 40 px control. Width and labels animate only after the learner + first toggles it (.is-animating), never on the first paint. */ .app { display: grid; grid-template-columns: 232px minmax(0, 1fr); @@ -140,7 +143,10 @@ pre, min-height: 100dvh; } .app.is-collapsed { - grid-template-columns: 60px minmax(0, 1fr); + grid-template-columns: 64px minmax(0, 1fr); +} +.app.is-animating { + transition: grid-template-columns var(--dur-shared) var(--ease-out); } .main { @@ -149,7 +155,8 @@ pre, } .page { - max-width: 1240px; + max-width: 1440px; + margin-inline: auto; padding: var(--space-8) var(--space-8) var(--space-16); display: grid; gap: var(--space-6); @@ -218,8 +225,12 @@ pre, padding: var(--space-4) var(--space-3); background: var(--color-surface); border-right: var(--hairline); + overflow-x: hidden; overflow-y: auto; } +.sidebar.is-collapsed { + overflow: hidden; +} .sidebar-head { display: flex; @@ -233,6 +244,7 @@ pre, display: flex; align-items: center; gap: var(--space-2); + min-width: 0; padding: 0 var(--space-2); color: var(--color-text); text-decoration: none; @@ -242,6 +254,12 @@ pre, .brand:hover { color: var(--color-text); } +.brand-name { + white-space: nowrap; +} +.mark { + flex: none; +} .mark path { stroke-width: 2.4; stroke-linecap: round; @@ -270,15 +288,18 @@ pre, margin: 0; padding: 0; display: grid; + grid-template-columns: minmax(0, 1fr); gap: 2px; } +/* The icon sits 11 px in, centred in a 40 px rail control, so it does not + move when the sidebar collapses; the label beside it fades out. */ .nav-item { position: relative; display: flex; align-items: center; gap: var(--space-3); min-height: var(--hit-min); - padding: 0 var(--space-3); + padding: 0 11px; border-radius: var(--radius-control); color: var(--color-text-secondary); text-decoration: none; @@ -286,6 +307,10 @@ pre, font-weight: 500; transition: background-color var(--dur-instant) var(--ease-out), color var(--dur-instant) var(--ease-out); } +.nav-item > .icon, +.nav-item > .live-dot { + flex: none; +} .nav-item:hover { background: var(--color-surface-hover); color: var(--color-text); @@ -294,14 +319,16 @@ pre, color: var(--color-text); background: var(--color-surface-sunken); } +/* The current page's indicator is at the sidebar's left edge, in the 12 px + gutter before the item, in both states. */ .nav-item::before { content: ""; position: absolute; - left: 0; + left: calc(-1 * var(--space-3)); top: 8px; bottom: 8px; - width: 2px; - border-radius: 2px; + width: 3px; + border-radius: 0 2px 2px 0; background: var(--color-accent); scale: 1 0; transition: scale var(--dur-tab) var(--ease-out); @@ -312,6 +339,8 @@ pre, .nav-label { display: grid; min-width: 0; + overflow: hidden; + white-space: nowrap; } .nav-sub { font-size: var(--text-min); @@ -337,6 +366,7 @@ pre, .sidebar-levels h2 { padding: 0 var(--space-3); margin-bottom: var(--space-2); + white-space: nowrap; } .sidebar-levels ul { list-style: none; @@ -349,9 +379,16 @@ pre, display: flex; align-items: center; gap: var(--space-3); + min-width: 0; font-size: var(--text-ui); } +.sidebar-levels .ring { + flex: none; +} .ring-topic { + min-width: 0; + overflow: hidden; + white-space: nowrap; color: var(--color-text-secondary); } @@ -364,28 +401,57 @@ pre, padding: var(--space-3) var(--space-2) 0; border-top: var(--hairline); } +.sidebar-help { + margin-left: auto; +} +.rail-lang { + position: relative; + font: 600 var(--text-min) / 1 var(--font-sans); + letter-spacing: var(--tracking-label); +} -.app.is-collapsed .brand-name, -.app.is-collapsed .nav-label, -.app.is-collapsed .ring-topic, -.app.is-collapsed .sidebar-levels h2 { +/* Collapsed: the mark over the toggle, the icons, then the compact + language, theme and help buttons in one column. The levels are gone. */ +.sidebar.is-collapsed .sidebar-head { + flex-direction: column; +} +.sidebar.is-collapsed .brand { + width: var(--hit-min); + padding: 0 8px; + overflow: hidden; +} +.sidebar.is-collapsed .nav-item { + gap: 0; +} +.sidebar.is-collapsed .brand-name, +.sidebar.is-collapsed .nav-label { + opacity: 0; + translate: -4px 0; +} +.sidebar.is-collapsed .sidebar-levels { display: none; } -.app.is-collapsed .sidebar-head { +.sidebar.is-collapsed .sidebar-foot { flex-direction: column; + flex-wrap: nowrap; + padding: var(--space-3) 0 0; } -.app.is-collapsed .nav-item { - justify-content: center; - padding: 0; +.sidebar.is-collapsed .sidebar-help { + margin-left: 0; } -.app.is-collapsed .sidebar-levels ul { - justify-items: center; + +.is-animating .brand-name, +.is-animating .nav-label { + transition: opacity var(--dur-shared) var(--ease-out), translate var(--dur-shared) var(--ease-out); } -.app.is-collapsed .sidebar-foot { - flex-direction: column; +.is-animating .sidebar-levels, +.is-animating .sidebar-foot > * { + animation: fade-in var(--dur-shared) var(--ease-out) both; } -.app.is-collapsed .seg { - flex-direction: column; +@keyframes fade-in { + from { + opacity: 0; + } } /* --- controls ------------------------------------------------------------ */ @@ -428,6 +494,20 @@ pre, border-color: var(--color-accent-hover); color: var(--color-text-on-accent); } +.btn-danger { + background: var(--color-fail); + border-color: var(--color-fail); + color: var(--color-text-on-accent); +} +.btn-danger:hover:not(:disabled) { + background: color-mix(in oklch, var(--color-fail) 85%, var(--color-text)); + border-color: color-mix(in oklch, var(--color-fail) 85%, var(--color-text)); + color: var(--color-text-on-accent); +} +.btn-danger:disabled:hover { + background: var(--color-fail); + color: var(--color-text-on-accent); +} .btn-quiet { border-color: transparent; background: transparent; @@ -826,7 +906,7 @@ kbd { /* --- inbox --------------------------------------------------------------- */ .page-inbox { - max-width: 1040px; + max-width: 1280px; } .stats-line { @@ -840,20 +920,6 @@ kbd { color: var(--color-fail); } -.intro { - display: flex; - align-items: start; - gap: var(--space-4); - padding: var(--space-3) var(--space-4); - background: var(--color-accent-soft); - border-left: 2px solid var(--color-accent); - font-size: var(--text-ui); -} -.intro p { - flex: 1; - max-width: var(--prose-width); -} - .pinned { display: flex; align-items: center; @@ -973,7 +1039,31 @@ kbd { color: var(--color-text); } .why-chevron { - transition: rotate var(--dur-fast) var(--ease-out); + transition: rotate var(--dur-shared) var(--ease-out); +} +/* The disclosures open by growing to their height and fading in; browsers + without ::details-content open them at once. */ +.why, +.activity, +.checklog, +.chart-table { + interpolate-size: allow-keywords; +} +.why::details-content, +.activity::details-content, +.checklog::details-content, +.chart-table::details-content { + block-size: 0; + overflow: clip; + opacity: 0; + transition: block-size var(--dur-base) var(--ease-out), opacity var(--dur-base) var(--ease-out), content-visibility var(--dur-base) allow-discrete; +} +.why[open]::details-content, +.activity[open]::details-content, +.checklog[open]::details-content, +.chart-table[open]::details-content { + block-size: auto; + opacity: 1; } details[open] > summary .why-chevron { rotate: 90deg; @@ -1485,7 +1575,7 @@ a.cell-title:hover { margin: 0; padding: 0; display: grid; - grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: var(--space-6); } .rings-large li { @@ -1516,9 +1606,8 @@ a.cell-title:hover { gap: var(--space-3); } .chart-svg { + display: block; width: 100%; - height: auto; - max-height: 260px; overflow: visible; } .chart-grid { @@ -1633,6 +1722,143 @@ a.cell-title:hover { accent-color: var(--color-accent); } +.dialog-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); +} +.dialog-actions form { + margin: 0; +} +.dialog-actions-spacer { + flex: 1; +} + +.dialog-help { + width: min(600px, calc(100vw - var(--space-8))); + max-height: calc(100dvh - var(--space-8)); +} +.help-section { + display: grid; + gap: var(--space-3); + padding-top: var(--space-4); + border-top: var(--hairline); +} +.help-section .note { + max-width: var(--prose-width); +} + +.dialog-wipe h2 { + font-size: var(--text-h3); +} +.field { + display: grid; + gap: var(--space-2); + font-size: var(--text-ui); +} +.input { + min-height: var(--hit-min); + padding: 0 var(--space-3); + border: 1px solid var(--color-border-control); + border-radius: var(--radius-control); + background: var(--color-bg); + color: var(--color-text); + font-size: var(--text-body); +} +.input:focus-visible { + outline-offset: 0; +} + +.dialog-intro { + width: min(520px, calc(100vw - var(--space-8))); +} +.dialog-intro .dialog-body { + gap: var(--space-4); +} +.intro-art { + display: grid; + place-items: center; + padding: var(--space-4); + border: var(--hairline); + border-radius: var(--radius-panel); + background: var(--color-surface-sunken); + animation: fade-in var(--dur-base) var(--ease-out) both; +} +.intro-text { + display: grid; + gap: var(--space-2); + min-height: 8.5rem; + align-content: start; + animation: enter var(--dur-base) var(--ease-enter) both; +} +.intro-text p:last-child { + color: var(--color-text-secondary); +} +.intro-dots { + display: flex; + gap: var(--space-2); +} +.intro-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-border-control); + transition: background-color var(--dur-fast) var(--ease-out), scale var(--dur-fast) var(--ease-out); +} +.intro-dot.is-on { + background: var(--color-accent); + scale: 1.35; +} +.art { + width: 100%; + max-width: 280px; + height: auto; +} +.art-panel { + fill: var(--color-surface); + stroke: var(--color-border-divider); +} +.art-hi { + fill: var(--color-surface); + stroke: var(--color-accent); +} +.art-ink { + fill: var(--color-text); +} +.art-soft { + fill: var(--color-border-control); +} +.art-accent { + fill: var(--color-accent); +} +.art-line { + fill: none; + stroke: var(--color-border-control); + stroke-width: 1.5; +} +.art-star { + fill: var(--color-accent); +} +.art-term { + fill: var(--color-text); +} +.art-term-ink { + fill: var(--color-bg); + opacity: 0.6; +} +.art-ring { + fill: none; + stroke: var(--color-border-divider); + stroke-width: 5; +} +.art-ring-fill { + fill: none; + stroke: var(--color-accent); + stroke-width: 5; + stroke-linecap: round; +} + .dialog-palette { margin-top: 12vh; } @@ -1738,9 +1964,24 @@ a.cell-title:hover { .nav-session { margin-top: 0; } - .app .nav-label, - .app .brand-name { - display: grid; + .sidebar.is-collapsed { + overflow: visible; + } + .sidebar.is-collapsed .sidebar-head { + flex-direction: row; + } + .sidebar.is-collapsed .brand { + width: auto; + padding: 0 var(--space-2); + overflow: visible; + } + .sidebar.is-collapsed .nav-item { + gap: var(--space-3); + } + .sidebar.is-collapsed .nav-label, + .sidebar.is-collapsed .brand-name { + opacity: 1; + translate: none; } .nav-item { white-space: nowrap; @@ -1823,6 +2064,20 @@ a.cell-title:hover { .skeleton-bar { animation: none; } + /* The sidebar and the disclosures change size at once. */ + .app.is-animating, + .is-animating .brand-name, + .is-animating .nav-label, + .why::details-content, + .activity::details-content, + .checklog::details-content, + .chart-table::details-content { + transition: none; + } + .is-animating .sidebar-levels, + .is-animating .sidebar-foot > * { + animation: none; + } .spinner { animation-duration: 1.6s; } diff --git a/web/src/ui/icons.tsx b/web/src/ui/icons.tsx index 6257e17..baf2692 100644 --- a/web/src/ui/icons.tsx +++ b/web/src/ui/icons.tsx @@ -25,6 +25,9 @@ const paths: Record = { timer: "M12 8v5l3 2M12 4a9 9 0 1 0 0 18 9 9 0 0 0 0-18M10 2h4", flag: "M5 21V4h11l-2 4 2 4H5", arrowRight: "M5 12h14M13 6l6 6-6 6", + arrowLeft: "M19 12H5M11 6l-6 6 6 6", + help: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6v.6M12 17h.01", + trash: "M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3", }; export type IconName = keyof typeof paths; diff --git a/web/src/ui/kit.tsx b/web/src/ui/kit.tsx index 06c81f8..0684828 100644 --- a/web/src/ui/kit.tsx +++ b/web/src/ui/kit.tsx @@ -27,7 +27,7 @@ export function Mark({ size = 24 }: { size?: number }) { ); } -type Variant = "primary" | "secondary" | "quiet"; +type Variant = "primary" | "secondary" | "quiet" | "danger"; interface ButtonProps extends ButtonHTMLAttributes { variant?: Variant; diff --git a/web/src/views/Inbox.tsx b/web/src/views/Inbox.tsx index ae53599..6e880dd 100644 --- a/web/src/views/Inbox.tsx +++ b/web/src/views/Inbox.tsx @@ -2,7 +2,6 @@ import { useMemo } from "react"; import { api, type ExerciseRow, type Overview } from "../api"; import { sessionHref, usePoll } from "../hooks"; import { useI18n } from "../i18n"; -import type { Prefs } from "../prefs"; import { Icon } from "../ui/icons"; import { Banner, Button, Difficulty, Skeleton, Stars, TopicLabel } from "../ui/kit"; import { StartProgress, useStart } from "./start"; @@ -10,8 +9,6 @@ import { StartProgress, useStart } from "./start"; interface Props { overview: Overview | undefined; offline: boolean; - prefs: Prefs; - setPrefs: (p: Partial) => void; reloadOverview: () => void; onStarting: (on: boolean) => void; } @@ -27,13 +24,12 @@ interface Row { // Inbox is the home page (spec section 11): the tickets as a helpdesk inbox. // The recommended one is pinned first with why it was picked; a session in // progress is pinned above everything. -export function Inbox({ overview, offline, prefs, setPrefs, reloadOverview, onStarting }: Props) { +export function Inbox({ overview, offline, reloadOverview, onStarting }: Props) { const { t } = useI18n(); const list = usePoll(api.exercises, 15000, "inbox"); const { start, busy, error } = useStart(reloadOverview, onStarting); const rows = useMemo(() => (overview && list.data ? inboxRows(overview, list.data) : null), [overview, list.data]); - const firstRun = list.data !== undefined && list.data.every((e) => e.last === null); const active = overview?.active ?? null; const starting = overview?.starting ?? null; const locked = active !== null || starting !== null || busy !== null || offline; @@ -49,15 +45,6 @@ export function Inbox({ overview, offline, prefs, setPrefs, reloadOverview, onSt {error && {error}} - {firstRun && !prefs.introDismissed && ( -
-

{t("web.inbox.intro")}

- -
- )} - {active && (