Skip to content

Commit 882b88c

Browse files
tamang29FelixTJDietrichclaude
authored
fix: make diagram sidebar more compact for iOS app (#781)
Co-authored-by: Felix T.J. Dietrich <felix_dietrich@gmx.de> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 474029e commit 882b88c

6 files changed

Lines changed: 135 additions & 16 deletions

File tree

.changeset/real-bags-rescue.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tumaet/apollon": patch
3+
---
4+
5+
See more of the canvas on mobile with a compact element palette in both portrait and landscape.

library/lib/components/Sidebar.tsx

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import React, { useLayoutEffect, useMemo, useRef, useState } from "react"
2-
import { ColorDescriptionConfig, dropElementConfigs, LAYOUT } from "@/constants"
2+
import {
3+
ColorDescriptionConfig,
4+
dropElementConfigs,
5+
LAYOUT,
6+
MOBILE_VIEW_QUERY,
7+
} from "@/constants"
38
import { useMetadataStore } from "@/store/context"
49
import { useShallow } from "zustand/shallow"
510
import { DraggableGhost } from "./DraggableGhost"
611
import { ApollonView } from "@/typings"
712
import {
13+
COMPACT_PALETTE,
814
PALETTE,
915
computePaletteLayout,
1016
previewScaleForCell,
@@ -53,12 +59,13 @@ export const Sidebar = () => {
5359
// Measure the canvas the palette floats over (its positioned ancestor) so the
5460
// grid can size itself to the available room.
5561
const asideRef = useRef<HTMLElement>(null)
56-
const [canvas, setCanvas] = useState({ w: 0, h: 0 })
62+
const [canvas, setCanvas] = useState({ w: 0, h: 0, compact: false })
5763

5864
useLayoutEffect(() => {
5965
const aside = asideRef.current
6066
const parent = aside?.offsetParent as HTMLElement | null
6167
if (!aside || !parent) return
68+
const mobileQuery = window.matchMedia(MOBILE_VIEW_QUERY)
6269
const measure = () => {
6370
const rect = parent.getBoundingClientRect()
6471
// Size the grid to the palette's ACTUAL available height — the gap between
@@ -74,21 +81,33 @@ export const Sidebar = () => {
7481
? controls.getBoundingClientRect().top - GAP
7582
: rect.bottom - (asideTop - rect.top) // symmetric fallback
7683
const h = Math.max(0, bottomLimit - asideTop)
77-
setCanvas({ w: rect.width, h })
84+
setCanvas({ w: rect.width, h, compact: mobileQuery.matches })
7885
}
7986
measure()
8087
const observer = new ResizeObserver(measure)
8188
observer.observe(parent)
82-
return () => observer.disconnect()
89+
mobileQuery.addEventListener("change", measure)
90+
return () => {
91+
observer.disconnect()
92+
mobileQuery.removeEventListener("change", measure)
93+
}
8394
}, [])
8495

8596
// The color-description element is the last grid cell.
8697
const cellCount = paletteItems.length + 1
8798
const chromeHeight = showInteractiveSelectionView ? VIEW_SWITCH_HEIGHT : 0
8899
const layout = useMemo(
89-
() => computePaletteLayout(cellCount, canvas.w, canvas.h, chromeHeight),
90-
[cellCount, canvas.w, canvas.h, chromeHeight]
100+
() =>
101+
computePaletteLayout(
102+
cellCount,
103+
canvas.w,
104+
canvas.h,
105+
chromeHeight,
106+
canvas.compact
107+
),
108+
[cellCount, canvas.w, canvas.h, canvas.compact, chromeHeight]
91109
)
110+
const paletteMetrics = canvas.compact ? COMPACT_PALETTE : PALETTE
92111

93112
// One uniform scale for the whole palette: pick the largest scale at which
94113
// EVERY element still fits its cell, then render them all at it. This keeps
@@ -103,11 +122,12 @@ export const Sidebar = () => {
103122
config.width,
104123
config.height + previewExtraHeight(config.type),
105124
layout.cellW,
106-
layout.cellH
125+
layout.cellH,
126+
canvas.compact
107127
)
108128
)
109129
)
110-
}, [paletteItems, layout.cellW, layout.cellH])
130+
}, [paletteItems, layout.cellW, layout.cellH, canvas.compact])
111131

