diff --git a/CHANGELOG.md b/CHANGELOG.md index 89e5b13..1c75e35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,8 @@ All notable changes to Graph Core are documented here. The format follows [Keep ### Changed -- Cards keep a height they can show their contents in. Rows divided the window between them, with a floor as low as 80px, so a screen of cards left each one too short and the child list of every card in the top row was sliced through the middle. The view scrolls now instead of shrinking them; a handful of cards still stretch to fill the window. +- A card grows to fit what it holds, between a floor and a cap, instead of every card taking the same height. One height was wrong both ways at once: it left most of a card holding a single child empty, and still cut the list off a card holding ten. The view scrolls when they do not all fit. +- Card borders have more contrast. The shared hairline is nearly invisible against the near-black page, so cards now use a stronger border token, defined in both themes. - Cards keep a readable width ([guide](docs/guides/views.md#cards-view)). The grid chose its column count purely to make cards square, so a wide window was sliced into many narrow columns: children's names truncated to "HPC Admin C..." and notes broke mid-word. Width comes first now, and squareness decides between the counts that clear it. A window too narrow for one full-width card still gets a single column. - A note longer than its card fades on its last line instead of stopping flat, so a note that continues is not mistaken for a broken one. diff --git a/docs/guides/views.md b/docs/guides/views.md index 83b07ac..36df1bd 100644 --- a/docs/guides/views.md +++ b/docs/guides/views.md @@ -19,7 +19,7 @@ Shows nodes as nested cards with visual hierarchy. - Nested card display up to 4 levels deep - Card width: the grid fits as many columns as it can while keeping each card wide enough to read, and chooses among those the count that makes cards closest to square. A window too narrow for one full-width card still gets a single column. -- Card height: a card keeps a height it can show its header, note and a row or two of children in. When the cards do not all fit, the view scrolls rather than shrinking them, because a card below the fold is easier to deal with than one cut through the middle. A few cards still stretch to fill the window. +- Card height: a row is as tall as the card in it that needs the most room, between a floor and a cap. A single height for every card was wrong both ways at once - it wasted most of a card holding one child and still cut the list off a card holding ten. The view scrolls when the cards do not all fit, because a card below the fold is easier to deal with than one cut through the middle. The cap stops one long note from dragging every card in its row down with it. - A note longer than its card scrolls, and its last line fades rather than stopping flat, so a truncated note is not mistaken for a broken one. - Inline notes editing: click a card's notes to edit them in place. A card with no notes yet shows a muted "Add notes..." placeholder in the same spot, so there is always something to click - without it an empty card offers no target and its notes cannot be started at all. The placeholder is not shown on `card-xs`, where notes are hidden entirely, or on nodes whose notes are marked sensitive. - Color inheritance from parent nodes diff --git a/src/__tests__/useCardsLayout.test.js b/src/__tests__/useCardsLayout.test.js index 42694b1..f4489fc 100644 --- a/src/__tests__/useCardsLayout.test.js +++ b/src/__tests__/useCardsLayout.test.js @@ -206,7 +206,7 @@ describe('useCardsLayout', () => { expect(cardsGridStyle.value.display).toBe('grid') expect(cardsGridStyle.value.gridTemplateColumns).toMatch(/repeat\(\d+, 1fr\)/) expect(cardsGridStyle.value.gap).toBe('10px') - expect(cardsGridStyle.value.alignContent).toBe('stretch') + expect(cardsGridStyle.value.alignContent).toBe('start') }) }) @@ -378,7 +378,64 @@ describe('cards keep a usable height', () => { } }) - it('still lets a small number of cards stretch to fill the window', () => { - expect(rowsFor(2, 2000, 950)).toMatch(/1fr\)$/) + it('gives a couple of cards a taller floor, since there is room to spare', () => { + const min = Number((rowsFor(2, 2000, 950).match(/minmax\((\d+)px/) || [])[1]) + expect(min).toBeGreaterThanOrEqual(320) + }) +}) + +/** + * Cards hold very different amounts: one child or ten, a long note or none. + * A single height for all of them is wrong either way - it wasted most of a + * sparse card and still cut the child list off a full one. Rows take the + * height their tallest card needs, within bounds. + */ +describe('cards grow to their contents', () => { + function styleFor(count, width, height) { + const { cardsGridStyle } = useCardGrid({ + items: ref(Array.from({ length: count }, (_, i) => ({ id: i + 1 }))), + containerWidth: ref(width), + containerHeight: ref(height), + }) + return cardsGridStyle.value + } + + it('lets a row grow past the floor when its card needs the room', () => { + const rows = styleFor(14, 2000, 950).gridAutoRows + expect(rows, `rows cannot grow: ${rows}`).toMatch(/auto\)$/) + }) + + it('keeps the floor so a nearly empty card is not a sliver', () => { + const min = Number((styleFor(14, 2000, 950).gridAutoRows.match(/minmax\((\d+)px/) || [])[1]) + expect(min).toBeGreaterThanOrEqual(260) + }) + + it('packs rows from the top rather than spreading them down the window', () => { + expect(styleFor(14, 2000, 950).alignContent).toBe('start') + }) +}) + +/** + * The card border has to separate one card from the next against a near-black + * page. The shared hairline is 0.12 alpha, which all but disappears there, so + * cards use a stronger token - defined in both themes, since a card on white + * needs the same separation. + */ +describe('the card border', () => { + it('uses the stronger token, not the hairline', async () => { + const { readFileSync } = await import('fs') + const { join } = await import('path') + const css = readFileSync(join(__dirname, '../components/CardsView.css'), 'utf-8') + const card = css.slice(css.indexOf('.node-card {'), css.indexOf('}', css.indexOf('.node-card {'))) + expect(card).toContain('var(--border-strong)') + }) + + it('is defined in both themes', async () => { + const { readFileSync } = await import('fs') + const { join } = await import('path') + for (const theme of ['dark', 'light']) { + const css = readFileSync(join(__dirname, `../themes/${theme}.css`), 'utf-8') + expect(css, `${theme} has no --border-strong`).toMatch(/--border-strong:\s*rgba\([^)]+\)/) + } }) }) diff --git a/src/components/CardsView.css b/src/components/CardsView.css index 1f5ee45..193dcec 100644 --- a/src/components/CardsView.css +++ b/src/components/CardsView.css @@ -13,9 +13,12 @@ flex-direction: column; background: var(--bg-primary); border-radius: 12px; - border: 1px solid var(--border-color); + border: 1px solid var(--border-strong); overflow: hidden; text-align: left; + /* Content decides the height; this keeps one long note from dragging every + card in its row down with it, since grid rows share a height. */ + max-height: 560px; } .empty-state { diff --git a/src/composables/useCardGrid.js b/src/composables/useCardGrid.js index ca0a948..96682d7 100644 --- a/src/composables/useCardGrid.js +++ b/src/composables/useCardGrid.js @@ -90,9 +90,14 @@ export function useCardGrid({ items, containerWidth, containerHeight }) { return { display: 'grid', gridTemplateColumns: `repeat(${cols}, 1fr)`, - gridAutoRows: `minmax(${minHeight}, 1fr)`, + // Rows take the height their tallest card needs. A single height for + // every card is wrong both ways at once: it wasted most of a card with + // one child and still cut the list off a card with ten. The floor keeps + // a nearly empty card from becoming a sliver; the card's own max-height + // stops one long note from dragging its whole row down with it. + gridAutoRows: `minmax(${minHeight}, auto)`, gap: `${gap}px`, - alignContent: 'stretch', + alignContent: 'start', } }) diff --git a/src/style.css b/src/style.css index 8f9e67a..57947e6 100644 --- a/src/style.css +++ b/src/style.css @@ -22,6 +22,9 @@ --text-tertiary: #888; --border-color: rgba(255, 255, 255, 0.12); --border-subtle: rgba(255, 255, 255, 0.06); + /* For surfaces that have to read as separate objects against the page, + like cards on a grid, where the 0.12 hairline all but disappears. */ + --border-strong: rgba(255, 255, 255, 0.26); --bg-primary: #000000; --bg-secondary: #080808; diff --git a/src/themes/dark.css b/src/themes/dark.css index e9cf370..d8b0a53 100644 --- a/src/themes/dark.css +++ b/src/themes/dark.css @@ -14,6 +14,9 @@ /* Borders */ --border-color: rgba(255, 255, 255, 0.12); --border-subtle: rgba(255, 255, 255, 0.06); + /* For surfaces that have to read as separate objects against the page, + like cards on a grid, where the 0.12 hairline all but disappears. */ + --border-strong: rgba(255, 255, 255, 0.26); /* Backgrounds */ --bg-primary: #000000; diff --git a/src/themes/light.css b/src/themes/light.css index 8f58381..69e5b7e 100644 --- a/src/themes/light.css +++ b/src/themes/light.css @@ -13,6 +13,9 @@ /* Borders */ --border-color: rgba(0, 0, 0, 0.12); --border-subtle: rgba(0, 0, 0, 0.06); + /* For surfaces that have to read as separate objects against the page, + like cards on a grid, where the 0.12 hairline all but disappears. */ + --border-strong: rgba(0, 0, 0, 0.24); /* Backgrounds */ --bg-primary: #ffffff;