feat(sidebar): colours you can choose, and projects indented under their group

`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 <[email protected]>
This commit is contained in:
Kristian Wind
2026-08-17 21:50:20 +02:00
co-authored by Claude Opus 5
parent e8ebd0fa78
commit 550f420c59
14 changed files with 584 additions and 20 deletions
+19 -2
View File
@@ -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
+50
View File
@@ -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"])
}
}
+19 -3
View File
@@ -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
}
+39
View File
@@ -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
}
+93
View File
@@ -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
}
@@ -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';
+13 -7
View File
@@ -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,
})