112132
if (paletteItems.length === 0) {
113133
return null
@@ -196,7 +216,7 @@ export const Sidebar = () => {
196216
className="apollon-palette__entries"
197217
style={{
198218
gridTemplateColumns: `repeat(${layout.cols}, ${layout.cellW}px)`,
199-
gap: PALETTE.GAP,
219+
gap: paletteMetrics.GAP,
200220
}}
201221
>
202222
{paletteItems.map((config, index) =>

library/lib/constants.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,20 @@ export const LAYOUT = Object.freeze({
179179
STEREOTYPE_NAME_GAP: 4,
180180
} as const)
181181

182+
/**
183+
* Media query for the compact "mobile" palette layout. First clause: portrait
184+
* phones, stopping below 768px so iPad portrait keeps the desktop layout. Second
185+
* clause: phones in landscape, where the short height distinguishes them from
186+
* tablets.
187+
*
188+
* Governs the PALETTE only. The webapp navbar uses its own width-only
189+
* `NARROW_VIEW_QUERY` (standalone/webapp/src/constants/responsive.ts) which does
190+
* not match landscape phones, so there the navbar stays full-size while the
191+
* palette still compacts.
192+
*/
193+
export const MOBILE_VIEW_QUERY =
194+
"(max-width: 767.95px), (max-width: 950px) and (max-height: 500px)"
195+
182196
// RFC 4122 v4 UUID via crypto.getRandomValues — available in every context the
183197
// editor runs in (secure or not, browser or Node ≥19 (global Web Crypto)),
184198
// unlike crypto.randomUUID() which requires a secure context an embeddable host

library/lib/utils/paletteLayout.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,27 @@ export const PALETTE = Object.freeze({
4949
CONTENT_INSET: 6,
5050
} as const)
5151

52+
/**
53+
* Mobile palettes should stay dense instead of expanding sparse element lists
54+
* to fill the available height. The 44px floor remains intact for touch, while
55+
* the lower comfort/cap sizes remove the large empty bands around previews.
56+
*/
57+
export const COMPACT_PALETTE = Object.freeze({
58+
CELL_MIN_H: 44,
59+
COMFORT_MIN_H: 52,
60+
// Capped tight: a capped grid can't shrink, and the CSS-clamped palette height
61+
// renders a few px under the JS-measured band, so a dense grid needs that slack
62+
// to avoid scrolling. Kept above COMFORT_MIN_H so cells stay legible.
63+
CELL_MAX_H: 56,
64+
CELL_RATIO: 1.6,
65+
GAP: 4,
66+
PAD: 4,
67+
MAX_FRAC_W: 0.5,
68+
CONTENT_INSET: 4,
69+
} as const)
70+
71+
type PaletteMetrics = typeof PALETTE | typeof COMPACT_PALETTE
72+
5273
export interface PaletteLayout {
5374
cols: number
5475
cellW: number
@@ -67,9 +88,9 @@ function cellHeightFor(
6788
count: number,
6889
budgetW: number,
6990
budgetH: number,
70-
chromeH: number
91+
chromeH: number,
92+
p: PaletteMetrics
7193
): number {
72-
const p = PALETTE
7394
const rows = Math.ceil(count / cols)
7495
const fillH = (budgetH - chromeH - 2 * p.PAD - (rows - 1) * p.GAP) / rows
7596
const cellW = (budgetW - 2 * p.PAD - (cols - 1) * p.GAP) / cols
@@ -82,14 +103,16 @@ function cellHeightFor(
82103
* @param availW measured canvas width
83104
* @param availH measured canvas height
84105
* @param chromeH height of non-grid palette chrome (view switch / hint), 0 if none
106+
* @param compact use denser mobile spacing and cell caps
85107
*/
86108
export function computePaletteLayout(
87109
itemCount: number,
88110
availW: number,
89111
availH: number,
90-
chromeH: number
112+
chromeH: number,
113+
compact = false
91114
): PaletteLayout {
92-
const p = PALETTE
115+
const p = compact ? COMPACT_PALETTE : PALETTE
93116
const floorCellW = Math.round(p.CELL_RATIO * p.CELL_MIN_H)
94117
if (itemCount <= 0 || availW <= 0 || availH <= 0) {
95118
return { cols: 1, cellW: floorCellW, cellH: p.CELL_MIN_H, scroll: false }
@@ -114,7 +137,7 @@ export function computePaletteLayout(
114137
let bestCols = 1
115138
let bestCellH = 0
116139
for (let cols = 1; cols <= maxCols; cols++) {
117-
const cellH = cellHeightFor(cols, itemCount, budgetW, budgetH, chromeH)
140+
const cellH = cellHeightFor(cols, itemCount, budgetW, budgetH, chromeH, p)
118141
if (cellH >= p.COMFORT_MIN_H) {
119142
return {
120143
cols,
@@ -152,9 +175,11 @@ export function previewScaleForCell(
152175
naturalWidth: number,
153176
naturalHeight: number,
154177
cellW: number,
155-
cellH: number
178+
cellH: number,
179+
compact = false
156180
): number {
157-
const inset = 2 * PALETTE.CONTENT_INSET
181+
const metrics = compact ? COMPACT_PALETTE : PALETTE
182+
const inset = 2 * metrics.CONTENT_INSET
158183
return Math.min(
159184
(cellW - inset) / naturalWidth,
160185
(cellH - inset) / naturalHeight

library/tests/unit/paletteLayout.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect } from "vitest"
22
import {
3+
COMPACT_PALETTE,
34
PALETTE,
45
computePaletteLayout,
56
previewScaleForCell,
@@ -92,6 +93,41 @@ describe("computePaletteLayout", () => {
9293
}
9394
})
9495

96+
it("keeps sparse mobile palettes compact in portrait and landscape", () => {
97+
const portrait = computePaletteLayout(4, 375, 540, 0, true)
98+
expect(portrait.cols).toBe(1)
99+
expect(portrait.cellH).toBe(COMPACT_PALETTE.CELL_MAX_H)
100+
101+
const portraitRows = Math.ceil(4 / portrait.cols)
102+
const portraitHeight =
103+
portraitRows * portrait.cellH +
104+
(portraitRows - 1) * COMPACT_PALETTE.GAP +
105+
2 * COMPACT_PALETTE.PAD
106+
expect(portraitHeight).toBeLessThanOrEqual(268)
107+
108+
const landscape = computePaletteLayout(6, 844, 250, 0, true)
109+
expect(landscape.cols).toBe(2)
110+
expect(landscape.cellH).toBe(COMPACT_PALETTE.CELL_MAX_H)
111+
expect(landscape.scroll).toBe(false)
112+
})
113+
114+
it("still fits a dense mobile palette without scrolling", () => {
115+
for (const [w, h] of [
116+
[375, 540],
117+
[844, 250],
118+
] as const) {
119+
const layout = computePaletteLayout(14, w, h, 0, true)
120+
const rows = Math.ceil(14 / layout.cols)
121+
const height =
122+
rows * layout.cellH +
123+
(rows - 1) * COMPACT_PALETTE.GAP +
124+
2 * COMPACT_PALETTE.PAD
125+
126+
expect(layout.scroll).toBe(false)
127+
expect(height).toBeLessThanOrEqual(h)
128+
}
129+
})
130+
95131
it("handles empty / degenerate input safely", () => {
96132
expect(computePaletteLayout(0, 800, 600, 0).cols).toBe(1)
97133
expect(computePaletteLayout(5, 0, 0, 0).cols).toBe(1)
@@ -111,4 +147,10 @@ describe("previewScaleForCell", () => {
111147
const s = previewScaleForCell(40, 40, 90, 56)
112148
expect(s).toBeCloseTo((56 - inset) / 40, 5)
113149
})
150+
151+
it("uses the tighter content inset for compact cells", () => {
152+
const compactInset = 2 * COMPACT_PALETTE.CONTENT_INSET
153+
const scale = previewScaleForCell(160, 60, 100, 60, true)
154+
expect(scale).toBeCloseTo((100 - compactInset) / 160, 5)
155+
})
114156
})

standalone/webapp/tests/e2e/editor.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,13 @@ test.describe("Mobile responsive layout", () => {
638638
const palette = page.getByTestId("apollon-palette")
639639
await expect(palette).toBeVisible()
640640

641+
const paletteBox = await palette.boundingBox()
642+
expect(paletteBox).not.toBeNull()
643+
// Six class-diagram entries stay in a dense single column instead of
644+
// stretching their rows to consume the full portrait height.
645+
expect(paletteBox!.width).toBeLessThanOrEqual(112)
646+
expect(paletteBox!.height).toBeLessThanOrEqual(396)
647+
641648
// Portrait uses the unified app-header height (NAVBAR_MIN_HEIGHT = 52).
642649
const navbar = page.locator("header")
643650
const navbarBox = await navbar.boundingBox()
@@ -715,6 +722,12 @@ test.describe("Mobile responsive layout", () => {
715722

716723
const palette = page.getByTestId("apollon-palette")
717724
await expect(palette).toBeVisible()
725+
const paletteBox = await palette.boundingBox()
726+
expect(paletteBox).not.toBeNull()
727+
// Landscape uses two compact columns, leaving substantially more canvas
728+
// visible than the old height-filling grid.
729+
expect(paletteBox!.width).toBeLessThanOrEqual(210)
730+
expect(paletteBox!.height).toBeLessThanOrEqual(210)
718731
// The floating palette fits every element without meaningful scroll in the
719732
// short (390px) viewport too; tolerance covers sub-pixel rounding.
720733
const overflow = await palette.evaluate(

0 commit comments

Comments
 (0)