diff --git a/Taskfile.yml b/Taskfile.yml index c4584c1..6a21375 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -349,7 +349,7 @@ tasks: - go run ./cmd/image dev-down theme:dev: - desc: "Build the local Orchis checkout (ORCHIS, in .env) into the machine task dev:up runs - GNOME Shell loads it, construct theme copies GTK's - and take the shell's views against the needles: _output/test/look.html (task theme:dev -- -type adds the type matrix). About a minute; the image is still the source of truth (pin, rootfs, stick)." + desc: "Build the local Orchis checkout (ORCHIS, in .env) into the machine task dev:up runs - GNOME Shell loads it, construct theme copies GTK's - and take the shell's views against the needles: _output/test/look.html (task theme:dev -- -type adds the type matrix; -only views,panel:network,org.gnome.Nautilus.desktop adds GTK's views and those apps and panels, as test -only names them). About a minute; the image is still the source of truth (pin, rootfs, stick)." cmds: - go run ./cmd/image theme-dev -orchis "{{.ORCHIS}}" -needles "{{.NEEDLES}}" {{.CLI_ARGS}} @@ -408,10 +408,15 @@ tasks: - go run ./cmd/fork sync {{.CLI_ARGS}} fork:check: - desc: "A fork's tree holds: no conflict left, every .js and .ts parses, the schemas compile, JSON and XML parse, metadata declares GNOME Shell 51 alone, the recipe's patches apply: task fork:check -- gnome-shell-extension-vitals" + desc: "A fork's tree holds: no conflict left, every .js and .ts parses, the schemas compile, JSON and XML parse, metadata declares GNOME Shell 51 alone, the recipe's patches apply, its copies of brand's files are the image's brand commit's: task fork:check -- gnome-shell-extension-vitals" cmds: - go run ./cmd/fork check {{.CLI_ARGS}} + fork:brand: + desc: "Write a fork's copies of brand's files - Orchis' palette SCSS, Bibata's palette JSON - from Construct-Linux/brand at the commit recipes/construct-brand.yaml pins, and its brand.ref: task fork:brand -- orchis-theme. fork:check fails a copy other than that commit's." + cmds: + - go run ./cmd/fork brand {{.CLI_ARGS}} + fork:pin: desc: "Move a recipe to its fork's head, which must carry upstream's: upstream's version when newer, a new epoch otherwise: task fork:pin -- gnome-shell-extension-vitals. The comment above git-checkout is then a person's to update." cmds: diff --git a/build/versions.env b/build/versions.env index 7dd4c56..038d233 100644 --- a/build/versions.env +++ b/build/versions.env @@ -7,8 +7,8 @@ WOLFI_BASE_IMAGE=cgr.dev/chainguard/wolfi-base:latest@sha256:88a20e2d017bd20cdc0 # The kernel, from kernel.org. A new series can fail the fragments' assertions; Arch's base config # (kernel/Dockerfile) is pinned to the same version, or Arch's newest while it has none for this one. -KERNEL_VERSION=7.3.1 -KERNEL_SHA256=fa595aad93734f829c963c2a90ef7c3673224fd7fdae7b49406e397cde2de231 +KERNEL_VERSION=7.3.3 +KERNEL_SHA256=79c66dde66cd96877fcde8f7d36aec946b1f423d2b97b5a7b1751b75c764f3d1 # Firmware (firmware/Dockerfile): linux-firmware's release, and Intel's microcode from its own. LINUX_FIRMWARE_VERSION=20261019 diff --git a/cmd/fork/brand.go b/cmd/fork/brand.go new file mode 100644 index 0000000..29dd4af --- /dev/null +++ b/cmd/fork/brand.go @@ -0,0 +1,161 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Construct-Linux/os/internal/forks" +) + +// brandCopy is a file a fork holds a copy of from Construct-Linux/brand rather than reading it at +// build: palette.py's outputs, which brand checks (contrast, perception) before it writes them. +type brandCopy struct { + from, to string + // comment is how a line comment starts in the copy, for its provenance header; "" for a + // format with none (JSON), whose provenance is brand.ref's alone. + comment string +} + +// brandCopies are the forks that copy brand's files, by repository. Every copy comes from the +// one brand commit recipes/construct-brand.yaml pins - the commit the image's palette.json, +// Ghostty's and VS Code's themes come from - so the theme, the cursor and the rest of the image +// cannot hold two palettes. The fork records that commit in brand.ref. +var brandCopies = map[string][]brandCopy{ + "Orchis-theme": {{from: "palette/construct.scss", to: "src/_sass/_construct-palette.scss", comment: "//"}}, + "Bibata_Cursor": {{from: "palette/construct.json", to: "construct-palette.json"}}, +} + +const brandRef = "brand.ref" + +// fetcher reads a file of a repository under Construct-Linux at a commit. +type fetcher func(ctx context.Context, repo, commit, path string) ([]byte, error) + +// rawGitHub reads it from raw.githubusercontent.com: brand is public, and a commit's file there +// is that commit's, whatever has been pushed since. +func rawGitHub(ctx context.Context, repo, commit, path string) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + url := fmt.Sprintf("https://raw.githubusercontent.com/Construct-Linux/%s/%s/%s", repo, commit, path) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s: %s", url, resp.Status) + } + return io.ReadAll(resp.Body) +} + +// brandPin is the brand commit recipes/construct-brand.yaml builds. +func brandPin(recipes string) (string, error) { + pin, err := forks.Find(recipes, "construct-brand") + if err != nil { + return "", err + } + if pin.Name != "brand" { + return "", fmt.Errorf("%s checks out %s, not brand", pin.Recipe, pin.Name) + } + return pin.Commit, nil +} + +// copied is a copy's bytes as the fork holds them: brand's file under a header naming where it +// came from, where the format has comments. +func copied(c brandCopy, commit string, raw []byte) []byte { + if c.comment == "" { + return raw + } + header := fmt.Sprintf("%s Copied from Construct-Linux/brand at brand.ref's commit (%s): its %s.\n"+ + "%s Change the colours in brand's palette.toml, pin brand in os, then task fork:brand.\n", + c.comment, commit[:12], c.from, c.comment) + return append([]byte(header), raw...) +} + +// writeBrand writes the fork's copies from brand at commit, and brand.ref. +func writeBrand(ctx context.Context, fetch fetcher, dir, repo, commit string) error { + for _, c := range brandCopies[repo] { + raw, err := fetch(ctx, "brand", commit, c.from) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dir, c.to), copied(c, commit, raw), 0o644); err != nil { + return err + } + } + return os.WriteFile(filepath.Join(dir, brandRef), []byte(commit+"\n"), 0o644) +} + +// checkBrand is what is wrong with the fork's copies: brand.ref other than the image's brand +// commit, or a copy other than brand's file at it. +func checkBrand(ctx context.Context, fetch fetcher, dir, repo, commit string) ([]string, error) { + cs := brandCopies[repo] + if len(cs) == 0 { + return nil, nil + } + var problems []string + ref, err := os.ReadFile(filepath.Join(dir, brandRef)) + switch { + case errors.Is(err, os.ErrNotExist): + return []string{brandRef + " missing: task fork:brand writes it with the copies"}, nil + case err != nil: + return nil, err + } + if got := strings.TrimSpace(string(ref)); got != commit { + problems = append(problems, fmt.Sprintf("%s is %.12s, the image's brand (recipes/construct-brand.yaml) is %.12s: task fork:brand", brandRef, got, commit)) + } + for _, c := range cs { + raw, err := fetch(ctx, "brand", commit, c.from) + if err != nil { + return nil, err + } + have, err := os.ReadFile(filepath.Join(dir, c.to)) + if err != nil { + return nil, err + } + if string(have) != string(copied(c, commit, raw)) { + problems = append(problems, fmt.Sprintf("%s is not brand's %s at %.12s: task fork:brand", c.to, c.from, commit)) + } + } + return problems, nil +} + +// brandCmd (fork brand ) writes the fork's copies of brand's files at the commit the +// image pins brand to. +func brandCmd(ctx context.Context, args []string, out io.Writer) error { + fl := flag.NewFlagSet("brand", flag.ContinueOnError) + var w where + w.flags(fl) + name, err := parse(fl, args) + if err != nil { + return err + } + pin, err := forks.Find(w.recipes, name) + if err != nil { + return err + } + if len(brandCopies[pin.Name]) == 0 { + return fmt.Errorf("%s holds no copy of brand's files", pin.Name) + } + commit, err := brandPin(w.recipes) + if err != nil { + return err + } + dir := filepath.Join(w.forks, pin.Name) + if err := writeBrand(ctx, rawGitHub, dir, pin.Name, commit); err != nil { + return err + } + fmt.Fprintf(out, "%s: brand's files at %.12s, and %s\n", dir, commit, brandRef) + return nil +} diff --git a/cmd/fork/brand_test.go b/cmd/fork/brand_test.go new file mode 100644 index 0000000..9bcb804 --- /dev/null +++ b/cmd/fork/brand_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// brandAt is a fake GitHub: brand's files by commit. +func brandAt(files map[string]map[string]string) fetcher { + return func(_ context.Context, repo, commit, path string) ([]byte, error) { + if repo != "brand" { + return nil, os.ErrNotExist + } + b, ok := files[commit][path] + if !ok { + return nil, os.ErrNotExist + } + return []byte(b), nil + } +} + +const ( + pinned = "1111111111111111111111111111111111111111" + older = "2222222222222222222222222222222222222222" +) + +var brandFiles = brandAt(map[string]map[string]string{ + pinned: {"palette/construct.scss": "$construct-light: #00E5FF;\n", "palette/construct.json": `{"cursor-fill": "#0B1116"}`}, + older: {"palette/construct.scss": "$construct-light: #00FFFF;\n", "palette/construct.json": `{"cursor-fill": "#000000"}`}, +}) + +func fork(t *testing.T, repo string) string { + t.Helper() + dir := t.TempDir() + for _, c := range brandCopies[repo] { + if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, c.to)), 0o755); err != nil { + t.Fatal(err) + } + } + return dir +} + +// What fork brand writes is what fork check takes, for each fork that copies brand. +func TestWrittenCopiesCheck(t *testing.T) { + ctx := context.Background() + for repo := range brandCopies { + dir := fork(t, repo) + if err := writeBrand(ctx, brandFiles, dir, repo, pinned); err != nil { + t.Fatal(err) + } + problems, err := checkBrand(ctx, brandFiles, dir, repo, pinned) + if err != nil || len(problems) > 0 { + t.Errorf("%s: %v %v", repo, problems, err) + } + } +} + +// Copies of another brand commit than the image's fail, and say so by brand.ref. +func TestCopiesOfAnotherBrandFail(t *testing.T) { + ctx := context.Background() + dir := fork(t, "Orchis-theme") + if err := writeBrand(ctx, brandFiles, dir, "Orchis-theme", older); err != nil { + t.Fatal(err) + } + problems, err := checkBrand(ctx, brandFiles, dir, "Orchis-theme", pinned) + if err != nil { + t.Fatal(err) + } + if len(problems) != 2 || !strings.Contains(problems[0], "brand.ref") { + t.Fatalf("problems = %q, want brand.ref's and the copy's", problems) + } +} + +// A copy edited by hand fails, though brand.ref is the image's. +func TestEditedCopyFails(t *testing.T) { + ctx := context.Background() + dir := fork(t, "Bibata_Cursor") + if err := writeBrand(ctx, brandFiles, dir, "Bibata_Cursor", pinned); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "construct-palette.json"), []byte(`{"cursor-fill": "#FF0000"}`), 0o644); err != nil { + t.Fatal(err) + } + problems, err := checkBrand(ctx, brandFiles, dir, "Bibata_Cursor", pinned) + if err != nil || len(problems) != 1 || !strings.Contains(problems[0], "construct-palette.json") { + t.Fatalf("problems = %q, err %v", problems, err) + } +} + +// The SCSS copy says where it came from; the JSON one is brand's bytes. +func TestCopiedHeader(t *testing.T) { + scss := string(copied(brandCopies["Orchis-theme"][0], pinned, []byte("x\n"))) + if !strings.HasPrefix(scss, "// Copied from Construct-Linux/brand") || !strings.HasSuffix(scss, "\nx\n") { + t.Errorf("scss copy = %q", scss) + } + if got := string(copied(brandCopies["Bibata_Cursor"][0], pinned, []byte("{}"))); got != "{}" { + t.Errorf("json copy = %q", got) + } +} diff --git a/cmd/fork/check.go b/cmd/fork/check.go index 5e8e278..016f94e 100644 --- a/cmd/fork/check.go +++ b/cmd/fork/check.go @@ -46,6 +46,19 @@ func checkCmd(ctx context.Context, args []string, out io.Writer) error { if marked, _ := r.lines(ctx, "grep", "-l", "-E", `^(<<<<<<< |>>>>>>> )`); len(marked) > 0 { fail("conflict markers left in: %s", strings.Join(marked, ", ")) } + if len(brandCopies[pin.Name]) > 0 { + commit, err := brandPin(w.recipes) + if err != nil { + return err + } + problems, err := checkBrand(ctx, rawGitHub, r.dir, pin.Name, commit) + if err != nil { + return err + } + for _, p := range problems { + fail("%s", p) + } + } files, err := r.lines(ctx, "ls-files") if err != nil { return err diff --git a/cmd/fork/main.go b/cmd/fork/main.go index 5cdc2cd..e212000 100644 --- a/cmd/fork/main.go +++ b/cmd/fork/main.go @@ -7,6 +7,8 @@ // XML parse, metadata declares GNOME Shell 51 alone, the recipe's patches apply (cmd/fork/check.go) // fork pin the recipe pins the fork's head, at upstream's version or a new epoch (cmd/fork/pin.go) // fork list every fork the recipes build from: recipe, repository, branch, commit, a line each +// fork brand the fork's copies of brand's files (the palette), at the brand commit the image pins +// (cmd/fork/brand.go); check fails a copy or a brand.ref other than that commit's // // A fork is upstream plus the fixes triaged against this stack, one commit each, on the branch // the recipe names; the checkout is <-forks>/, beside this repository by default, @@ -35,7 +37,8 @@ const usage = `usage: fork sync [-ours] [-recipes dir] [-forks dir] [-out dir] fork check [-recipes dir] [-forks dir] fork pin [-recipes dir] [-forks dir] [-out dir] - fork list [-recipes dir]` + fork list [-recipes dir] + fork brand [-recipes dir] [-forks dir]` // where is what every command is told: the recipes, the checkouts and the reports. type where struct { @@ -65,6 +68,8 @@ func main() { err = pinCmd(ctx, os.Args[2:], os.Stdout) case "list": err = listCmd(os.Args[2:], os.Stdout) + case "brand": + err = brandCmd(ctx, os.Args[2:], os.Stdout) default: err = fmt.Errorf("unknown command %q\n%s", os.Args[1], usage) } diff --git a/cmd/image/dev.go b/cmd/image/dev.go index ed4cff3..0ca3b50 100644 --- a/cmd/image/dev.go +++ b/cmd/image/dev.go @@ -14,6 +14,7 @@ import ( "os/exec" "os/signal" "path/filepath" + "strings" "syscall" "time" @@ -133,7 +134,12 @@ func themeDev(args []string) error { orchis := fl.String("orchis", "", "the Orchis-theme checkout to build (ORCHIS)") needles := fl.String("needles", "", "the needles checkout to compare with (NEEDLES)") withType := fl.Bool("type", false, "take the type specimen and its rendering matrix too (_output/test/type.html)") + only := fl.String("only", "", "apps and Settings panels to take too, as test -only names them, and views for GTK's own; the shell's views are always taken") _ = fl.Parse(args) + want, unknown := parseOnly(*only) + if len(unknown) > 0 { + return fmt.Errorf("theme-dev: -only names no app, panel: or views: %s", strings.Join(unknown, ", ")) + } if *orchis == "" { return errors.New("theme-dev: -orchis names an Orchis-theme checkout (ORCHIS)") } @@ -200,6 +206,18 @@ func themeDev(args []string) error { r.add("views' clock", false, "at "+viewsTime+": "+err.Error()) } r.shellViews(v, g, l) + // The apps take the theme as a person's do, from what construct theme copied: a design + // review's screens in a minute or two, where the image and the stick take half an hour. + if len(want) > 0 { + if err := g.holdReadings(); err != nil { + r.add("views' readings", false, err.Error()) + } + if want["views"] { + r.gtkViews(v, g, l, false) + r.gtkViews(v, g, l, true) + } + r.appsAndPanels(g, l, want) + } if *withType { r.typeMatrix(v, g, l) } diff --git a/cmd/image/lf.go b/cmd/image/lf.go index 7bbf2f5..f8bd8f5 100644 --- a/cmd/image/lf.go +++ b/cmd/image/lf.go @@ -41,10 +41,9 @@ const ( lfAccentTol = 14 // The calendar's today ring is 2 px on a curve: its pixels are half blends of the accent. lfRingAccentTol = 40 - // A quick toggle's title on its tint, and the focus ring's inset shadow over a toggle: thin - // accent strokes on a fill, so, like the today ring, most of their pixels are blends of it. - lfAccentTitleTol = 40 - lfFocusRingTol = 40 + // The focus ring's inset shadow over a toggle: thin accent strokes on a fill, so, like the + // today ring, most of their pixels are blends of it. + lfFocusRingTol = 40 // The ring against what is 6 px outside it: the brand's minimum edge contrast. lfEdgeContrast = 1.5 lfEdgeGap = 6 @@ -133,7 +132,7 @@ var lfTokens = paletteTokens{ colors: []string{"brand-boot", "neutral-a", "neutral-e", "neutral-f", "neutral-g", "neutral-h", "dark-border", "dark-accent", "dark-accent-text", "dark-text", "dark-muted", "dark-on-accent", "light-accent", "light-accent-text", "light-on-accent", "light-text", "light-muted", "light-desk", "light-shelf"}, - nums: []string{"tint-dark-rest", "tint-dark-hover", "tint-dark-active", "line-light-ring", "line-light-border", "shape-inset", "shape-radius-control", + nums: []string{"tint-dark-rest", "tint-dark-hover", "tint-dark-active", "tint-dark-on", "tint-dark-on-hover", "line-light-ring", "line-light-border", "shape-inset", "shape-radius-control", "shape-radius-surface", "shape-radius-sheet"}, strs: []string{"type-ui", "type-mono"}, } @@ -849,7 +848,7 @@ func (l *lf) quickSettings(img image.Image, s quickSettingsSeen) { n := pixels.CountNear(img, pixels.Rect{X: 0, Y: 0, W: img.Bounds().Dx(), H: img.Bounds().Dy()}, l.c("dark-accent"), lfAccentTol) most := lfAccentQuickSettings * l.k * l.k l.check("accent", "quick settings", n < most, fmt.Sprintf("%d px of the accent, want under %d", n, most)) - fill := pixels.Tint(l.c("dark-accent"), h, l.pal.nums["tint-dark-rest"]) + fill := pixels.Tint(l.c("dark-accent"), h, l.pal.nums["tint-dark-on"]) ground := l.c("neutral-e") var on, off int var bad, discs []string @@ -864,10 +863,10 @@ func (l *lf) quickSettings(img image.Image, s quickSettingsSeen) { on++ mode, _ := pixels.ModeColor(img, l.px(t.Look.rect()).Inset(4*l.k, 4*l.k)) if !pixels.Near(mode, fill, lfTextTol) { - bad = append(bad, fmt.Sprintf("%q on is %s, not the tint %s", t.Title.Text, pixels.Hex(mode), pixels.Hex(fill))) + bad = append(bad, fmt.Sprintf("%q on is %s, not the on tint %s", t.Title.Text, pixels.Hex(mode), pixels.Hex(fill))) } - if c := t.Title.ink(ground); !pixels.Near(c, l.c("dark-accent-text"), lfTextTol) || pixels.CountNear(img, l.px(t.Title.rect()), l.c("dark-accent-text"), lfAccentTitleTol) == 0 { - bad = append(bad, fmt.Sprintf("%q on titled %s, not the accent", t.Title.Text, pixels.Hex(c))) + if c := t.Title.ink(ground); !pixels.Near(c, l.c("dark-text"), lfTextTol) { + bad = append(bad, fmt.Sprintf("%q on titled %s, not text", t.Title.Text, pixels.Hex(c))) } if t.Subtitle != nil && !pixels.Near(t.Subtitle.ink(ground), l.c("dark-text"), lfTextTol) { bad = append(bad, fmt.Sprintf("%q on subtitled %s, not text: muted never goes on a tint", t.Title.Text, pixels.Hex(t.Subtitle.ink(ground)))) @@ -1053,7 +1052,8 @@ func (l *lf) calendar(img image.Image, s calendarSeen) { } // today is F1's today: accent_text at 700 with no ring and no box-shadow, unless it has the key -// focus; the menu opens on it, so it is the selected day too, the rest tint inside. +// focus; the menu opens on it, so it is the selected day too, and selected today is on: the on +// tint inside, text at 700. func (l *lf) today(img image.Image, days []calendarDay, selected, focus bool) { var t *lfLook for _, d := range days { @@ -1066,22 +1066,21 @@ func (l *lf) today(img image.Image, days []calendarDay, selected, focus bool) { return } h := l.c("neutral-h") - centre := h + centre, ink := h, l.c("dark-accent-text") if selected { - centre = pixels.Tint(l.c("dark-accent"), h, l.pal.nums["tint-dark-rest"]) + centre, ink = pixels.Tint(l.c("dark-accent"), h, l.pal.nums["tint-dark-on"]), l.c("dark-text") } mode, _ := pixels.ModeColor(img, l.px(t.rect()).Inset(4*l.k, 4*l.k)) share := pixels.RingShare(img, l.px(t.rect()), float64(l.k), l.c("dark-accent"), lfRingAccentTol) fg, _ := pixels.ParseHex(t.FG) - accent := l.c("dark-accent-text") shadow := t.Shadow != nil && !strings.HasSuffix(t.Shadow.Color, "00") - ok := pixels.Near(mode, centre, lfTextTol) && t.Weight == 700 && fg == color.NRGBA{accent.R, accent.G, accent.B, 255} + ok := pixels.Near(mode, centre, lfTextTol) && t.Weight == 700 && fg == color.NRGBA{ink.R, ink.G, ink.B, 255} if !focus { ok = ok && !shadow && share < lfTodayNoRing } l.check("today", "calendar", ok, fmt.Sprintf("centre %s, want %s (selected %v); %s %d, want %s 700; box-shadow %v, %.0f%% of its outline the accent, want none and under %.0f%% (focused %v)", - pixels.Hex(mode), pixels.Hex(centre), selected, t.FG, t.Weight, pixels.Hex(accent), shadow, share*100, lfTodayNoRing*100, focus)) + pixels.Hex(mode), pixels.Hex(centre), selected, t.FG, t.Weight, pixels.Hex(ink), shadow, share*100, lfTodayNoRing*100, focus)) } // focusRing is whether St's box-shadow is the key focus's ring - inset, 2 px or more, the accent @@ -1361,10 +1360,10 @@ const lfStates = lfQuickSettings + ` return lfAll(m.box, t => t._lfState !== undefined).map(t => ({state: t._lfState, look: look(t), title: look(t._title ?? null)}));` // states is S2 on the quick toggles shell-states forced into each step - on at rest, under the -// pointer, pressed: each fill is the accent at its step's tint over h, in St and on the screen, -// and each label accent_text. +// pointer, pressed: each fill is the accent at its step's tint over h, in St and on the screen - +// the on tint at rest, on-hover under the pointer and pressed - and each label text. func (l *lf) states(img image.Image, s []stateSeen) { - steps := map[string]string{"rest": "tint-dark-rest", "hover": "tint-dark-hover", "active": "tint-dark-active"} + steps := map[string]string{"rest": "tint-dark-on", "hover": "tint-dark-on-hover", "active": "tint-dark-on-hover"} h, ground, accent := l.c("neutral-h"), l.c("neutral-e"), l.c("dark-accent") seen := map[string]bool{} var bad []string @@ -1380,8 +1379,8 @@ func (l *lf) states(img image.Image, s []stateSeen) { if !pixels.Near(st, want, lfTextTol) || !pixels.Near(drawn, want, lfTextTol) { bad = append(bad, fmt.Sprintf("%s: St %s, drawn %s, want %s (%s)", t.State, pixels.Hex(st), pixels.Hex(drawn), pixels.Hex(want), tok)) } - if t.Title != nil && !pixels.Near(t.Title.ink(ground), l.c("dark-accent-text"), lfTextTol) { - bad = append(bad, fmt.Sprintf("%s: %q titled %s, not accent_text", t.State, t.Title.Text, pixels.Hex(t.Title.ink(ground)))) + if t.Title != nil && !pixels.Near(t.Title.ink(ground), l.c("dark-text"), lfTextTol) { + bad = append(bad, fmt.Sprintf("%s: %q titled %s, not text", t.State, t.Title.Text, pixels.Hex(t.Title.ink(ground)))) } } l.check("states", "quick toggles", len(seen) == len(steps) && len(bad) == 0, @@ -1851,10 +1850,10 @@ func (l *lf) guestFiles() { want := fmt.Sprintf("'%s 11'\n'%s 11'\n%s\n%s\n%s\n", ui, mono, mono, ui, ui) l.check("fonts", "G4", err == nil && fonts == want, fmt.Sprintf("font-name, monospace-font-name, fc-match monospace/sans-serif/system-ui: %s; want %s", oneLine(fonts), oneLine(want))+errText(err)) - dconf, err := l.g.run(`for k in dash-to-panel/focus-highlight-color dash-to-panel/focus-highlight-opacity dash-to-panel/trans-use-border tilingshell/window-border-color; do `+asLive+`dconf read /org/gnome/shell/extensions/$k; done`, "") + dconf, err := l.g.run(`for k in dash-to-panel/focus-highlight-color dash-to-panel/focus-highlight-opacity dash-to-panel/trans-use-border tilingshell/window-border-color tilingshell/window-use-custom-border-color; do `+asLive+`dconf read /org/gnome/shell/extensions/$k; done`, "") accent := pixels.Hex(l.c("dark-accent")) // The dock's focus highlight is the rest tint, in dash-to-panel's percent. - wantDconf := fmt.Sprintf("'%s'\n%.0f\nfalse\n'%s'\n", accent, l.pal.nums["tint-dark-rest"]*100, accent) + wantDconf := fmt.Sprintf("'%s'\n%.0f\nfalse\n'%s'\ntrue\n", accent, l.pal.nums["tint-dark-rest"]*100, accent) l.check("extensions", "G5 settings", err == nil && strings.EqualFold(dconf, wantDconf), fmt.Sprintf("%s; want %s", oneLine(dconf), oneLine(wantDconf))+errText(err)) for _, uuid := range []string{"dash-to-panel@jderose9.github.com", "tilingshell@ferrarodomenico.com", "Vitals@CoreCoding.com"} { sheet, err := l.g.run("cat /usr/share/gnome-shell/extensions/"+uuid+"/stylesheet.css", "") diff --git a/cmd/image/lf_test.go b/cmd/image/lf_test.go index b17dab1..f9268f2 100644 --- a/cmd/image/lf_test.go +++ b/cmd/image/lf_test.go @@ -60,7 +60,7 @@ const tPalette = `{"brand-boot":"#000000","neutral-e":"#0B1116","neutral-f":"#11 "dark-text":"#E6EDF2","dark-muted":"#9FB3C0","dark-on-accent":"#0B1116","light-accent":"#007A85","light-accent-text":"#00646D", "light-on-accent":"#FFFFFF","light-text":"#11181E","light-muted":"#536672","light-desk":"#DEE4E8","light-shelf":"#E9EDF0", "neutral-a":"#FFFFFF","line-light-ring":0.4,"line-light-border":0.3,"ansi-dark-blue":"#5DB2F7", -"tint-dark-rest":0.16,"tint-dark-hover":0.23,"tint-dark-active":0.28,"shape-inset":6,"shape-radius-control":9,"shape-radius-surface":15,"shape-radius-sheet":24, +"tint-dark-rest":0.16,"tint-dark-hover":0.23,"tint-dark-active":0.28,"tint-dark-on":0.36,"tint-dark-on-hover":0.4,"shape-inset":6,"shape-radius-control":9,"shape-radius-surface":15,"shape-radius-sheet":24, "shape-space":[3,6,12,18,24],"type-ui":"Adwaita Sans","type-mono":"Adwaita Mono"}` // palette.json is flat and mixed - colours, numbers, families, a list - and a token a check needs @@ -657,12 +657,12 @@ func TestStatesView(t *testing.T) { h, accent := l.c("neutral-h"), l.c("dark-accent") img := solid(640, 400, h) var s []stateSeen - for i, st := range []struct{ state, tok string }{{"rest", "tint-dark-rest"}, {"hover", "tint-dark-hover"}, {"active", "tint-dark-active"}} { + for i, st := range []struct{ state, tok string }{{"rest", "tint-dark-on"}, {"hover", "tint-dark-on-hover"}, {"active", "tint-dark-on-hover"}} { want := pixels.Tint(accent, h, l.pal.nums[st.tok]) r := pixels.Rect{X: 20 + 120*i, Y: 20, W: 100, H: 40} fill(img, r, want) look := &lfLook{X: r.X, Y: r.Y, W: r.W, H: r.H, Chain: []string{pixels.Hexa(want)}} - title := &lfLook{FG: pixels.Hexa(l.c("dark-accent-text")), Opacity: 1, Chain: []string{pixels.Hexa(want)}} + title := &lfLook{FG: pixels.Hexa(l.c("dark-text")), Opacity: 1, Chain: []string{pixels.Hexa(want)}} s = append(s, stateSeen{State: st.state, Look: look, Title: title}) } l.states(img, s) diff --git a/cmd/image/test.go b/cmd/image/test.go index c98dab5..5660221 100644 --- a/cmd/image/test.go +++ b/cmd/image/test.go @@ -66,7 +66,7 @@ var panels = []string{"wifi", "network", "bluetooth", "display", "sound", "power func test(args []string) (err error) { fl := flag.NewFlagSet("test", flag.ExitOnError) - only := fl.String("only", "", "comma-separated scenario names (app ids, panel:, views, type, bench, suspend); all by default") + only := fl.String("only", "", "comma-separated scenario names (app ids, panel:, views, installer, system, idle, type, input, bench, suspend, wifi); all by default") stick := fl.Bool("usb", false, "boot the USB disk (task usb -- -test) as a machine does, instead of the ext4 test disk") needles := fl.String("needles", "", "a checkout of github.com/Construct-Linux/needles: every screenshot is compared with its needle") _ = fl.Parse(args) @@ -138,33 +138,44 @@ func test(args []string) (err error) { _, _ = g.run(asLive+`gsettings set org.gnome.desktop.privacy remember-app-usage false`, "") _, _ = g.eval(`Main.overview.hide(); true`) time.Sleep(time.Second) - r.installer(v, g, l, *stick) - // Before the desktop is shot: the stick's icon is gone again by then. - r.keyd(v, g) - r.usbStick(v, g) - r.appFolder(g) - r.screenReader(g) - // The live user's login keyring has no password (construct live): a secret is kept and read back - // with no prompt. A plaintext keyring reads as locked until something uses it, so the check - // does what an app does; a prompt would hang it until the timeout. - keyring, _ := g.run(keyringRoundTrip("live", 1000), "") - r.add("the login keyring is open", strings.TrimSpace(keyring) == "kept", oneLine(keyring)) - if *stick { - r.secureBoot(g) - r.dbxCurrent(g) - r.efiPrograms(g) - r.loaderCache(g) + // -only runs the scenarios it names and nothing else that can be left out: the session's own + // checks (system) and its measurement at rest (idle) took a third of a run that wanted a few + // views (a design review's capture). The desktop's view and the boot's are always taken. + want, unknown := parseOnly(*only) + for _, n := range unknown { + r.add("-only "+n, false, "no app, panel:, views, installer, system, idle, type, input, bench, suspend or wifi is called that") + } + runs := func(name string) bool { return len(want) == 0 || want[name] } + if runs("installer") { + r.installer(v, g, l, *stick) + } + if runs("system") { + // Before the desktop is shot: the stick's icon is gone again by then. + r.keyd(v, g) + r.usbStick(v, g) + r.appFolder(g) + r.screenReader(g) + // The live user's login keyring has no password (construct live): a secret is kept and read + // back with no prompt. A plaintext keyring reads as locked until something uses it, so the + // check does what an app does; a prompt would hang it until the timeout. + keyring, _ := g.run(keyringRoundTrip("live", 1000), "") + r.add("the login keyring is open", strings.TrimSpace(keyring) == "kept", oneLine(keyring)) + if *stick { + r.secureBoot(g) + r.dbxCurrent(g) + r.efiPrograms(g) + r.loaderCache(g) + } } l.shot(viewMeta{"desktop", needle.GroupDesktop, "The desktop at login over the default dark wallpaper: both bars opaque g with their ring, the top bar a 36 px pill 6 px from the edge, its text Adwaita Sans 400, and no accent", []string{"S1", "SP2", "R1", "T2", "E2"}}, (*lf).viewDesktop) - r.systemChecks(g) + if runs("system") { + r.systemChecks(g) + } // With the desktop up and nothing asked of it yet: what it does by itself. - r.idle(g) - - want, unknown := parseOnly(*only) - for _, n := range unknown { - r.add("-only "+n, false, "no app, panel:, views, type, input, bench or suspend is called that") + if runs("idle") { + r.idle(g) } // Every view from here is shot at viewsTime and with the bar's readings held: Calendar's // month is the views' month, and the date menu hangs under a clock that stays where it was. @@ -174,6 +185,53 @@ func test(args []string) (err error) { if err := g.holdReadings(); err != nil { r.add("views' readings", false, err.Error()) } + r.appsAndPanels(g, l, want) + if len(want) == 0 || want["views"] { + r.gtkViews(v, g, l, false) + r.gtkViews(v, g, l, true) + r.shellViews(v, g, l) + r.unlockLeaves(v, g) + r.desktopViews(v, g, l, sc) + l.guestLook() + } + // After the views: the radios it adds put a Wi-Fi toggle in quick settings, which their + // needles do not have. + if len(want) == 0 || want["wifi"] { + r.wifi(g, l) + } + l.finish() + if len(want) == 0 || want["type"] { + r.typeMatrix(v, g, l) + } + // After the views: the layout it switches to would be the views' too. + if len(want) == 0 || want["input"] { + r.deadKeys(v, g) + } + // After every view: the recording's errors - PipeWire refusing the DMA-BUF formats a VM + // without a GPU cannot give (withoutCastFormats) - are in the journal Logs shows. + if runs("system") { + r.screencast(g) + if *stick { + r.etcCopies(g) + } + } + // What the run needed, before the bench asks for more than there is. + r.memory(g) + r.console(g) + r.diagnose(g) + if len(want) == 0 || want["bench"] { + r.bench(g) + } + if len(want) == 0 || want["suspend"] { + r.suspend(v, g) + } + r.compareNeedles(*needles) + return r.write(g) +} + +// appsAndPanels opens each app and Settings panel want names (every one when it names none), shoots +// it and checks its surfaces: test's, and theme-dev's on a machine dev-up left running. +func (r *report) appsAndPanels(g *guest, l *lf, want map[string]bool) { for _, a := range apps { if len(want) > 0 && !want[a.id] { continue @@ -207,40 +265,6 @@ func test(args []string) (err error) { []string{"S3", "TY4", "TY5"}}, nil) } g.quitApp(settingsApp) - if len(want) == 0 || want["views"] { - r.gtkViews(v, g, l, false) - r.gtkViews(v, g, l, true) - r.shellViews(v, g, l) - r.unlockLeaves(v, g) - r.desktopViews(v, g, l, sc) - l.guestLook() - } - l.finish() - if len(want) == 0 || want["type"] { - r.typeMatrix(v, g, l) - } - // After the views: the layout it switches to would be the views' too. - if len(want) == 0 || want["input"] { - r.deadKeys(v, g) - } - // After every view: the recording's errors - PipeWire refusing the DMA-BUF formats a VM - // without a GPU cannot give (withoutCastFormats) - are in the journal Logs shows. - r.screencast(g) - if *stick { - r.etcCopies(g) - } - // What the run needed, before the bench asks for more than there is. - r.memory(g) - r.console(g) - r.diagnose(g) - if len(want) == 0 || want["bench"] { - r.bench(g) - } - if len(want) == 0 || want["suspend"] { - r.suspend(v, g) - } - r.compareNeedles(*needles) - return r.write(g) } // parseOnly is the scenarios -only names, all when it names none, and those of its names nothing @@ -252,7 +276,7 @@ func parseOnly(only string) (want map[string]bool, unknown []string) { want[n] = true } } - known := map[string]bool{"views": true, "type": true, "input": true, "bench": true, "suspend": true} + known := map[string]bool{"views": true, "wifi": true, "installer": true, "system": true, "idle": true, "type": true, "input": true, "bench": true, "suspend": true} for _, a := range apps { known[a.id] = true } @@ -650,7 +674,7 @@ var shellViews = []shellView{ "A folder of the app grid open: its dialog on g with the ring and its radius, its name at the top, the apps it holds in a grid under it", []string{"S3", "E3"}, nil}, {"quick-settings", `Main.panel.statusArea.quickSettings.menu.open()`, `Main.panel.statusArea.quickSettings.menu.close()`, nil, 0, - "Quick settings: the menu on h with its ring and concentric radius, toggles on as the rest tint with the accent's 24 px disc and accent_text, off in text", + "Quick settings: the menu on h with its ring and concentric radius, toggles on as the on tint with the accent's 24 px disc and text, off in text", []string{"S2", "C3", "C4", "R1"}, (*lf).viewQuickSettings}, // The first quick toggle on screen with the key focus, once the menu is open: its focus ring, // against the view above. Hidden toggles (no Bluetooth, no Wi-Fi) come first in the tree. @@ -671,10 +695,10 @@ var shellViews = []shellView{ {"states", `const m = Main.panel.statusArea.quickSettings.menu; m.open(); GLib.timeout_add(GLib.PRIORITY_DEFAULT, 800, () => { lfAll(m.box, lfClass('quick-toggle')).filter(t => t.is_mapped() && t.reactive).slice(0, 3).forEach((t, i) => { t._lfAdded = [['checked'], ['checked', 'hover'], ['checked', 'active']][i].filter(c => !t.has_style_pseudo_class(c)); t._lfAdded.forEach(c => t.add_style_pseudo_class(c)); t._lfState = ['rest', 'hover', 'active'][i]; }); return GLib.SOURCE_REMOVE; })`, `const m = Main.panel.statusArea.quickSettings.menu; lfAll(m.box, t => t._lfState !== undefined).forEach(t => { t._lfAdded.forEach(c => t.remove_style_pseudo_class(c)); delete t._lfAdded; delete t._lfState; }); m.close()`, nil, 0, - "Quick toggles on at rest, under the pointer and pressed: each the accent's tint at its own step, the label accent_text on all three", + "Quick toggles on at rest, under the pointer and pressed: the on tint at rest, its hover step under the pointer and pressed, the label text on all three", []string{"S2", "C3"}, (*lf).viewStates}, {"calendar", `Main.panel.statusArea.dateMenu.menu.open()`, `Main.panel.statusArea.dateMenu.menu.close()`, nil, 0, - "The date menu: the calendar on h at the quick settings' height, today accent_text at 700 with no ring, the month's days in text, the other months' muted", + "The date menu: the calendar on h at the quick settings' height, today on (the on tint, text at 700) with no ring, the month's days in text, the other months' muted", []string{"S2", "F1", "C2", "R1"}, (*lf).viewCalendar}, // A day that is not today with the key focus: the ring is focus's alone (F1). {"calendar-focus", @@ -696,6 +720,9 @@ var shellViews = []shellView{ `Shell.AppSystem.get_default().lookup_app("org.gnome.Settings.desktop").request_quit(); new imports.gi.Gio.Settings({schema_id: "org.gnome.desktop.interface"}).set_string("color-scheme", "prefer-dark")`, nil, 0, "Settings in the light scheme, the shell still dark: the window on the desk with its ring line, the sidebar the shelf, the selected row the sheet, one headerbar rule across both panes, no bright accent", []string{"S5", "S6", "C11", "E3", "C2"}, (*lf).viewSettingsLight}, + // Shot 2 s after the pointer wakes it: the shell's hint ("Click or press a key") comes after 4 s + // without input, and a few seconds later the locked screen turns off - a view that waited for + // the hint was the display switched off. {"lock-screen", `Main.screenShield.lock(false)`, ``, wakePointer, 0, "The lock screen on the boot's black, its clock regular, nothing of an extension in the panel", []string{"S4", "T2"}, (*lf).viewLock}, diff --git a/cmd/image/test_test.go b/cmd/image/test_test.go index e860c8b..3d42083 100644 --- a/cmd/image/test_test.go +++ b/cmd/image/test_test.go @@ -30,4 +30,8 @@ func TestParseOnly(t *testing.T) { if want, unknown := parseOnly(""); len(want) != 0 || len(unknown) != 0 { t.Errorf("all: %v %v", want, unknown) } + // The scenarios a partial run leaves out unless it names them. + if _, unknown := parseOnly("installer,system,idle"); len(unknown) != 0 { + t.Errorf("installer, system and idle unknown: %v", unknown) + } } diff --git a/cmd/image/wifi.go b/cmd/image/wifi.go new file mode 100644 index 0000000..c7f6140 --- /dev/null +++ b/cmd/image/wifi.go @@ -0,0 +1,96 @@ +package main + +import ( + "fmt" + "strings" + "time" + + "github.com/Construct-Linux/os/internal/needle" +) + +// wifiAPs is how many networks the wifi scenario puts in the air: more than the shell lists +// (network.js shows 8 and folds the rest into "More networks"), so a list that grows with the +// networks around shows here, as it did in a busy building where the menu went off the screen. +const wifiAPs = 12 + +// wifiSetup gives the machine Wi-Fi: mac80211_hwsim's radios, one the machine's own and the rest +// access points. NetworkManager is told to leave the access points alone before they exist - its +// supplicant set up all thirteen at once and timed out on the machine's own - so the shell and +// Settings see one adapter and the networks it scans; wpa_supplicant's AP mode (CONFIG_AP, in its +// recipe) runs each network, WPA2 with a throwaway passphrase, each a unit of its own: a daemon +// the command started went with its SSH session, and the networks with it. +const wifiSetup = `set -e +mkdir -p /run/NetworkManager/conf.d /run/harness-wifi +printf '[keyfile]\nunmanaged-devices=%%s\n' "$(seq -f 'interface-name:wlan%%g' -s ';' 1 %[2]d)" > /run/NetworkManager/conf.d/harness-wifi.conf +nmcli general reload conf +modprobe mac80211_hwsim radios=%[1]d +for i in $(seq 1 50); do [ -e /sys/class/net/wlan%[2]d ] && break; sleep 0.2; done +for i in $(seq 1 %[2]d); do + cat > /run/harness-wifi/$i.conf < GLib.timeout_add(GLib.PRIORITY_DEFAULT, 800, () => r())); + const t = lfAll(m.box, lfClass('quick-toggle-has-menu')).find(t => t.is_mapped() && t.constructor.name.includes('Wireless')); + t?.menu.open(true); + await new Promise(r => GLib.timeout_add(GLib.PRIORITY_DEFAULT, 3000, () => r())); + const e = m.box.get_transformed_extents(); + const y = e.origin.y, h = e.size.height; + const items = t ? lfAll(t.menu.box, a => a.constructor.name.includes('NetworkItem') && a.is_mapped()).length : -1; + return {toggle: !!t, open: m.isOpen, mapped: m.actor.is_mapped(), y, h, stage: global.stage.height, items, + submenu: t?.menu.isOpen ?? false};` + +type wifiMenuSeen struct { + Toggle, Open, Mapped, Submenu bool + Y, H, Stage float64 + Items int +} + +// wifi is the scenario: the networks up, the machine scanning them, the shell's menu of them and +// Settings' Wi-Fi panel. +func (r *report) wifi(g *guest, l *lf) { + out, err := g.run(fmt.Sprintf(wifiSetup, wifiAPs+1, wifiAPs), "") + r.add("wifi access points", err == nil, fmt.Sprintf("%d networks by mac80211_hwsim%s%s", wifiAPs, lastLines(out, 4), errText(err))) + if err != nil { + return + } + var seenN int + ok := until(60*time.Second, 3*time.Second, func() bool { + s, _ := g.run(`nmcli -t -f SSID device wifi list ifname wlan0 --rescan yes`, "") + seenN = strings.Count(s, "Harness network") + return seenN >= wifiAPs + }) + r.add("wifi scan", ok, fmt.Sprintf("wlan0 sees %d of %d networks", seenN, wifiAPs)) + var s wifiMenuSeen + err = g.evalJSON(lfWifiMenu, &s) + time.Sleep(time.Second) + l.shot(viewMeta{"shell-quick-settings-wifi", needle.GroupShell, + "Quick settings with the Wi-Fi menu open among a dozen networks: the menu whole on the screen, the networks listed in the well", + []string{"S2", "E3"}}, nil) + r.add("wifi menu", err == nil && s.Toggle && s.Open && s.Mapped && s.Submenu && s.Items > 0 && s.Y >= 0 && s.Y+s.H <= s.Stage, + fmt.Sprintf("%+v%s", s, errText(err))) + _, _ = g.eval(lfPrelude + `const m = Main.panel.statusArea.quickSettings.menu; lfAll(m.box, lfClass('quick-toggle-has-menu')).forEach(t => t.menu?.close(false)); m.close(); true`) + time.Sleep(time.Second) + ok, detail := g.runPanel("wifi") + r.add("panel:wifi networks", ok, detail) + l.shot(viewMeta{"panel-wifi-networks", needle.GroupSettings, + "Settings' Wi-Fi panel with a dozen networks in range: the list's rows, their signal and lock", + []string{"S3", "TY4"}}, nil) + g.quitApp(settingsApp) +} diff --git a/image/apko.lock.yaml b/image/apko.lock.yaml index 299feb7..b4d95bc 100644 --- a/image/apko.lock.yaml +++ b/image/apko.lock.yaml @@ -17,7 +17,7 @@ contents: - at-spi2-core=2.62.1-r1 - at-spi2-core-lang=2.62.1-r1 - baobab=50.0-r0 - - bibata-cursor-theme=2.0.7-r5 + - bibata-cursor-theme=2.0.7-r8 - bluez=5.88-r1 - bluez-doc=5.88-r1 - bolt=0.9.11-r1 @@ -25,7 +25,7 @@ contents: - code=1.145.2-r0 - colord=1.4.8-r0 - colord-gtk=0.3.1-r0 - - construct-brand=0_git20261005-r1 + - construct-brand=0_git20261005-r4 - cracklib=2.10.4-r0 - cracklib-lang=2.10.4-r0 - cups-browsed=2.1.1-r1 @@ -118,8 +118,8 @@ contents: - gnome-shell-extension-appindicator-lang=66-r6 - gnome-shell-extension-caffeine=61-r0 - gnome-shell-extension-caffeine-lang=61-r0 - - gnome-shell-extension-clipboard-indicator=72-r0 - - gnome-shell-extension-clipboard-indicator-lang=72-r0 + - gnome-shell-extension-clipboard-indicator=72-r1 + - gnome-shell-extension-clipboard-indicator-lang=72-r1 - gnome-shell-extension-dash-to-panel=74-r9 - gnome-shell-extension-drive-menu=51.0-r0 - gnome-shell-extension-gtk4-ding=100.29_git20261002-r3 @@ -245,8 +245,8 @@ contents: - openssh-sk-helper=10.6_p1-r1 - orca=51.1-r0 - orca-lang=51.1-r0 - - orchis-theme=20260914-r17 - - orchis-theme-shell=20260914-r17 + - orchis-theme=20260914-r23 + - orchis-theme-shell=20260914-r23 - ostree=2026.6-r0 - pam-u2f=1.4.0-r0 - pangomm=2.58.0-r0 diff --git a/image/rootfs/etc/dconf/db/local.d/00-construct b/image/rootfs/etc/dconf/db/local.d/00-construct index 5745470..1f30632 100644 --- a/image/rootfs/etc/dconf/db/local.d/00-construct +++ b/image/rootfs/etc/dconf/db/local.d/00-construct @@ -113,9 +113,11 @@ dot-style-unfocused='DOTS' trans-use-border=false dot-color-override=false -# The focused window's border, when a person turns it on, is the accent. +# The focused window's border, when a person turns it on, is the accent: without the custom colour +# tilingshell draws St's accent, GNOME's teal, not the brand's cyan. [org/gnome/shell/extensions/tilingshell] window-border-color='#00E5FF' +window-use-custom-border-color=true [org/gnome/shell/extensions/just-perfection] clock-menu-position=1 diff --git a/image/rootfs/usr/bin/construct-app b/image/rootfs/usr/bin/construct-app index 7cc615b..8495c89 100755 --- a/image/rootfs/usr/bin/construct-app +++ b/image/rootfs/usr/bin/construct-app @@ -80,7 +80,6 @@ class KeysPage(Adw.PreferencesPage): self.window = window self.encrypted = False self.group = Adw.PreferencesGroup( - title=_("Security keys"), description=_("A key you add opens this computer's disk with its PIN and a touch, and signs you in " "where Construct asks for your password - sudo, the lock screen, a change to the " "system - with a touch. Your password keeps working without it. Add a second key " @@ -229,8 +228,7 @@ class UpdatesPage(Adw.PreferencesPage): super().__init__(title=_("Updates"), icon_name="software-update-available-symbolic") self.window = window rel = os_release() - group = Adw.PreferencesGroup(title=_("Updates"), - description=_("Construct updates itself once a day and starts the new release at " + group = Adw.PreferencesGroup(description=_("Construct updates itself once a day and starts the new release at " "the next restart. If it does not come up, the one before starts again.")) version = Adw.ActionRow(title=_("Running"), subtitle=rel.get("IMAGE_VERSION", _("a development build"))) group.add(version) @@ -368,8 +366,7 @@ class TailscalePage(Adw.PreferencesPage): def __init__(self, window): super().__init__(title=_("Tailscale"), icon_name="network-vpn-symbolic") self.window = window - group = Adw.PreferencesGroup(title=_("Tailscale"), - description=_("A private network between your own devices. Nothing reaches this " + group = Adw.PreferencesGroup(description=_("A private network between your own devices. Nothing reaches this " "computer through it that you did not open.")) self.state = Adw.ActionRow(title=_("State")) self.button = Gtk.Button(valign=Gtk.Align.CENTER) diff --git a/image/rootfs/usr/bin/construct-installer b/image/rootfs/usr/bin/construct-installer index d69af49..e637721 100755 --- a/image/rootfs/usr/bin/construct-installer +++ b/image/rootfs/usr/bin/construct-installer @@ -111,12 +111,22 @@ class Installer(Adw.ApplicationWindow): description=_("Construct is running from this stick. Install it on this computer to keep it, " "or close this window and keep trying it."), ) + # No disk is a state with a way out, not a dead end (the design review's round 1): what is + # missing, what to do, and the one action that helps - looking again once a disk is in. if self.disk is None: - status.set_description(_("This computer has no disk Construct can be installed on.")) + status.set_description(_("This computer has no disk to install Construct on, other than the " + "stick it is running from. Connect one and look again, or close this " + "window and keep trying Construct.")) + status.set_child(self.button(_("Look Again"), "suggested-action", self.look_again)) else: status.set_child(self.button(_("Install…"), "suggested-action", lambda *args: self.nav.push(self.disk_page()))) return self.page(_("Welcome"), status, "welcome") + def look_again(self, *args): + self.disks = self.read_disks() + self.disk = next((d for d in self.disks if d["default"]), self.disks[0] if self.disks else None) + self.nav.replace([self.welcome_page()]) + def disk_page(self): group = Adw.PreferencesGroup( title=_("Where to install"), diff --git a/recipes/bibata-cursor-theme.yaml b/recipes/bibata-cursor-theme.yaml index 072ef35..4ca6de7 100644 --- a/recipes/bibata-cursor-theme.yaml +++ b/recipes/bibata-cursor-theme.yaml @@ -1,7 +1,7 @@ package: name: bibata-cursor-theme version: 2.0.7 - epoch: 5 + epoch: 8 description: Bibata Modern cursors in Construct's colours (Bibata-Modern-Construct) copyright: - license: GPL-3.0-or-later @@ -13,7 +13,8 @@ environment: - python-3.13 - rsvg-convert -# The fork's gnome-51 branch builds the theme from Bibata's SVGs with one Python program +# The fork's gnome-51 branch (its colours from the pinned brand's palette.json, brand.ref) builds +# the theme from Bibata's SVGs with one Python program # (build.py): the X cursors every client reads, and the cursors_scalable/ SVGs GNOME Shell 51 # draws its own cursor from - without them the shell logs "Could not load cursor" and shows # Adwaita's. @@ -22,7 +23,7 @@ pipeline: with: repository: https://github.com/Construct-Linux/Bibata_Cursor branch: gnome-51 - expected-commit: e602d9a4e06d4745fc8eed315289ddf4708bdcbd + expected-commit: 6aa8079aaf2d9a8822e3519789f5562a35c21a1a - runs: | python3 build.py -o ${{targets.destdir}}/usr/share/icons diff --git a/recipes/construct-brand.yaml b/recipes/construct-brand.yaml index 07bb43c..71eac18 100644 --- a/recipes/construct-brand.yaml +++ b/recipes/construct-brand.yaml @@ -5,12 +5,13 @@ # Background settings list it. The palette comes as data and as what apps outside GTK read: # palette.json, flat, for cmd/image (Plymouth's prompt, the harness's checks); Ghostty's Construct # Dark and Light themes, which `construct theme` seeds a config to; and VS Code's construct-theme, -# a built-in extension, so it is there before any account opens the editor. The images and the +# a built-in extension, so it is there before any account opens the editor; System Monitor's chart +# colours as dconf defaults (the palette's series). The images and the # palette's outputs are the ones brand commits and holds to its sources (its task check). package: name: construct-brand version: 0_git20261005 - epoch: 1 + epoch: 4 description: CONSTRUCT's logo, boot animation, wallpaper and palette copyright: - license: CC-BY-SA-4.0 @@ -26,8 +27,8 @@ pipeline: - uses: git-checkout with: repository: https://github.com/Construct-Linux/brand - branch: main - expected-commit: 32b719201ebc44bf6971c0b875d920cec041bd80 + branch: ui-hill-climb + expected-commit: 0989b1f4ef2151355f613ea369caf96391a3a846 - runs: | d=${{targets.destdir}}/usr/share @@ -39,6 +40,8 @@ pipeline: cp logos/construct-symbolic.svg $d/icons/hicolor/symbolic/apps/construct-logo-symbolic.svg cp logos/io.github.construct_linux.Installer.svg logos/io.github.construct_linux.Construct.svg $d/icons/hicolor/scalable/apps/ cp palette/construct.json $d/construct/palette.json + # bake's dconf update compiles it into the local database with etc/dconf's own. + install -Dm644 palette/system-monitor.dconf ${{targets.destdir}}/etc/dconf/db/local.d/20-construct-system-monitor cp "palette/ghostty/Construct Dark" "palette/ghostty/Construct Light" $d/ghostty/themes/ # Its themes include VS Code's own 2026 ones for syntax by a path relative to the extensions # directory (theme-defaults), so it works only from there. diff --git a/recipes/gnome-shell-extension-clipboard-indicator.yaml b/recipes/gnome-shell-extension-clipboard-indicator.yaml index 8027b53..5e0e516 100644 --- a/recipes/gnome-shell-extension-clipboard-indicator.yaml +++ b/recipes/gnome-shell-extension-clipboard-indicator.yaml @@ -1,7 +1,7 @@ package: name: gnome-shell-extension-clipboard-indicator version: "72" - epoch: 0 + epoch: 1 description: Clipboard history manager in the GNOME Shell top bar copyright: - license: MIT @@ -31,7 +31,7 @@ pipeline: with: repository: https://github.com/Construct-Linux/gnome-shell-extension-clipboard-indicator branch: gnome-51 - expected-commit: 2eaeafc2771d78eb56392136f0b69ea6812cdc55 + expected-commit: 0a286404eb6e1488feb7091edadde7b37e814e1b - runs: | glib-compile-schemas --strict --dry-run schemas diff --git a/recipes/orchis-theme.yaml b/recipes/orchis-theme.yaml index 2a7af2f..09754c0 100644 --- a/recipes/orchis-theme.yaml +++ b/recipes/orchis-theme.yaml @@ -1,7 +1,7 @@ package: name: orchis-theme version: "20260914" - epoch: 17 + epoch: 23 description: Orchis GTK and GNOME Shell theme in Construct's colors, light and dark copyright: - license: GPL-3.0-only @@ -22,12 +22,13 @@ pipeline: # The gnome-shell-51 branch of a fork, on upstream's master: Shell 51 only, and what 49-51 # changed in the shell's widgets that upstream's newest stylesheet (48) leaves unstyled; the # brand's palette as its one accent, and only GNOME Shell (dark: the shell is), GTK 3 and GTK 4. - # awk, which the build runs, is busybox's. + # The palette is copied from the pinned brand (brand.ref, task fork:brand). awk, which the build + # runs, is busybox's. - uses: git-checkout with: repository: https://github.com/Construct-Linux/Orchis-theme branch: gnome-shell-51 - expected-commit: 13a4e67ca68e2dc68989285cf076fe0cd9e23e0e + expected-commit: 742d42005257d3b8fcaf5fa3b17f7d4371cfa63c # No -l: it links into a home directory, and the image links libadwaita per user. - runs: |