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 <noreply@anthropic.com>
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
+21 -2
View File
@@ -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:
+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,
})
+35 -5
View File
@@ -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([]);
+28
View File
@@ -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. */
+45
View File
@@ -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-<id>` 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;
}
+103 -1
View File
@@ -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))}
>
<span class="dot" aria-hidden="true"></span>
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
{project.name}
{#if project.shared}
<span class="count">{project.member_count}</span>
@@ -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. -->
<!-- The dot is the control as well as the preview: what you press is
the thing you are about to change. It also keeps the heading's
actions down to two — they carry `opacity: 0` rather than
`display: none`, so they take their width whether or not you can
see them, and a third one squeezed the group's name until it
truncated. -->
<button
class="dot group-dot"
style="background: {colorVar(group.color)}"
onclick={() => (coloringGroup = coloringGroup === group.id ? null : group.id)}
aria-expanded={coloringGroup === group.id}
aria-label="Farve på {group.name}"
title="Farve"
></button>
<h2 class="fold">
<button onclick={() => app.toggleGroup(group.id)} aria-expanded={!group.collapsed}>
<svg
@@ -505,6 +522,25 @@
{/if}
</div>
{#if coloringGroup === group.id}
<div class="swatches" role="group" aria-label="Farve på {group.name}">
{#each COLORS as color (color.id)}
<button
class="swatch"
class:chosen={(group.color ?? 'graphite') === color.id}
style="background: {colorVar(color.id)}"
title={color.name}
aria-label={color.name}
aria-pressed={(group.color ?? 'graphite') === color.id}
onclick={() => {
app.setGroupColor(group.id, color.id);
coloringGroup = null;
}}
></button>
{/each}
</div>
{/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;
+18
View File
@@ -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);
+90
View File
@@ -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}
</h1>
{/if}
{#if isOwner && !project.is_inbox}
<button
class="color"
style="background: {colorVar(project.color)}"
onclick={() => (choosingColor = !choosingColor)}
aria-expanded={choosingColor}
aria-label="Farve på projektet"
title="Farve på projektet"
></button>
{/if}
<div class="views" role="group" aria-label="Visning">
{#each [['list', 'Liste'], ['board', 'Board'], ['calendar', 'Kalender']] as [value, label]}
<button
@@ -241,6 +265,22 @@
{/if}
</header>
{#if choosingColor}
<div class="panel swatches" role="group" aria-label="Vælg farve">
{#each COLORS as color (color.id)}
<button
class="swatch"
class:chosen={(project.color ?? 'graphite') === color.id}
style="background: {colorVar(color.id)}"
title={color.name}
aria-label={color.name}
aria-pressed={(project.color ?? 'graphite') === color.id}
onclick={() => setColor(color.id)}
></button>
{/each}
</div>
{/if}
{#if showShare}
<div class="panel">
<ul class="members">
@@ -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;