feat(projects): section headings you can count at a glance, and an Inbox that speaks the language
Two from use. Section headings were small uppercase text over a hairline — a heading you have to look for. A project with four sections is four groups, and the eye should be able to count them without reading a word, so the heading gets a ground of its own and the rows sit against the page. `--surface` rather than a tint of the accent: the colour on that page belongs to the project, and a section is not a second thing to colour. A step in lightness separates them in all five themes without anybody measuring anything. And the Inbox. The server names it at account creation, before anybody has seen a settings page, so it is the one project name nobody chose — and an account made in Danish went on saying "Indbakke" inside an English interface, which reads as a string somebody forgot to translate. It was. `projectName()` translates it while it still carries the name the server gave it; rename it and that is your name for it, in every language, like any other project. The rename field keeps the raw name, or editing a translated label would save the translation. The screenshot set gains the list view, which is where sections read as bands rather than as columns — and the shape most people actually work in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 167 KiB |
@@ -1157,8 +1157,13 @@ test('kontoens sprog skifter hele fladen, ikke kun parseren', async ({ page }) =
|
|||||||
/januar|februar|marts|maj|juni|juli|oktober|december/i
|
/januar|februar|marts|maj|juni|juli|oktober|december/i
|
||||||
);
|
);
|
||||||
|
|
||||||
// The Inbox keeps its name: it is a project row created at signup, not a
|
// The Inbox is named by the server at account creation, before anybody has seen
|
||||||
// string in the interface, and the person can rename it like any other.
|
// a settings page — so it is the one project name nobody chose, and it follows
|
||||||
|
// the language like a label. Rename it and that becomes your name for it in
|
||||||
|
// every language, the same rule as any other project.
|
||||||
|
await expect(
|
||||||
|
page.getByRole('navigation', { name: 'Main menu' }).getByRole('link', { name: 'Inbox' })
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
// Danish again, for everything that runs after this.
|
// Danish again, for everything that runs after this.
|
||||||
await page.goto('/indstillinger');
|
await page.goto('/indstillinger');
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ const SHOTS = [
|
|||||||
{ name: 'upcoming', path: '/upcoming', theme: 'dark', prepare: showMonth },
|
{ name: 'upcoming', path: '/upcoming', theme: 'dark', prepare: showMonth },
|
||||||
{ name: 'week', path: '/upcoming', theme: 'dark', prepare: showWeek },
|
{ name: 'week', path: '/upcoming', theme: 'dark', prepare: showWeek },
|
||||||
{ name: 'board', path: (ids) => `/projekt/${ids.work}`, theme: 'dark' },
|
{ name: 'board', path: (ids) => `/projekt/${ids.work}`, theme: 'dark' },
|
||||||
|
// The same project as a list, which is where sections read as bands rather than
|
||||||
|
// as columns — and the shape most people actually work in.
|
||||||
|
{ name: 'sections', path: (ids) => `/projekt/${ids.work}`, theme: 'dark', prepare: showList },
|
||||||
{ name: 'detail', path: '/', theme: 'dark', prepare: openFirstTask },
|
{ name: 'detail', path: '/', theme: 'dark', prepare: openFirstTask },
|
||||||
{ name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' },
|
{ name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' },
|
||||||
{ name: 'themes', path: '/indstillinger', theme: 'dark' },
|
{ name: 'themes', path: '/indstillinger', theme: 'dark' },
|
||||||
@@ -59,6 +62,11 @@ const SHOTS = [
|
|||||||
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' }
|
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
async function showList(page) {
|
||||||
|
await page.getByRole('button', { name: 'Liste', exact: true }).click();
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
}
|
||||||
|
|
||||||
async function showMonth(page) {
|
async function showMonth(page) {
|
||||||
await page.getByRole('button', { name: 'Måned', exact: true }).click();
|
await page.getByRole('button', { name: 'Måned', exact: true }).click();
|
||||||
await page.waitForTimeout(800);
|
await page.waitForTimeout(800);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { app, sidebar } from '$lib/stores.svelte.js';
|
import { app, sidebar, projectName } from '$lib/stores.svelte.js';
|
||||||
import { api } from '$lib/api.js';
|
import { api } from '$lib/api.js';
|
||||||
import { TASK, PROJECT, GROUP, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
import { TASK, PROJECT, GROUP, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||||
import { COLORS, colorVar } from '$lib/colors.js';
|
import { COLORS, colorVar } from '$lib/colors.js';
|
||||||
@@ -410,7 +410,7 @@
|
|||||||
ondrop={(e) => (carries(e, TASK) ? onTaskDrop(e, project) : sortable && onDrop(e, project))}
|
ondrop={(e) => (carries(e, TASK) ? onTaskDrop(e, project) : sortable && onDrop(e, project))}
|
||||||
>
|
>
|
||||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
|
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
|
||||||
{project.name}
|
{projectName(project)}
|
||||||
{#if project.shared}
|
{#if project.shared}
|
||||||
<span class="count">{project.member_count}</span>
|
<span class="count">{project.member_count}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* necessarily the last one sent.
|
* necessarily the last one sent.
|
||||||
*/
|
*/
|
||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app, projectName } from '$lib/stores.svelte.js';
|
||||||
import { focusOnMount } from '$lib/focus.js';
|
import { focusOnMount } from '$lib/focus.js';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
|
|
||||||
@@ -436,7 +436,7 @@
|
|||||||
<label for="project">{t('detail.project')}</label>
|
<label for="project">{t('detail.project')}</label>
|
||||||
<select id="project" bind:value={projectId} onchange={saveProject}>
|
<select id="project" bind:value={projectId} onchange={saveProject}>
|
||||||
{#each app.projects as project (project.id)}
|
{#each app.projects as project (project.id)}
|
||||||
<option value={project.id}>{project.name}</option>
|
<option value={project.id}>{projectName(project)}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -620,6 +620,28 @@ class AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What to call a project on screen.
|
||||||
|
*
|
||||||
|
* Almost always its own name — somebody wrote it and it is theirs. The Inbox is the
|
||||||
|
* exception: the server names it at account creation, before anybody has seen a
|
||||||
|
* settings page, so it is the one project name nobody chose. Leaving it as stored
|
||||||
|
* meant an account created in Danish said "Indbakke" in an English interface, which
|
||||||
|
* reads as a string somebody forgot to translate. It was.
|
||||||
|
*
|
||||||
|
* Only while it still carries a name the server gave it. Rename it and that is your
|
||||||
|
* name for it, in every language — the same rule as any other project.
|
||||||
|
*/
|
||||||
|
const SYSTEM_INBOX_NAMES = new Set(['indbakke', 'inbox']);
|
||||||
|
|
||||||
|
export function projectName(project) {
|
||||||
|
if (!project) return '';
|
||||||
|
if (project.is_inbox && SYSTEM_INBOX_NAMES.has(project.name.trim().toLowerCase())) {
|
||||||
|
return t('nav.inbox');
|
||||||
|
}
|
||||||
|
return project.name;
|
||||||
|
}
|
||||||
|
|
||||||
/** Enough of a task's title to recognise it, without a toast the width of the page. */
|
/** Enough of a task's title to recognise it, without a toast the width of the page. */
|
||||||
function truncate(text, max = 40) {
|
function truncate(text, max = 40) {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
*/
|
*/
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app, projectName } from '$lib/stores.svelte.js';
|
||||||
import { colorVar } from '$lib/colors.js';
|
import { colorVar } from '$lib/colors.js';
|
||||||
import { focusOnMount } from '$lib/focus.js';
|
import { focusOnMount } from '$lib/focus.js';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
@@ -165,7 +165,7 @@
|
|||||||
<a href="/projekt/{project.id}">
|
<a href="/projekt/{project.id}">
|
||||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"
|
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"
|
||||||
></span>
|
></span>
|
||||||
{project.name}
|
{projectName(project)}
|
||||||
<!-- Counted by the server in the same request. Open tasks, not all
|
<!-- Counted by the server in the same request. Open tasks, not all
|
||||||
of them: "12" beside a project finished last year is a number
|
of them: "12" beside a project finished last year is a number
|
||||||
that means nothing. -->
|
that means nothing. -->
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
* audit log must not have.
|
* audit log must not have.
|
||||||
*/
|
*/
|
||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app, projectName } from '$lib/stores.svelte.js';
|
||||||
import { eventName, eventDetail } from '$lib/events.js';
|
import { eventName, eventDetail } from '$lib/events.js';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
import { ago } from '$lib/when.js';
|
import { ago } from '$lib/when.js';
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
<select bind:value={filterProject}>
|
<select bind:value={filterProject}>
|
||||||
<option value="">{t('history.all')}</option>
|
<option value="">{t('history.all')}</option>
|
||||||
{#each projects as project (project.id)}
|
{#each projects as project (project.id)}
|
||||||
<option value={project.id}>{project.name}</option>
|
<option value={project.id}>{projectName(project)}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app, completedView } from '$lib/stores.svelte.js';
|
import { app, completedView, projectName } from '$lib/stores.svelte.js';
|
||||||
import { COLORS, colorVar } from '$lib/colors.js';
|
import { COLORS, colorVar } from '$lib/colors.js';
|
||||||
import { eventName, eventDetail } from '$lib/events.js';
|
import { eventName, eventDetail } from '$lib/events.js';
|
||||||
import { TASK, carries, dragged, accept } from '$lib/dnd.js';
|
import { TASK, carries, dragged, accept } from '$lib/dnd.js';
|
||||||
@@ -434,10 +434,10 @@
|
|||||||
<h1 class:renameable={isOwner}>
|
<h1 class:renameable={isOwner}>
|
||||||
{#if isOwner}
|
{#if isOwner}
|
||||||
<button onclick={() => (editing = true)} title={t('project.clickToRename')}>
|
<button onclick={() => (editing = true)} title={t('project.clickToRename')}>
|
||||||
{project.name}
|
{projectName(project)}
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
{project.name}
|
{projectName(project)}
|
||||||
{/if}
|
{/if}
|
||||||
</h1>
|
</h1>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1273,19 +1273,39 @@
|
|||||||
margin-bottom: var(--s2);
|
margin-bottom: var(--s2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A band rather than a rule.
|
||||||
|
*
|
||||||
|
* Small uppercase text over a hairline is a heading you have to look for. A
|
||||||
|
* project with four sections is four groups, and the eye should be able to
|
||||||
|
* count them without reading a word — so the heading gets a ground of its own,
|
||||||
|
* and the rows below sit against the page.
|
||||||
|
*
|
||||||
|
* `--surface`, not a tint of the accent: the colour on this page belongs to the
|
||||||
|
* project, and a section is not a second thing to colour. What is wanted is
|
||||||
|
* separation, and a step in lightness gives that in all five themes without
|
||||||
|
* anybody measuring anything.
|
||||||
|
*/
|
||||||
.section-head {
|
.section-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: var(--s3);
|
gap: var(--s3);
|
||||||
border-bottom: 1px solid var(--line);
|
background: var(--surface);
|
||||||
margin-bottom: var(--s2);
|
border-radius: var(--radius);
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
margin-bottom: var(--s1);
|
||||||
|
/* Pulled out to the padding of the rows below, so the band lines up with the
|
||||||
|
text it heads rather than with the rows' own inset. */
|
||||||
|
margin-left: calc(var(--s2) * -1);
|
||||||
|
margin-right: calc(var(--s2) * -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-head h2 {
|
.section-head h2 {
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
padding: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
color: var(--ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hidden until the section is hovered or something in it has focus: a heading
|
/* Hidden until the section is hovered or something in it has focus: a heading
|
||||||
|
|||||||