From 550f420c59b2740d03f37791c6974a524555cafb Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Mon, 17 Aug 2026 21:50:20 +0200 Subject: [PATCH] feat(sidebar): colours you can choose, and projects indented under their group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `projects.color` has been in the schema since the first migration, has defaulted to 'graphite' on every row ever written, and nothing has ever read it. There was no palette, no name-to-colour mapping, and the dot in the sidebar was hardcoded to a border grey. So this is not "add a picker"; it is implementing colour for the first time. Names, not hex values. A hex in ten thousand rows is a decision nobody can revisit; a name is resolved when the interface paints and can be retuned for a new theme or a new display. It is also the difference between a database that stores a fact and one that stores a rendering. The set is closed and refused at the edge — an unknown name would be stored happily and painted as the default, which looks like a colour that did not save. One palette across all five themes, and this is the one place in app.css that is not redefined per theme. Everything else there is text, a ground or a border and has to be measured against what sits behind it. These are small solid marks beside a label that already carries the meaning: what they owe is to be told apart, and to be visible on both a near-black and a white ground, which is why all ten sit in the mid-tone band. It also keeps them out of the trap the themes are in — a colour added to four blocks and forgotten in the fifth looks finished and falls back silently. There is no fifth block to forget. The list is written down three times: Go for validation, colors.js for the picker, app.css for the tokens that paint. So a test reads the other two — the same small-scanner approach openapi_test.go takes to the spec — and fails if the server accepts a colour nothing can draw, or the picker offers one the server refuses. Groups get a colour of their own (migration 0006) and projects inside a group are indented. The indent is to the group's *name*, not to its dot: the eye follows the text, and lining the projects up under the dot leaves them looking a step short of where the heading starts. The drop line is indented with them, so a drop inside a group reads as landing in the group. The group's dot is the control as well as the preview — you press the thing you are about to change, as on the project page. That was not the first design: a third text action, "Farve", sat beside Omdøb and Slet, and those carry `opacity: 0` rather than `display: none`, so they take their width whether or not you can see them. A group called "Arbejde" rendered as "ARBEJD". Moving the control onto the dot gave the name its room back. The smoke test measures the indent at the content edge rather than the element's, because the indent is padding and the box does not move — which is how the first version of that assertion passed 12 against 12. Co-Authored-By: Claude Opus 5 --- docs/openapi.yaml | 23 +++- internal/httpapi/projectgroups_handlers.go | 21 +++- internal/httpapi/projectgroups_test.go | 50 +++++++++ internal/httpapi/projects_handlers.go | 22 +++- internal/store/colors.go | 39 +++++++ internal/store/colors_test.go | 93 ++++++++++++++++ .../store/migrations/0006_group_colour.sql | 11 ++ internal/store/projectgroups.go | 20 ++-- web/e2e/smoke.spec.js | 40 ++++++- web/src/app.css | 28 +++++ web/src/lib/colors.js | 45 ++++++++ web/src/lib/components/Sidebar.svelte | 104 +++++++++++++++++- web/src/lib/stores.svelte.js | 18 +++ web/src/routes/projekt/[id]/+page.svelte | 90 +++++++++++++++ 14 files changed, 584 insertions(+), 20 deletions(-) create mode 100644 internal/store/colors.go create mode 100644 internal/store/colors_test.go create mode 100644 internal/store/migrations/0006_group_colour.sql create mode 100644 web/src/lib/colors.js diff --git a/docs/openapi.yaml b/docs/openapi.yaml index b464b73..935de8c 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -2549,7 +2549,7 @@ components: properties: id: { type: string } name: { type: string } - color: { type: string } + color: { $ref: "#/components/schemas/Color" } icon: { type: string } view_mode: { type: string, enum: [list, board, calendar] } owner_id: { type: string } @@ -2570,7 +2570,7 @@ components: type: object properties: name: { type: string } - color: { type: string } + color: { $ref: "#/components/schemas/Color" } icon: { type: string } view_mode: { type: string, enum: [list, board, calendar] } archived: { type: boolean } @@ -2588,6 +2588,7 @@ components: properties: id: { type: string } name: { type: string } + color: { $ref: "#/components/schemas/Color" } collapsed: { type: boolean } sort_order: { type: number } @@ -2595,8 +2596,26 @@ components: type: object properties: name: { type: string } + color: { $ref: "#/components/schemas/Color" } collapsed: { type: boolean } + Color: + type: string + default: graphite + enum: [graphite, tomato, amber, lime, green, teal, blue, indigo, violet, magenta] + description: | + A name, not a hex value. The interface resolves it when it paints, so the + palette can be retuned for a new theme or a new display without rewriting + every row — and a database that stores a fact rather than a rendering. + + The set is closed: an unknown name is refused rather than stored and + painted as the default, which would look like a colour that did not save. + + One palette for all five themes. These are small solid marks beside a + label that carries the meaning, not text or grounds, so what they owe is + to be told apart and to be visible on both a near-black and a white + ground — which is why they all sit in the mid-tone band. + Section: type: object properties: diff --git a/internal/httpapi/projectgroups_handlers.go b/internal/httpapi/projectgroups_handlers.go index e6cf6dc..5eb7dad 100644 --- a/internal/httpapi/projectgroups_handlers.go +++ b/internal/httpapi/projectgroups_handlers.go @@ -19,12 +19,16 @@ import ( type projectGroupJSON struct { ID string `json:"id"` Name string `json:"name"` + Color string `json:"color"` Collapsed bool `json:"collapsed"` SortOrder float64 `json:"sort_order"` } func toProjectGroupJSON(g store.ProjectGroup) projectGroupJSON { - return projectGroupJSON{ID: g.ID, Name: g.Name, Collapsed: g.Collapsed, SortOrder: g.SortOrder} + return projectGroupJSON{ + ID: g.ID, Name: g.Name, Color: g.Color, + Collapsed: g.Collapsed, SortOrder: g.SortOrder, + } } func (s *Server) handleListProjectGroups(w http.ResponseWriter, r *http.Request) { @@ -42,6 +46,7 @@ func (s *Server) handleListProjectGroups(w http.ResponseWriter, r *http.Request) type projectGroupRequest struct { Name *string `json:"name"` + Color *string `json:"color"` Collapsed *bool `json:"collapsed"` } @@ -59,7 +64,15 @@ func (s *Server) handleCreateProjectGroup(w http.ResponseWriter, r *http.Request return } + if req.Color != nil && !store.ValidColor(*req.Color) { + writeFieldErrors(w, map[string]string{"color": colorFieldError}) + return + } + g := &store.ProjectGroup{Name: name, OwnerID: userFrom(r.Context()).ID} + if req.Color != nil { + g.Color = *req.Color + } if req.Collapsed != nil { g.Collapsed = *req.Collapsed } @@ -87,9 +100,13 @@ func (s *Server) handleUpdateProjectGroup(w http.ResponseWriter, r *http.Request } req.Name = &name } + if req.Color != nil && !store.ValidColor(*req.Color) { + writeFieldErrors(w, map[string]string{"color": colorFieldError}) + return + } if err := s.db.UpdateProjectGroup(r.Context(), groupID, userID, store.ProjectGroupUpdate{ - Name: req.Name, Collapsed: req.Collapsed, + Name: req.Name, Color: req.Color, Collapsed: req.Collapsed, }); err != nil { s.storeError(w, "update project group", err) return diff --git a/internal/httpapi/projectgroups_test.go b/internal/httpapi/projectgroups_test.go index b087536..a8dddf3 100644 --- a/internal/httpapi/projectgroups_test.go +++ b/internal/httpapi/projectgroups_test.go @@ -219,3 +219,53 @@ func groupOf(t *testing.T, ts *testServer, projectID string) string { t.Fatalf("project %s is not in the listing", projectID) return "" } + +// Colour is a name from a closed set, on both a project and a group. +// +// Refused rather than stored: an unknown name would be written happily and then +// painted as the default, which looks like a colour that did not save. +func TestAColourMustBeOneFromThePalette(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + _, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"}) + groupID := group["id"].(string) + if group["color"] != "graphite" { + t.Errorf("a new group's colour = %v, want graphite", group["color"]) + } + + _, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab"}) + projectID := project["id"].(string) + + for _, c := range []struct { + what, method, path string + }{ + {"group", "PATCH", "/api/v1/project-groups/" + groupID}, + {"project", "PATCH", "/api/v1/projects/" + projectID}, + } { + resp, body := ts.do(t, c.method, c.path, map[string]any{"color": "teal"}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s, a real colour: %d %v", c.what, resp.StatusCode, body) + } + if body["color"] != "teal" { + t.Errorf("%s colour = %v, want teal", c.what, body["color"]) + } + + resp, body = ts.do(t, c.method, c.path, map[string]any{"color": "#ff0000"}) + if resp.StatusCode != http.StatusUnprocessableEntity { + t.Errorf("%s, a hex value: status %d, want 422 — body %v", c.what, resp.StatusCode, body) + } + } + + // A rename does not disturb the colour, and a recolour does not disturb the + // name: both are PATCH fields on the same row, and the whole point of a PATCH + // is that the one you did not send is the one you did not mean. + _, renamed := ts.do(t, "PATCH", "/api/v1/project-groups/"+groupID, + map[string]any{"name": "Kontoret"}) + if renamed["color"] != "teal" { + t.Errorf("colour = %v after a rename, want teal", renamed["color"]) + } + if renamed["name"] != "Kontoret" { + t.Errorf("name = %v", renamed["name"]) + } +} diff --git a/internal/httpapi/projects_handlers.go b/internal/httpapi/projects_handlers.go index 8042bbc..aeac10c 100644 --- a/internal/httpapi/projects_handlers.go +++ b/internal/httpapi/projects_handlers.go @@ -122,7 +122,7 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) { if req.Name != nil { name = strings.TrimSpace(*req.Name) } - if fields := validateProject(name, req.ViewMode); len(fields) > 0 { + if fields := validateProject(name, req.ViewMode, req.Color); len(fields) > 0 { writeFieldErrors(w, fields) return } @@ -158,7 +158,13 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) { name = strings.TrimSpace(*req.Name) req.Name = &name } - if fields := validateProject(name, req.ViewMode); req.Name != nil && len(fields) > 0 { + // The name is only checked when it was sent — but the colour is checked + // whenever it was, which is why this is two conditions rather than one. + fields := validateProject(name, req.ViewMode, req.Color) + if req.Name == nil { + delete(fields, "name") + } + if len(fields) > 0 { writeFieldErrors(w, fields) return } @@ -260,7 +266,12 @@ func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -func validateProject(name string, viewMode *string) map[string]string { +// colorFieldError names the closed set rather than listing it. The list is ten +// long and lives in three places already; a caller that got it wrong needs to be +// pointed at the documentation, not handed a copy that will one day be stale. +const colorFieldError = "must be one of the colours in the palette — see the Color schema" + +func validateProject(name string, viewMode, color *string) map[string]string { fields := map[string]string{} switch { case name == "": @@ -275,6 +286,11 @@ func validateProject(name string, viewMode *string) map[string]string { fields["view_mode"] = "must be list, board or calendar" } } + // Refused rather than stored and quietly painted as the default, which is what + // an unknown name would do — and which looks like a colour that did not save. + if color != nil && !store.ValidColor(*color) { + fields["color"] = colorFieldError + } return fields } diff --git a/internal/store/colors.go b/internal/store/colors.go new file mode 100644 index 0000000..fccc817 --- /dev/null +++ b/internal/store/colors.go @@ -0,0 +1,39 @@ +package store + +// The colours a project, group, label or filter may be marked with. +// +// Names rather than hex values, and the reason is what happens later: a hex in +// ten thousand rows is a decision nobody can revisit, while a name is resolved by +// the interface at the moment it paints and can be retuned for a new theme, a new +// palette, or a display nobody has yet. It is also the difference between a +// database that stores a fact and one that stores a rendering. +// +// The list is closed. An unknown name would be stored happily and then paint as +// the default, which looks like a colour that did not save — so it is refused at +// the edge instead. web/src/lib/colors.js carries the same ids for the picker, and +// a test reads that file to check the two have not drifted. +var Colors = []string{ + "graphite", + "tomato", + "amber", + "lime", + "green", + "teal", + "blue", + "indigo", + "violet", + "magenta", +} + +// DefaultColor is what an unmarked thing is, and what the schema defaults to. +const DefaultColor = "graphite" + +// ValidColor reports whether a name is one this app can paint. +func ValidColor(name string) bool { + for _, c := range Colors { + if c == name { + return true + } + } + return false +} diff --git a/internal/store/colors_test.go b/internal/store/colors_test.go new file mode 100644 index 0000000..5d565c7 --- /dev/null +++ b/internal/store/colors_test.go @@ -0,0 +1,93 @@ +package store + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// The palette is written down three times: here, in web/src/lib/colors.js for the +// picker, and in web/src/app.css as the tokens that actually paint. Three copies +// is two chances to drift, and the failure is quiet — a colour the server accepts +// and the interface cannot draw renders as the default, which looks like a colour +// that did not save. +// +// So this reads the other two. A small scanner rather than a parser, the same +// approach openapi_test.go takes to the spec: it needs the ids out of a list this +// repository also owns, and that is worth more than a dependency in a project +// whose whole shape is "one static binary". +func TestThePaletteIsTheSameInGoAndInTheFrontend(t *testing.T) { + js := readFrontendColors(t, filepath.Join("..", "..", "web", "src", "lib", "colors.js")) + css := readColorTokens(t, filepath.Join("..", "..", "web", "src", "app.css")) + + for _, name := range Colors { + if !js[name] { + t.Errorf("%q is accepted by the server and missing from colors.js — the picker cannot offer it", name) + } + if !css[name] { + t.Errorf("--color-%s is missing from app.css — the server accepts a colour nothing can paint", name) + } + } + for name := range js { + if !ValidColor(name) { + t.Errorf("colors.js offers %q, which the server refuses — picking it would fail to save", name) + } + } +} + +// readFrontendColors pulls the ids out of the COLORS array: lines shaped +// `{ id: 'tomato', name: 'Tomat' },`. +func readFrontendColors(t *testing.T, path string) map[string]bool { + t.Helper() + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read colors.js: %v", err) + } + + found := map[string]bool{} + for _, line := range strings.Split(string(body), "\n") { + id, ok := between(line, "{ id: '", "'") + if ok { + found[id] = true + } + } + if len(found) == 0 { + t.Fatal("no colours were read out of colors.js — the scanner and the file disagree") + } + return found +} + +// readColorTokens pulls the names out of `--color-tomato: #d9614f;`. +func readColorTokens(t *testing.T, path string) map[string]bool { + t.Helper() + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read app.css: %v", err) + } + + found := map[string]bool{} + for _, line := range strings.Split(string(body), "\n") { + name, ok := between(line, "--color-", ":") + if ok { + found[name] = true + } + } + if len(found) == 0 { + t.Fatal("no --color- tokens were read out of app.css") + } + return found +} + +func between(line, open, close string) (string, bool) { + start := strings.Index(line, open) + if start < 0 { + return "", false + } + rest := line[start+len(open):] + end := strings.Index(rest, close) + if end <= 0 { + return "", false + } + return rest[:end], true +} diff --git a/internal/store/migrations/0006_group_colour.sql b/internal/store/migrations/0006_group_colour.sql new file mode 100644 index 0000000..8784d56 --- /dev/null +++ b/internal/store/migrations/0006_group_colour.sql @@ -0,0 +1,11 @@ +-- A colour on a sidebar group, so a group can be marked the way a project is. +-- +-- Projects, labels and filters have carried a `color` since 0001; groups arrived +-- in 0005 without one because nothing rendered any of them yet. Now that the +-- sidebar paints them, a group without one would be the only thing in the list +-- that could not be told apart at a glance. +-- +-- Same default and same spelling as the others: a colour name, not a hex. The +-- value has to survive a theme change and a redesign, and '#8a8f98' in ten +-- thousand rows is a decision nobody can revisit. +ALTER TABLE project_groups ADD COLUMN color TEXT NOT NULL DEFAULT 'graphite'; diff --git a/internal/store/projectgroups.go b/internal/store/projectgroups.go index bb38a13..63ee825 100644 --- a/internal/store/projectgroups.go +++ b/internal/store/projectgroups.go @@ -18,6 +18,7 @@ type ProjectGroup struct { ID string OwnerID string Name string + Color string Collapsed bool SortOrder float64 CreatedAt time.Time @@ -26,7 +27,7 @@ type ProjectGroup struct { func (db *DB) ListProjectGroups(ctx context.Context, userID string) ([]ProjectGroup, error) { rows, err := db.QueryContext(ctx, - `SELECT id, owner_id, name, collapsed, sort_order, created_at, updated_at + `SELECT id, owner_id, name, color, collapsed, sort_order, created_at, updated_at FROM project_groups WHERE owner_id = ? ORDER BY sort_order, created_at`, userID) if err != nil { @@ -39,7 +40,7 @@ func (db *DB) ListProjectGroups(ctx context.Context, userID string) ([]ProjectGr var g ProjectGroup var collapsed int var created, updated int64 - if err := rows.Scan(&g.ID, &g.OwnerID, &g.Name, &collapsed, &g.SortOrder, + if err := rows.Scan(&g.ID, &g.OwnerID, &g.Name, &g.Color, &collapsed, &g.SortOrder, &created, &updated); err != nil { return nil, err } @@ -59,9 +60,9 @@ func (db *DB) GetProjectGroup(ctx context.Context, groupID, userID string) (*Pro var collapsed int var created, updated int64 err := db.QueryRowContext(ctx, - `SELECT id, owner_id, name, collapsed, sort_order, created_at, updated_at + `SELECT id, owner_id, name, color, collapsed, sort_order, created_at, updated_at FROM project_groups WHERE id = ? AND owner_id = ?`, groupID, userID). - Scan(&g.ID, &g.OwnerID, &g.Name, &collapsed, &g.SortOrder, &created, &updated) + Scan(&g.ID, &g.OwnerID, &g.Name, &g.Color, &collapsed, &g.SortOrder, &created, &updated) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } @@ -80,6 +81,9 @@ func (db *DB) CreateProjectGroup(ctx context.Context, g *ProjectGroup) error { } now := time.Now().UTC() g.CreatedAt, g.UpdatedAt = now, now + if g.Color == "" { + g.Color = DefaultColor + } if g.SortOrder == 0 { var max sql.NullFloat64 if err := db.QueryRowContext(ctx, @@ -90,14 +94,15 @@ func (db *DB) CreateProjectGroup(ctx context.Context, g *ProjectGroup) error { g.SortOrder = max.Float64 + 1024 } _, err := db.ExecContext(ctx, - `INSERT INTO project_groups (id, owner_id, name, collapsed, sort_order, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - g.ID, g.OwnerID, g.Name, boolToInt(g.Collapsed), g.SortOrder, now.Unix(), now.Unix()) + `INSERT INTO project_groups (id, owner_id, name, color, collapsed, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + g.ID, g.OwnerID, g.Name, g.Color, boolToInt(g.Collapsed), g.SortOrder, now.Unix(), now.Unix()) return err } type ProjectGroupUpdate struct { Name *string + Color *string Collapsed *bool SortOrder *float64 } @@ -105,6 +110,7 @@ type ProjectGroupUpdate struct { func (db *DB) UpdateProjectGroup(ctx context.Context, groupID, userID string, u ProjectGroupUpdate) error { set, args := buildUpdate(map[string]any{ "name": u.Name, + "color": u.Color, "collapsed": u.Collapsed, "sort_order": u.SortOrder, }) diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index a3b90cc..312e0e5 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -256,7 +256,10 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me await sidebar.getByLabel('Gruppens navn').fill('Arbejde'); await sidebar.getByLabel('Gruppens navn').press('Enter'); - const heading = sidebar.getByRole('button', { name: 'Arbejde' }); + // `exact`, because the heading's colour dot is a button too and its label is + // "Farve på Arbejde" — and Playwright matches an accessible name by substring + // unless told otherwise. + const heading = sidebar.getByRole('button', { name: 'Arbejde', exact: true }); await expect(heading).toBeVisible(); await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible(); @@ -295,11 +298,38 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me await heading.click(); await expect(heading).toHaveAttribute('aria-expanded', 'false'); await page.reload(); - await expect(sidebar.getByRole('button', { name: 'Arbejde' })).toHaveAttribute( + await expect(sidebar.getByRole('button', { name: 'Arbejde', exact: true })).toHaveAttribute( 'aria-expanded', 'false' ); - await sidebar.getByRole('button', { name: 'Arbejde' }).click(); + await sidebar.getByRole('button', { name: 'Arbejde', exact: true }).click(); + + // Colour, and indentation. The dot is the control as well as the preview, so + // the thing you press is the thing you are about to change. + await sidebar.getByRole('button', { name: 'Farve på Arbejde' }).click(); + await sidebar.getByRole('button', { name: 'Petrol' }).click(); + + const painted = await sidebar.locator('.folder-head .group-dot').evaluate((el) => + getComputedStyle(el).backgroundColor + ); + expect(painted, 'gruppens prik fik ikke sin farve').not.toBe('rgba(0, 0, 0, 0)'); + + // A project inside a group starts further in than a row outside one. Measured + // rather than asserted on a class, because indentation is a fact about where + // the row's contents land — and measured at the *content* edge, not the box: + // the indent is padding, so the element's own left edge does not move. + await sidebar.getByRole('link', { name: 'Regnskab' }).dragTo(heading); + const contentLeft = (el) => + el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft); + const [inside, outside] = await Promise.all([ + sidebar.locator('.folder > a').first().evaluate(contentLeft), + sidebar.locator('.views a').first().evaluate(contentLeft) + ]); + expect(inside, 'et projekt i en gruppe er ikke rykket ind').toBeGreaterThan(outside); + + await sidebar + .getByRole('link', { name: 'Regnskab' }) + .dragTo(sidebar.getByRole('heading', { name: 'Projekter' })); // Renaming writes one row rather than one per project, which is the reason a // group is a table and not a string repeated on every project. @@ -307,14 +337,14 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me const name = sidebar.getByLabel('Gruppens navn'); await name.fill('Kontoret'); await name.press('Enter'); - await expect(sidebar.getByRole('button', { name: 'Kontoret' })).toBeVisible(); + await expect(sidebar.getByRole('button', { name: 'Kontoret', exact: true })).toBeVisible(); // Deleting the heading must not take the project filed under it. It is // ungrouped here, which is the case that would look identical either way if // the assertion were only "the group is gone". page.once('dialog', (dialog) => dialog.accept()); await sidebar.getByRole('button', { name: 'Slet' }).click(); - await expect(sidebar.getByRole('button', { name: 'Kontoret' })).toBeHidden(); + await expect(sidebar.getByRole('button', { name: 'Kontoret', exact: true })).toBeHidden(); await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible(); expect(trouble).toEqual([]); diff --git a/web/src/app.css b/web/src/app.css index 6d4a914..e8c9acd 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -45,6 +45,34 @@ --danger: #cf6b6b; --danger-sunken: #33201f; + /* Marks for projects, groups, labels and filters. + * + * The one set of colours in this file that is *not* redefined per theme, and + * on purpose. Everything else here is text, a ground or a border, and has to + * be measured against whatever sits behind it. These are none of those: they + * are small solid dots beside a label that carries the meaning, so what they + * owe is to be told apart from one another and to be visible on both a + * near-black and a white ground. All of them sit in the mid-tone band where + * that is true, which is why none is very dark or very pale. + * + * It also keeps them out of the trap the themes are in: a colour added to four + * blocks and forgotten in the fifth looks finished and falls back silently. + * There is no fifth block to forget. + * + * The ids are what the database stores. web/src/lib/colors.js lists them for + * the picker, and a Go test reads that file to check the server's list of + * accepted names has not drifted from it. */ + --color-graphite: #8a8f98; + --color-tomato: #d9614f; + --color-amber: #d9a04f; + --color-lime: #a3b74d; + --color-green: #4fae7a; + --color-teal: #45b0ab; + --color-blue: #5b9bd9; + --color-indigo: #7f83d6; + --color-violet: #a86fd0; + --color-magenta: #d46aa8; + /* Type. System stack: it renders instantly, matches the platform, and has proper Danish glyphs on every device — a webfont would cost a round trip and a flash of unstyled text on the one screen people open fifty times a day. */ diff --git a/web/src/lib/colors.js b/web/src/lib/colors.js new file mode 100644 index 0000000..4bb8285 --- /dev/null +++ b/web/src/lib/colors.js @@ -0,0 +1,45 @@ +/** + * The colours a project, a group, a label or a filter can be marked with. + * + * The `color` column has been on projects since the first migration, defaulting + * to 'graphite', and nothing has ever read it. This is the list that makes it + * mean something. + * + * **One set for all five themes, deliberately.** Every other colour in this app is + * redefined per theme, because every other colour is text, a ground, or a border, + * and those have to be measured against the surface behind them. These are none of + * those: they are small solid marks sitting next to a label that carries the + * meaning. What they need is to be told apart from each other, and to be visible + * on both a near-black and a white ground — which mid-tone, medium-saturation + * colours are, and which is why they are all in that band. + * + * Defining them once is also what keeps them out of the trap the themes are in: + * a colour added to four theme blocks and forgotten in the fifth looks finished + * and falls back silently. There is no fifth block to forget. + * + * The ids are what the database stores, so they are not to be renamed. `app.css` + * carries a `--color-` token for each, and a Go test reads this file to check + * the server's list of accepted names has not drifted from it. + */ +export const COLORS = [ + { id: 'graphite', name: 'Grafit' }, + { id: 'tomato', name: 'Tomat' }, + { id: 'amber', name: 'Rav' }, + { id: 'lime', name: 'Lime' }, + { id: 'green', name: 'Grøn' }, + { id: 'teal', name: 'Petrol' }, + { id: 'blue', name: 'Blå' }, + { id: 'indigo', name: 'Indigo' }, + { id: 'violet', name: 'Violet' }, + { id: 'magenta', name: 'Magenta' } +]; + +/** The CSS variable a colour id paints with, falling back to the default. */ +export function colorVar(id) { + return COLORS.some((c) => c.id === id) ? `var(--color-${id})` : 'var(--color-graphite)'; +} + +/** The Danish name, for the swatch's label. */ +export function colorName(id) { + return COLORS.find((c) => c.id === id)?.name ?? COLORS[0].name; +} diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index aabd263..4db0cba 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -2,6 +2,7 @@ import { app, sidebar } from '$lib/stores.svelte.js'; import { api } from '$lib/api.js'; import { TASK, PROJECT, GROUP, startDrag, carries, dragged, accept } from '$lib/dnd.js'; + import { COLORS, colorVar } from '$lib/colors.js'; import { page } from '$app/stores'; import { goto } from '$app/navigation'; @@ -78,6 +79,8 @@ let newGroupName = $state(''); let renamingGroup = $state(null); let groupName = $state(''); + /** The group whose swatches are open, if any. */ + let coloringGroup = $state(null); async function createGroup(event) { event.preventDefault(); @@ -353,7 +356,7 @@ }} ondrop={(e) => (carries(e, TASK) ? onTaskDrop(e, project) : sortable && onDrop(e, project))} > - + {project.name} {#if project.shared} {project.member_count} @@ -480,6 +483,20 @@ the heading away from a screen reader. The whole heading folds — at this size a lone chevron is a target you miss, and the name is where the eye already is. --> + +

+ {/each} + + {/if} + {#if !group.collapsed} {#each inside as project (project.id)} {@render projectRow(project, true)} @@ -852,6 +888,72 @@ margin-left: 0; } + /* The group's own mark, in front of the chevron so the colours of the headings + line up with each other rather than with the projects underneath. */ + .group-dot { + margin-left: var(--s2); + padding: 0; + box-shadow: 0 0 0 2px var(--surface-sunken); + transition: box-shadow var(--fast) var(--ease); + } + + .group-dot:hover, + .group-dot:focus-visible { + box-shadow: + 0 0 0 2px var(--surface-sunken), + 0 0 0 3px var(--line-strong); + outline: none; + } + + /* Projects inside a group are indented to the group's name, not to its dot: + the eye follows the text, and lining up under the dot leaves the names + looking a step short of where the heading starts. */ + .folder > a { + padding-left: calc(var(--s2) + var(--s4)); + } + + /* The line is indented with them, so a drop between two projects in a group + reads as landing in the group rather than across the whole sidebar. */ + .folder > a.sortable::before { + left: calc(var(--s2) + var(--s4)); + } + + .folder > .empty { + padding-left: calc(var(--s2) + var(--s4)); + } + + .swatches { + display: flex; + flex-wrap: wrap; + gap: var(--s2); + padding: var(--s2) var(--s2) var(--s3); + } + + .swatch { + width: 16px; + height: 16px; + border-radius: var(--radius-full); + flex: none; + /* A ring in the ground colour, so the chosen one reads as selected without + the swatch changing size and shuffling the row as you move along it. */ + box-shadow: 0 0 0 2px var(--surface-sunken); + transition: box-shadow var(--fast) var(--ease); + } + + .swatch:hover, + .swatch:focus-visible { + box-shadow: + 0 0 0 2px var(--surface-sunken), + 0 0 0 3px var(--line-strong); + outline: none; + } + + .swatch.chosen { + box-shadow: + 0 0 0 2px var(--surface-sunken), + 0 0 0 3px var(--ink); + } + .folder-head form { flex: 1; min-width: 0; diff --git a/web/src/lib/stores.svelte.js b/web/src/lib/stores.svelte.js index 6f8e9bf..bd11fea 100644 --- a/web/src/lib/stores.svelte.js +++ b/web/src/lib/stores.svelte.js @@ -410,6 +410,10 @@ class AppState { await this.#patchGroup(id, { name }); } + async setGroupColor(id, color) { + await this.#patchGroup(id, { color }); + } + async #patchGroup(id, patch) { const previous = this.groups.find((g) => g.id === id); if (!previous) return; @@ -459,6 +463,20 @@ class AppState { } } + /** Marks a project with one of the palette's colours. */ + async setProjectColor(projectId, color) { + const previous = this.projects.find((p) => p.id === projectId); + if (!previous || previous.color === color) return; + + this.upsertProject({ ...previous, color }); + try { + this.upsertProject(await api.updateProject(projectId, { color })); + } catch (e) { + this.upsertProject(previous); + this.toast(humanMessage(e)); + } + } + /** Files a project under a group, or takes it out of one when groupId is ''. */ async setProjectGroup(projectId, groupId) { const previous = this.projects.find((p) => p.id === projectId); diff --git a/web/src/routes/projekt/[id]/+page.svelte b/web/src/routes/projekt/[id]/+page.svelte index 470f76a..eaab02b 100644 --- a/web/src/routes/projekt/[id]/+page.svelte +++ b/web/src/routes/projekt/[id]/+page.svelte @@ -4,6 +4,7 @@ import { goto } from '$app/navigation'; import { api, humanMessage } from '$lib/api.js'; import { app } from '$lib/stores.svelte.js'; + import { COLORS, colorVar } from '$lib/colors.js'; import TaskList from '$lib/components/TaskList.svelte'; import QuickAdd from '$lib/components/QuickAdd.svelte'; import BoardView from '$lib/components/BoardView.svelte'; @@ -73,6 +74,18 @@ let unsectioned = $derived(open.filter((t) => !t.section_id)); let editing = $state(false); + let choosingColor = $state(false); + + /** + * The colour is written to the store as well as here: the sidebar reads its own + * copy of the project list, and a dot that only changed on this page would be + * the same colour in the two places you look at it. + */ + async function setColor(color) { + choosingColor = false; + project = { ...project, color }; + await app.setProjectColor(project.id, color); + } /** Renames on blur. A no-op when nothing changed, so tabbing through is free. */ async function rename(input) { @@ -216,6 +229,17 @@ {/if}

{/if} + {#if isOwner && !project.is_inbox} + + {/if} +
{#each [['list', 'Liste'], ['board', 'Board'], ['calendar', 'Kalender']] as [value, label]} + {/each} +
+ {/if} + {#if showShare}
    @@ -482,6 +522,56 @@ stroke-linejoin: round; } + /* A circle in the project's own colour, which is both the control and its own + preview: the thing you are about to change is what you press. */ + .color { + flex: none; + width: 20px; + height: 20px; + border-radius: var(--radius-full); + box-shadow: 0 0 0 2px var(--ground); + transition: box-shadow var(--fast) var(--ease); + } + + .color:hover, + .color:focus-visible { + box-shadow: + 0 0 0 2px var(--ground), + 0 0 0 3px var(--line-strong); + outline: none; + } + + .swatches { + display: flex; + flex-wrap: wrap; + gap: var(--s3); + } + + .swatch { + width: 22px; + height: 22px; + border-radius: var(--radius-full); + flex: none; + /* A ring in the panel's own colour, so the selected swatch reads as chosen + without changing size and shuffling the row as you move along it. */ + box-shadow: 0 0 0 2px var(--surface); + transition: box-shadow var(--fast) var(--ease); + } + + .swatch:hover, + .swatch:focus-visible { + box-shadow: + 0 0 0 2px var(--surface), + 0 0 0 3px var(--line-strong); + outline: none; + } + + .swatch.chosen { + box-shadow: + 0 0 0 2px var(--surface), + 0 0 0 3px var(--ink); + } + .views { display: flex; gap: 1px;