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}