From 50b49be14550d77f2b67395d0da5bb73375d06cf Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Fri, 18 Sep 2026 16:40:14 +0200 Subject: [PATCH 1/2] Let the library rail go down to 160px MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floor was 220 and the rail opened at 288, and neither number was about the tree: `space-editor` and `folder-editor` carry a `min-width` of 240px, which is what gives a dropdown a width at all. Projected inline between the rows they need no such thing, so the minimum is handed down as `--editor-min-width` and the rail sets it to 0. The rows inside then wrap rather than squeeze. The name of a row was cut mid-letter rather than ellipsised on the way: `text-overflow` is a block container's property and `.node-name` is a flex one, which ignores it. It lives on a `.node-label` of its own now — nothing said so for as long as the rail could not be narrow enough to cut a name. Closes #212 Co-Authored-By: Claude Opus 5 --- docs/architecture.md | 15 +++++++++++ e2e/pageobjects/overlays.page.ts | 25 +++++++++++++++++ e2e/specs/05-spaces.e2e.ts | 23 ++++++++++++++++ .../services/settings/app-settings.model.ts | 9 ++++--- .../folder-editor.component.scss | 10 +++++-- .../space-editor/space-editor.component.scss | 10 +++++-- .../library-tree/library-tree.component.html | 9 ++++--- .../library-tree/library-tree.component.scss | 27 ++++++++++++++++--- .../library-tree.component.spec.ts | 24 ++++++++++++++--- 9 files changed, 134 insertions(+), 18 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 07667bf..9a63532 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -735,6 +735,21 @@ drag in this application — HTML5 drag and drop does not work in this WebView a `role="separator"` so the arrow keys move it too. It is clamped on the way in _and_ on the way out (`RAIL_WIDTH`), because a preferences file written by hand is an input like any other. +**The floor is the tree's own, not the panels'.** It stopped at 220px, and that number had +nothing to do with the rows: `space-editor` and `folder-editor` carry a `min-width` of 240px, +which is what gives a _dropdown_ a width at all. Projected inline between the rows they need no +such thing, so the minimum is handed down as `--editor-min-width` and the rail sets it to `0` — +a custom property, because a rule spelled in the rail's stylesheet is rewritten with the rail's +own `_ngcontent` and can never reach into a component's. The rows inside then **wrap rather than +squeeze**: under about 230px the submit drops below the field. The floor is 160, and so is the +default, the width being remembered like the window's own geometry — the default is a first +launch and nothing else. + +⚠️ **A row's name is ellipsised by `.node-label`, a box of its own.** `text-overflow` is a +property of a block container and `.node-name` is a flex one, which ignores it outright: the +name was cut mid-letter. Nothing said so for as long as the rail could not be narrow enough to +cut one. + ### Managing spaces from the switcher The space switcher's dropdown has three mutually exclusive states: the menu, the creation diff --git a/e2e/pageobjects/overlays.page.ts b/e2e/pageobjects/overlays.page.ts index 5ef0268..f2e7d0d 100644 --- a/e2e/pageobjects/overlays.page.ts +++ b/e2e/pageobjects/overlays.page.ts @@ -48,6 +48,31 @@ export const rail = { ); await browser.keys(['ArrowRight', 'ArrowRight']); }, + + /** The same edge the other way, far enough to reach the floor from any width. */ + async narrow(): Promise { + await browser.execute( + (selector: string) => (document.querySelector(selector) as HTMLElement | null)?.focus(), + testid('library-rail-edge'), + ); + await browser.keys(Array.from({ length: 30 }, () => 'ArrowLeft')); + }, + + /** + * How far a control sticks out past the rail's right edge. The panels a ⋯ opens are + * projected inline between the rows, so one holding a minimum of its own would spill + * over the edge instead of following it. + */ + overflowOf: (selector: string): Promise => + browser.execute( + (railSelector: string, controlSelector: string) => { + const edge = document.querySelector(railSelector)?.getBoundingClientRect().right; + const control = document.querySelector(controlSelector)?.getBoundingClientRect().right; + return edge === undefined || control === undefined ? -1 : Math.round(Math.max(0, control - edge)); + }, + testid('library-rail'), + selector, + ), }; /** diff --git a/e2e/specs/05-spaces.e2e.ts b/e2e/specs/05-spaces.e2e.ts index 7328f17..a6b3248 100644 --- a/e2e/specs/05-spaces.e2e.ts +++ b/e2e/specs/05-spaces.e2e.ts @@ -1,5 +1,6 @@ import { browser, expect } from '@wdio/globals'; +import { RAIL_WIDTH } from '@core/services/settings/app-settings.model'; import { canvas } from '../pageobjects/canvas.page.js'; import { rail, spaces } from '../pageobjects/overlays.page.js'; import { eventually, press, reloadCanvas, testid } from '../support/app.js'; @@ -207,6 +208,28 @@ describe('Spaces', () => { expect(await rail.width()).toBe(widened); }); + /** + * ⚠️ The two panels a ⋯ opens used to hold the floor up with a `min-width` of their + * own, and releasing it is the whole of #212: at the floor the delete control has to + * still be inside the rail, which only a laid-out window can say. + */ + it('narrows to its floor with the space panel still inside it', async () => { + await rail.show(); + await rail.narrow(); + const narrowed = await eventually( + () => rail.width(), + (width) => width === RAIL_WIDTH.min, + 'the rail never reached its floor', + ); + + await browser.$(testid('space-edit')).click(); + + expect(narrowed).toBe(RAIL_WIDTH.min); + // The rename submit and not the delete: the delete only exists while another space + // stands to take the notes in, and it is the same row either way. + expect(await rail.overflowOf(testid('space-rename-submit'))).toBe(0); + }); + /** Hidden or shown is a preference, so it has to survive the page it was set on. */ it('gives the switchers back when it is put away, and is remembered', async () => { await rail.hide(); diff --git a/src/app/core/services/settings/app-settings.model.ts b/src/app/core/services/settings/app-settings.model.ts index 79f2995..c62a3d4 100644 --- a/src/app/core/services/settings/app-settings.model.ts +++ b/src/app/core/services/settings/app-settings.model.ts @@ -19,11 +19,12 @@ export const DENSITIES = ['comfortable', 'compact'] as const; export type Density = (typeof DENSITIES)[number]; /** - * ⚠️ Narrow enough to leave the canvas usable on a small window, wide enough for the - * panels a row opens — they are 240px of form, and a rail narrower than that would cut - * the delete button off. + * ⚠️ The floor is the tree's own, not the panels': `--editor-min-width` is what lets + * `space-editor` and `folder-editor` follow the rail rather than hold it open at their + * 240px. It opens at that floor — the width is remembered, so the default is a first + * launch and nothing else. */ -export const RAIL_WIDTH = { min: 220, max: 520, default: 288 } as const; +export const RAIL_WIDTH = { min: 160, max: 520, default: 160 } as const; export interface AppSettings { readonly locale: LocaleChoice; diff --git a/src/app/notes/header/folder-editor/folder-editor.component.scss b/src/app/notes/header/folder-editor/folder-editor.component.scss index 17b1aa0..61b1ef8 100644 --- a/src/app/notes/header/folder-editor/folder-editor.component.scss +++ b/src/app/notes/header/folder-editor/folder-editor.component.scss @@ -1,11 +1,13 @@ @use 'mixins' as *; +// A dropdown needs the 240px to have a width at all; the rail hands down 0, and the +// panel follows the rail rather than holding it open. .editor-panel { display: flex; flex-direction: column; gap: 8px; padding: 4px; - min-width: 240px; + min-width: var(--editor-min-width, 240px); } .editor-title { @@ -20,6 +22,7 @@ .editor-colours { display: flex; + flex-wrap: wrap; gap: 6px; padding: 0 4px; } @@ -49,6 +52,7 @@ .editor-form { display: flex; + flex-wrap: wrap; gap: 6px; padding: 0 4px; } @@ -57,7 +61,9 @@ @include text-field; @include surface(var(--bg-1), 6px); - flex: 1; + // A basis and not 0: it is what makes the row wrap once the button no longer fits + // beside the field, instead of squeezing the two of them. + flex: 1 1 112px; min-width: 0; padding: 6px 8px; font-size: 13px; diff --git a/src/app/notes/header/space-editor/space-editor.component.scss b/src/app/notes/header/space-editor/space-editor.component.scss index e8059f9..bb2bbb6 100644 --- a/src/app/notes/header/space-editor/space-editor.component.scss +++ b/src/app/notes/header/space-editor/space-editor.component.scss @@ -1,11 +1,13 @@ @use 'mixins' as *; +// A dropdown needs the 240px to have a width at all; the rail hands down 0, and the +// panel follows the rail rather than holding it open. .editor-panel { display: flex; flex-direction: column; gap: 8px; padding: 4px; - min-width: 240px; + min-width: var(--editor-min-width, 240px); } .editor-title { @@ -45,6 +47,7 @@ .editor-form { display: flex; + flex-wrap: wrap; gap: 6px; padding: 0 4px; } @@ -53,7 +56,9 @@ @include text-field; @include surface(var(--bg-1), 6px); - flex: 1; + // A basis and not 0: it is what makes the row wrap once the button no longer fits + // beside the field, instead of squeezing the two of them. + flex: 1 1 112px; min-width: 0; padding: 6px 8px; font-size: 13px; @@ -89,6 +94,7 @@ .editor-delete-row { display: flex; + flex-wrap: wrap; gap: 6px; padding: 0 4px; } diff --git a/src/app/notes/sidebar/library-tree/library-tree.component.html b/src/app/notes/sidebar/library-tree/library-tree.component.html index 1761a76..20ef7b8 100644 --- a/src/app/notes/sidebar/library-tree/library-tree.component.html +++ b/src/app/notes/sidebar/library-tree/library-tree.component.html @@ -42,7 +42,7 @@

