feat(ui): phone layout up to 768 px
Phones in landscape and small tablets (iPad mini upright) got the desktop layout from 640 px. The sm breakpoint is now 768 px; the media queries in index.css and the width checks in code (isPhoneWidth) follow it. The desktop header grid applies from sm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8891dc046d
commit
117cf3445e
8 files changed
+39
-20
No files matched your search
@@ -85,7 +85,7 @@ Fremde Branches (z. B. von anderen Agenten) werden vor dem Mergen geprüft.
|
|||||||
## Oberfläche
|
## Oberfläche
|
||||||
|
|
||||||
- **Einheitliche Bedienelemente:** Personen-Suche, Profilbild-Stapel, Zähler und weitere Muster stehen in [Doku/ui-patterns.md](Doku/ui-patterns.md). Vorhandene Komponenten verwenden statt neuer Varianten; neue gemeinsame Muster dort ergänzen.
|
- **Einheitliche Bedienelemente:** Personen-Suche, Profilbild-Stapel, Zähler und weitere Muster stehen in [Doku/ui-patterns.md](Doku/ui-patterns.md). Vorhandene Komponenten verwenden statt neuer Varianten; neue gemeinsame Muster dort ergänzen.
|
||||||
- **Handy und Desktop:** Unter `sm` ist Handy-Layout, ab `lg` die volle Desktop-Ansicht (z. B. Seitenleiste neben der Notiz). Wird nur das Handy geändert, bleiben die Werte ab `sm`/`lg` exakt wie vorher.
|
- **Handy und Desktop:** Unter `sm` ist Handy-Layout, ab `lg` die volle Desktop-Ansicht (z. B. Seitenleiste neben der Notiz). `sm` liegt bei **768 px**, nicht bei Tailwinds 640 px (Handys quer und kleine Tablets bekommen das Handy-Layout); `md` ist gleich `sm`. Im Code dafür `isPhoneWidth()` aus `lib/breakpoints.ts` verwenden, keine feste Pixelzahl. Wird nur das Handy geändert, bleiben die Werte ab `sm`/`lg` exakt wie vorher.
|
||||||
- **Antippen am Handy:** Ziele mindestens 44 px hoch (`h-11 sm:h-…`). Auf reinen Touch-Geräten gibt es kein HTML5-Drag-&-Drop; Kanban nutzt dafür `useKanbanTouchDrag`, die Notizen die Leiste über der Tastatur (`MobileOutlineBar`).
|
- **Antippen am Handy:** Ziele mindestens 44 px hoch (`h-11 sm:h-…`). Auf reinen Touch-Geräten gibt es kein HTML5-Drag-&-Drop; Kanban nutzt dafür `useKanbanTouchDrag`, die Notizen die Leiste über der Tastatur (`MobileOutlineBar`).
|
||||||
- **Themes:** Farben nur über die Theme-Variablen bzw. Klassen (`bg-panel`, `bg-canvas`, `text-main`, `text-muted`, `border-border`, `bg-accent` …), keine festen Farben. Rahmen mit `bg-panel`/`bg-canvas` bekommen die Ecken des Themes automatisch; liegt darin ein Kopfbereich mit eigenem Hintergrund, braucht der Rahmen `overflow-hidden`, sonst ragen bei runden Ecken die Kanten heraus.
|
- **Themes:** Farben nur über die Theme-Variablen bzw. Klassen (`bg-panel`, `bg-canvas`, `text-main`, `text-muted`, `border-border`, `bg-accent` …), keine festen Farben. Rahmen mit `bg-panel`/`bg-canvas` bekommen die Ecken des Themes automatisch; liegt darin ein Kopfbereich mit eigenem Hintergrund, braucht der Rahmen `overflow-hidden`, sonst ragen bei runden Ecken die Kanten heraus.
|
||||||
- **Installierte App (PWA):** Manifest-Änderungen (Farben, Verknüpfungen) übernimmt Android erst, wenn es die App aktualisiert (bis etwa einen Tag) oder nach Neuinstallation; normaler App-Code kommt beim nächsten Start. Ändert sich der Cache-Inhalt des Service Workers, `VERSION` in `frontend/public/sw.js` erhöhen.
|
- **Installierte App (PWA):** Manifest-Änderungen (Farben, Verknüpfungen) übernimmt Android erst, wenn es die App aktualisiert (bis etwa einen Tag) oder nach Neuinstallation; normaler App-Code kommt beim nächsten Start. Ändert sich der Cache-Inhalt des Service Workers, `VERSION` in `frontend/public/sw.js` erhöhen.
|
||||||
+10
-9
@@ -82,6 +82,7 @@ import { consumePendingShares, formatSharedThought } from './lib/shareTarget';
|
|||||||
import { APP_VERSION } from './version';
|
import { APP_VERSION } from './version';
|
||||||
import { readViewSession, writeViewSession } from './lib/viewSession';
|
import { readViewSession, writeViewSession } from './lib/viewSession';
|
||||||
import { setVoicePreference } from './lib/voice';
|
import { setVoicePreference } from './lib/voice';
|
||||||
|
import { isPhoneWidth } from './lib/breakpoints';
|
||||||
|
|
||||||
// Connection status at the profile (useConnectionState); full class names for Tailwind
|
// Connection status at the profile (useConnectionState); full class names for Tailwind
|
||||||
const CONNECTION_STATUS = {
|
const CONNECTION_STATUS = {
|
||||||
@@ -302,7 +303,7 @@ export const App: React.FC = () => {
|
|||||||
// pendingShortcut above re-runs this effect and must not cancel the timer)
|
// pendingShortcut above re-runs this effect and must not cancel the timer)
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
if (target === 'triage') handleTriggerAddButton();
|
if (target === 'triage') handleTriggerAddButton();
|
||||||
else if (window.innerWidth < 640 || target === 'lists') primaryActionRef.current?.();
|
else if (isPhoneWidth() || target === 'lists') primaryActionRef.current?.();
|
||||||
}, 400);
|
}, 400);
|
||||||
}, [pendingShortcut, user?.id, activeTab]);
|
}, [pendingShortcut, user?.id, activeTab]);
|
||||||
|
|
||||||
@@ -465,7 +466,7 @@ export const App: React.FC = () => {
|
|||||||
// Immediately offer Web-App installation on mobile if not yet running as installed standalone app
|
// Immediately offer Web-App installation on mobile if not yet running as installed standalone app
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || isStandaloneApp) return;
|
if (!user || isStandaloneApp) return;
|
||||||
if (window.innerWidth < 640 && !sessionStorage.getItem('mindsift_pwa_dismissed')) {
|
if (isPhoneWidth() && !sessionStorage.getItem('mindsift_pwa_dismissed')) {
|
||||||
setShowPwaInstallModal(true);
|
setShowPwaInstallModal(true);
|
||||||
}
|
}
|
||||||
}, [user?.id, isStandaloneApp]);
|
}, [user?.id, isStandaloneApp]);
|
||||||
@@ -828,7 +829,7 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
const handleTriggerAddButton = () => {
|
const handleTriggerAddButton = () => {
|
||||||
triggerHaptic(20);
|
triggerHaptic(20);
|
||||||
if (window.innerWidth < 640) {
|
if (isPhoneWidth()) {
|
||||||
openMobileTriageCapture();
|
openMobileTriageCapture();
|
||||||
} else {
|
} else {
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
@@ -1008,16 +1009,16 @@ export const App: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop Header: 3-column Layout (Logo, InstantCaptureBar, Profile Pill). From md a grid with equal
|
{/* Desktop Header: 3-column grid (Logo, InstantCaptureBar, Profile Pill) with equal outer columns, so
|
||||||
outer columns, so the capture bar stays exactly centred whatever name or status stands on the right */}
|
the capture bar stays exactly centred whatever name or status stands on the right */}
|
||||||
<div className="hidden sm:flex md:grid md:grid-cols-[minmax(12rem,1fr)_minmax(0,36rem)_minmax(12rem,1fr)] max-w-6xl mx-auto px-4 py-2 items-start justify-between gap-6">
|
<div className="hidden sm:grid grid-cols-[minmax(12rem,1fr)_minmax(0,36rem)_minmax(12rem,1fr)] max-w-6xl mx-auto px-4 py-2 items-start gap-6">
|
||||||
{/* Logo & Headline */}
|
{/* Logo & Headline */}
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveTab('triage');
|
setActiveTab('triage');
|
||||||
handleTriggerAddButton();
|
handleTriggerAddButton();
|
||||||
}}
|
}}
|
||||||
className="h-9 flex items-center gap-2.5 cursor-pointer group select-none flex-shrink-0 md:justify-self-start"
|
className="h-9 flex items-center gap-2.5 cursor-pointer group select-none flex-shrink-0 justify-self-start"
|
||||||
>
|
>
|
||||||
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
|
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
|
||||||
<Brain className="w-4 h-4" />
|
<Brain className="w-4 h-4" />
|
||||||
@@ -1040,7 +1041,7 @@ export const App: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
|
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
|
||||||
<div className="flex-1 max-w-xl mx-auto md:w-full flex items-center gap-2">
|
<div className="w-full max-w-xl mx-auto flex items-center gap-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<InstantCaptureBar
|
<InstantCaptureBar
|
||||||
onCaptured={handleCaptured}
|
onCaptured={handleCaptured}
|
||||||
@@ -1060,7 +1061,7 @@ export const App: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User Profile Pill & Dropdown Menu */}
|
{/* User Profile Pill & Dropdown Menu */}
|
||||||
<div className="relative h-9 flex items-center gap-2 flex-shrink-0 md:justify-self-end" ref={dropdownRef}>
|
<div className="relative h-9 flex items-center gap-2 flex-shrink-0 justify-self-end" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
triggerHaptic(10);
|
triggerHaptic(10);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
|||||||
import { CountBadge } from '../../components/CountBadge';
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
import { ListSwitcher } from '../../components/ListSwitcher';
|
import { ListSwitcher } from '../../components/ListSwitcher';
|
||||||
import { LivePill } from '../../components/LivePill';
|
import { LivePill } from '../../components/LivePill';
|
||||||
|
import { isPhoneWidth } from '../../lib/breakpoints';
|
||||||
import { OriginBadge } from '../../components/OriginBadge';
|
import { OriginBadge } from '../../components/OriginBadge';
|
||||||
import { VoiceInputButton } from '../../components/VoiceInputButton';
|
import { VoiceInputButton } from '../../components/VoiceInputButton';
|
||||||
import { FormSheet } from '../../components/FormSheet';
|
import { FormSheet } from '../../components/FormSheet';
|
||||||
@@ -126,10 +127,10 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
// Phones: the add form lives in a sheet opened by "+" instead of taking room above the list
|
// Phones: the add form lives in a sheet opened by "+" instead of taking room above the list
|
||||||
const [isPhone, setIsPhone] = useState(() => typeof window !== 'undefined' && window.innerWidth < 640);
|
const [isPhone, setIsPhone] = useState(isPhoneWidth);
|
||||||
const [showAddSheet, setShowAddSheet] = useState(false);
|
const [showAddSheet, setShowAddSheet] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onResize = () => setIsPhone(window.innerWidth < 640);
|
const onResize = () => setIsPhone(isPhoneWidth());
|
||||||
window.addEventListener('resize', onResize);
|
window.addEventListener('resize', onResize);
|
||||||
return () => window.removeEventListener('resize', onResize);
|
return () => window.removeEventListener('resize', onResize);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -838,7 +839,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
}, [openItems]);
|
}, [openItems]);
|
||||||
|
|
||||||
const focusNewItemInput = () => {
|
const focusNewItemInput = () => {
|
||||||
if (window.innerWidth < 640) {
|
if (isPhoneWidth()) {
|
||||||
setShowAddSheet(true);
|
setShowAddSheet(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { X, Trash2, Loader2 } from 'lucide-react';
|
|||||||
import type { EmojiClickData } from 'emoji-picker-react';
|
import type { EmojiClickData } from 'emoji-picker-react';
|
||||||
import { Categories, EmojiStyle, Theme, SuggestionMode } from 'emoji-picker-react';
|
import { Categories, EmojiStyle, Theme, SuggestionMode } from 'emoji-picker-react';
|
||||||
import { useKeyboardInset } from '../../lib/useKeyboardInset';
|
import { useKeyboardInset } from '../../lib/useKeyboardInset';
|
||||||
|
import { isPhoneWidth } from '../../lib/breakpoints';
|
||||||
|
|
||||||
// Lazy-load emoji-picker-react so the main bundle size stays small
|
// Lazy-load emoji-picker-react so the main bundle size stays small
|
||||||
const EmojiPicker = React.lazy(() => import('emoji-picker-react'));
|
const EmojiPicker = React.lazy(() => import('emoji-picker-react'));
|
||||||
@@ -38,7 +39,7 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
|||||||
anchor,
|
anchor,
|
||||||
}) => {
|
}) => {
|
||||||
const { inset, visibleHeight } = useKeyboardInset();
|
const { inset, visibleHeight } = useKeyboardInset();
|
||||||
const isPhone = typeof window !== 'undefined' && window.innerWidth < 640;
|
const isPhone = isPhoneWidth();
|
||||||
// On phones the picker shrinks with the keyboard so search field and results stay visible
|
// On phones the picker shrinks with the keyboard so search field and results stay visible
|
||||||
const pickerHeight = isPhone ? Math.max(200, Math.min(350, visibleHeight - 110)) : 300;
|
const pickerHeight = isPhone ? Math.max(200, Math.min(350, visibleHeight - 110)) : 300;
|
||||||
const [isDark, setIsDark] = useState<boolean>(() =>
|
const [isDark, setIsDark] = useState<boolean>(() =>
|
||||||
@@ -105,14 +106,14 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
|||||||
|
|
||||||
const picker = (
|
const picker = (
|
||||||
<>
|
<>
|
||||||
{/* Mobile Backdrop (< 640px) */}
|
{/* Mobile Backdrop (below sm) */}
|
||||||
<div
|
<div
|
||||||
className={`fixed inset-0 bg-black/60 sm:hidden backdrop-blur-xs ${anchor ? 'z-[60]' : 'z-40'}`}
|
className={`fixed inset-0 bg-black/60 sm:hidden backdrop-blur-xs ${anchor ? 'z-[60]' : 'z-40'}`}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main Container: Mobile Bottom-Sheet (<640px) & Desktop Popover (>=640px) */}
|
{/* Main Container: Mobile Bottom-Sheet (below sm) & Desktop Popover (from sm) */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={`
|
className={`
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import { CountBadge } from '../../components/CountBadge';
|
|||||||
import { EmojiIconButton } from '../../components/EmojiIconButton';
|
import { EmojiIconButton } from '../../components/EmojiIconButton';
|
||||||
import { LazyMount } from '../../components/LazyMount';
|
import { LazyMount } from '../../components/LazyMount';
|
||||||
import { extractHashtags } from '../../lib/hashtags';
|
import { extractHashtags } from '../../lib/hashtags';
|
||||||
|
import { isPhoneWidth } from '../../lib/breakpoints';
|
||||||
|
|
||||||
interface WorkspaceViewProps {
|
interface WorkspaceViewProps {
|
||||||
currentUser?: User | null;
|
currentUser?: User | null;
|
||||||
@@ -624,7 +625,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
const [newNoteIcon, setNewNoteIcon] = useState('🗒️');
|
const [newNoteIcon, setNewNoteIcon] = useState('🗒️');
|
||||||
const openNewNote = useCallback(
|
const openNewNote = useCallback(
|
||||||
(folderPath = '') => {
|
(folderPath = '') => {
|
||||||
if (window.innerWidth >= 640) {
|
if (!isPhoneWidth()) {
|
||||||
void handleCreateDocument(folderPath);
|
void handleCreateDocument(folderPath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -563,7 +563,7 @@ body {
|
|||||||
--epr-dark-category-icon-active-color: var(--accent-primary);
|
--epr-dark-category-icon-active-color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 639px) {
|
@media (max-width: 767px) {
|
||||||
/* Prevent iOS auto-zoom on input focus */
|
/* Prevent iOS auto-zoom on input focus */
|
||||||
.mindsift-emoji-picker .EmojiPickerReact input.epr-search-input {
|
.mindsift-emoji-picker .EmojiPickerReact input.epr-search-input {
|
||||||
font-size: 1rem !important;
|
font-size: 1rem !important;
|
||||||
@@ -584,7 +584,7 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 768px) {
|
||||||
/* Desktop: compact like a context menu, in step with the app's small type (the sprite of the category
|
/* Desktop: compact like a context menu, in step with the app's small type (the sprite of the category
|
||||||
icons scales with its size variable) */
|
icons scales with its size variable) */
|
||||||
.mindsift-emoji-picker .EmojiPickerReact {
|
.mindsift-emoji-picker .EmojiPickerReact {
|
||||||
@@ -630,7 +630,7 @@ fieldset:disabled :is(button, input, select, textarea) {
|
|||||||
--outline-indent: 14px;
|
--outline-indent: 14px;
|
||||||
--outline-min-text: 14rem;
|
--outline-min-text: 14rem;
|
||||||
}
|
}
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 768px) {
|
||||||
:root {
|
:root {
|
||||||
--outline-indent: 22px;
|
--outline-indent: 22px;
|
||||||
--outline-min-text: 0px;
|
--outline-min-text: 0px;
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Width below which the app uses its phone layout; the same value as Tailwind's sm (tailwind.config.js)
|
||||||
|
// and the media queries in index.css.
|
||||||
|
export const PHONE_MAX_WIDTH = 768;
|
||||||
|
|
||||||
|
export const isPhoneWidth = (): boolean => typeof window !== 'undefined' && window.innerWidth < PHONE_MAX_WIDTH;
|
||||||
@@ -12,6 +12,16 @@ export default {
|
|||||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
|
// Phone layout below sm, desktop from sm (see AGENTS.md). sm is 768 px, not Tailwind's 640, so phones in
|
||||||
|
// landscape and small tablets (iPad mini upright) get the touch layout too; keep PHONE_MAX_WIDTH in
|
||||||
|
// src/lib/breakpoints.ts and the media queries in index.css in step.
|
||||||
|
screens: {
|
||||||
|
sm: '768px',
|
||||||
|
md: '768px',
|
||||||
|
lg: '1024px',
|
||||||
|
xl: '1280px',
|
||||||
|
'2xl': '1536px',
|
||||||
|
},
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
canvas: v('--bg-app'),
|
canvas: v('--bg-app'),
|
||||||
|
|||||||
Reference in new issue
Block a user