{{ 'sidebar.library' | transloco (click)="chooseSpace(null)" > - {{ 'notes.allSpaces' | transloco }} + {{ 'notes.allSpaces' | transloco }} @@ -81,7 +81,7 @@

{{ 'sidebar.library' | transloco {{ 'notes.pinnedSpace' | transloco }} } - {{ space.name }} + {{ space.name }} } diff --git a/src/app/notes/sidebar/library-tree/library-tree.component.scss b/src/app/notes/sidebar/library-tree/library-tree.component.scss index 6f76f73..11f432c 100644 --- a/src/app/notes/sidebar/library-tree/library-tree.component.scss +++ b/src/app/notes/sidebar/library-tree/library-tree.component.scss @@ -75,6 +75,7 @@ .rail-form { display: flex; + flex-wrap: wrap; gap: 6px; padding: 2px 4px; } @@ -83,7 +84,8 @@ @include text-field; @include surface(var(--bg-0), 6px); - flex: 1; + // A basis and not 0: what makes the row wrap rather than squeeze both onto one line. + flex: 1 1 96px; min-width: 0; padding: 5px 7px; font-size: 12.5px; @@ -140,6 +142,15 @@ // A name longer than the rail is cut, never wrapped: a wrapped row breaks the levels. white-space: nowrap; overflow: hidden; +} + +// ⚠️ The ellipsis belongs to a child and not to the row: `text-overflow` is a property of +// a block container, and a flex one simply ignores it — the name was cut mid-letter. It +// went unseen until the rail could be narrow enough to cut one (#212). +.node-label { + flex: 1; + min-width: 0; + overflow: hidden; text-overflow: ellipsis; } @@ -219,14 +230,24 @@ background: var(--folder-hue); } -// ⚠️ Not indented with its row: the panel is 240px of form, and the rail has exactly -// that much room once its own padding is taken out. +// Not indented with its row: the rail is narrow enough as it is. ⚠️ And its minimum is +// released here, so the panel follows the rail where a dropdown needs 240px to have a +// width at all — a custom property because a rule spelled here would be rewritten with +// the rail's own `_ngcontent` and never reach the panel (#207). app-space-editor, app-folder-editor { + --editor-min-width: 0; + display: block; margin: 2px 0 6px; } +// Back out of the folder indent, which the row above needs and a form does not: at the +// floor those 16px are the five colours on one line rather than four and one. +.folders app-folder-editor { + margin-left: -16px; +} + .node-name:focus-visible, .node-menu:focus-visible, .node-twisty:focus-visible, diff --git a/src/app/notes/sidebar/library-tree/library-tree.component.spec.ts b/src/app/notes/sidebar/library-tree/library-tree.component.spec.ts index 54f76d2..bc18702 100644 --- a/src/app/notes/sidebar/library-tree/library-tree.component.spec.ts +++ b/src/app/notes/sidebar/library-tree/library-tree.component.spec.ts @@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { Folder } from '@core/model/folder.model'; import { Space } from '@core/model/space.model'; +import { RAIL_WIDTH } from '@core/services/settings/app-settings.model'; import { provideTranslocoTesting } from '@testing/provide-transloco-testing'; import { LibraryTreeComponent } from './library-tree.component'; @@ -70,6 +71,23 @@ describe('LibraryTreeComponent', () => { expect(names('[data-testid="folder-option"]')).toEqual(['Perf', 'Migrations', 'Async']); }); + /** + * ⚠️ The name sits in a box of its own rather than loose in the row: `text-overflow` is + * a block container's property and the row is a flex one, which ignores it — a name too + * long for the rail was cut mid-letter instead of ellipsised. + */ + it('gives every row name a box the ellipsis can apply to', () => { + expect(names('.node-label')).toEqual([ + 'Tous les espaces', + 'SQL', + 'Perf', + 'Migrations', + 'Nouveau dossier', + 'Rust', + 'Async', + ]); + }); + it('opens on the whole library rather than on what was expanded before', () => { expect(each('[data-testid="space-twisty"]').map((t) => t.getAttribute('aria-expanded'))).toEqual([ 'true', @@ -165,15 +183,15 @@ describe('LibraryTreeComponent', () => { edge.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); await fixture.whenStable(); - expect(widths).toEqual([304]); + expect(widths).toEqual([RAIL_WIDTH.default + 16]); - fixture.componentRef.setInput('width', 220); + fixture.componentRef.setInput('width', RAIL_WIDTH.min); await fixture.whenStable(); edge.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); await fixture.whenStable(); // Already at the minimum: clamped to the same number, so nothing is emitted. - expect(widths).toEqual([304]); + expect(widths).toEqual([RAIL_WIDTH.default + 16]); }); it('offers every other space as a refuge when a space is being deleted', async () => { From 54b45019370a17b14409c0a378cb5fa458033d46 Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Fri, 18 Sep 2026 17:12:36 +0200 Subject: [PATCH 2/2] Line the rail's rows up on one column, and make the twisty visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ✦ sat inside the name of "all spaces", so that one label started its own width further right than every space under it. It moves into the column the twisty already owns, which the two now share a width with. ⚠️ ▾ is a *small* triangle by design: at the 12px the row's other marks use, it drew a speck nobody could identify. 16px is where it reads as a triangle — read off the rendered pixels at five sizes, not guessed. Co-Authored-By: Claude Opus 5 --- .../library-tree/library-tree.component.html | 3 +-- .../library-tree/library-tree.component.scss | 21 +++++++++++++++---- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/app/notes/sidebar/library-tree/library-tree.component.html b/src/app/notes/sidebar/library-tree/library-tree.component.html index 20ef7b8..dcd1063 100644 --- a/src/app/notes/sidebar/library-tree/library-tree.component.html +++ b/src/app/notes/sidebar/library-tree/library-tree.component.html @@ -32,7 +32,7 @@

{{ 'sidebar.library' | transloco
  • - +
    diff --git a/src/app/notes/sidebar/library-tree/library-tree.component.scss b/src/app/notes/sidebar/library-tree/library-tree.component.scss index 11f432c..9c1be7c 100644 --- a/src/app/notes/sidebar/library-tree/library-tree.component.scss +++ b/src/app/notes/sidebar/library-tree/library-tree.component.scss @@ -167,7 +167,7 @@ .node-icon, .node-pin { flex-shrink: 0; - font-size: 10px; + font-size: 12px; color: var(--text-2); } @@ -189,7 +189,7 @@ flex-shrink: 0; padding: 4px 3px; border-radius: 5px; - font-size: 10px; + font-size: 12px; line-height: 1; color: var(--text-2); cursor: pointer; @@ -214,10 +214,23 @@ color: var(--text-0); } -// The "all spaces" row has no folders to open, and its name still has to line up. +// ⚠️ The twisty and the ✦ that stands in for it are one column, sized once: it is what +// puts the name of "all spaces" on the same edge as every space under it. The ✦ used to +// sit inside the name, which pushed that one label right by its own width. +.node-twisty, .node-twisty-space { - width: 16px; + display: flex; + align-items: center; + justify-content: center; flex-shrink: 0; + width: 18px; +} + +// ⚠️ ▾ is a *small* triangle by design: at the 12px the row's other marks use it draws a +// speck of ink, and nobody could tell what it was. 16px is where it reads as a triangle — +// measured against the rendered pixels, not guessed, and still lighter than the label. +.node-twisty { + font-size: 16px; } .folder-swatch {