diff --git a/.opencode/context/CONTEXT.md b/.opencode/context/CONTEXT.md index 34a094d..caa9382 100644 --- a/.opencode/context/CONTEXT.md +++ b/.opencode/context/CONTEXT.md @@ -4,7 +4,7 @@ - **Product**: Simpler — a self-hosted, **unified ADHD-friendly workspace**: tasks (kanban home), calendar (AI auto-scheduling), notes (markdown capture + Cleanify), and mail (IMAP → task), all organized around the same **Space** concept and served from one shell with one header. Guiding principle: friction-free, everything reachable in as few clicks as possible. - **Actors**: Single user (ADHD individual) authenticated by one shared `APP_PASSWORD`. External integrators: any OpenAI-compatible LLM API (Anthropic / Mistral / OpenAI / Infomaniak) for task parsing / note cleaning / email-to-task, any ICS-publishing calendar (Google, Outlook) for external events, any IMAP server for mailboxes. The **embedded Chainlit assistant** (first-party, mounted at `/assistant`) acts on the app through the `mcp_server/` sidecar with the `API_TOKEN` bearer credential (mutations audited `actor='agent'`), and code-runs snippets in the isolated **sandbox** (`sandbox/`). -- **Tech stack**: Python 3.11, one ASGI process (`asgi.py`): **FastAPI umbrella** mounting **Chainlit 2** at `/assistant` + Flask 3 at `/` (a2wsgi WSGI bridge). Flask side: app factory + per-domain blueprints + Flask-SQLAlchemy + SQLite (`instance/tasks.db`). Chat history in a second SQLite (`instance/chainlit.db`, owned by `chat/data_layer.py`). `cryptography` (Fernet) for mailbox secrets, Bootstrap 5 + FullCalendar.js + SortableJS + EasyMDE (vanilla JS, CDN, no build step), Docker Compose deploy on port 53000. `migrate_db.py` (repo root) migrates prod app DBs (additive DDL + idempotent data fixups). Active vision: `.opencode/plans/000_PrePRD_unified-adhd-workspace.md` (applied 2026-07). +- **Tech stack**: Python 3.11, one ASGI process (`asgi.py`): **FastAPI umbrella** mounting **Chainlit 2** at `/assistant` + Flask 3 at `/` (a2wsgi WSGI bridge). Flask side: app factory + per-domain blueprints + Flask-SQLAlchemy + SQLite (`instance/tasks.db`). Chat history in a second SQLite (`instance/chainlit.db`, owned by `chat/data_layer.py`). `cryptography` (Fernet) for mailbox secrets, Bootstrap 5 + FullCalendar.js + SortableJS + EasyMDE (vanilla JS, CDN, no build step) with a phone layer bolted on as progressive enhancement (`static/css/mobile.css` + `isMobile()` branches — desktop byte-identical, see the responsive topic), Docker Compose deploy on port 53000. `migrate_db.py` (repo root) migrates prod app DBs (additive DDL + idempotent data fixups). Active vision: `.opencode/plans/000_PrePRD_unified-adhd-workspace.md` (applied 2026-07). ## Architecture @@ -30,6 +30,7 @@ Module map: - `src/calendar_integration.py` — live ICS fetch. `src/mail_integration.py` — live IMAP fetch (transient DTOs). `src/crypto_utils.py` — Fernet from SECRET_KEY. - `src/config.py` — env + prompt loading, cached at startup: `SYSTEM_PROMPT` (`task_creation.md`), `NOTES_CLEANIFY_PROMPT`, `EMAIL_TO_TASK_PROMPT` (all under `src/prompts/*.md`). Exposes `assistant_url` (== `/assistant/` when `SIMPLER_ASSISTANT_MOUNTED`, else None) so the shell template-conditionalizes the Assistant tab. - `src/templates/index.html` — THE shell (all destinations + modals incl. shortcuts help). `login.html`. +- `src/static/css/` — `style.css` (the shell; desktop-first, plus legacy 768/900/992px tablet blocks) and `mobile.css` (the phone layer, everything scoped to `@media (max-width: 640px)`, loaded last). Mobile rules never leave that breakpoint — see `topics/responsive-mobile.md`. - `src/static/js/` — `app.js` (shell nav, shortcuts, board, calendar, overview), `notes.js` (`NotesView`), `mail.js` (`MailView`), `spaces.js` (`SpacesView` — space CRUD + AI context editor), `task_draft_modal.js` (shared draft confirm), `workspace.js` (`WorkspaceView` — the Assistant tab's right file drawer over `/api/workspace/*`, toggle `W`), `assistant_settings.js` (the Assistant settings modal over `/api/assistant/*`, shortcut `,`). - `chat/` — the embedded first-party Chainlit assistant (see `topics/chat-assistant.md`): `chainlit_app.py` (wiring only), `agent.py` (model⇄tools loop), `toolbox.py` + `mcp_tools.py` + `sandbox_tools.py` + `web_tools.py` + `files.py` + `skills.py` + `commands.py` (tool + UI feature surface), `providers.py` (OpenAI/Anthropic wire formats + reasoning-effort mapping), `modes.py` (chat-bar Mode pickers: model + reasoning, read per message), `assistant_settings.py` (pure path/JSON helpers for `instance/assistant/` — editable `system.md` override, `models.json`, `reasoning.json`, instance skills dir), `auth_bridge.py` (session-cookie identity), `data_layer.py` (SQLite-aware Chainlit history layer), `settings.py` / `workspace.py` (env + context building). - `instance/assistant/` — gitignored per-instance assistant customization (survives upgrades, files-on-disk only, no DB): `system.md` (base-prompt override, read per message), `models.json` + `reasoning.json` (chat-bar pickers), `skills/` (user-authored skills shadowing the bundled `chat/skills/` by name). Managed from the in-app Assistant settings panel. @@ -46,6 +47,7 @@ Module map: - @.opencode/context/topics/notes.md - @.opencode/context/topics/mail.md - @.opencode/context/topics/chat-assistant.md +- @.opencode/context/topics/responsive-mobile.md ## Domain glossary @@ -55,6 +57,7 @@ Module map: - **Space AI context**: `Space.context_markdown` is appended to every AI task prompt (`space_guidance_block()`) wrapped in explicit guide-not-source framing — it steers space choice/priority/deadline/wording but must never be copied into task fields or treated as part of the user's request. - **space_id vs space**: `Task.space_id` (FK) is canonical; the `Task.space` string column is legacy, unread by code, and backfilled into `space_id` by `migrate_db.py`. `to_dict()` echoes the name from the relation. - **Unified shell**: one page (`index.html`), one header, destinations switched client-side (`switchDestination`), deep links `#tasks/#notes/#mail/#calendar/#spaces/#assistant`, last destination remembered (unavailable remembered destination falls back to Tasks). +- **Phone layer**: the shell's mobile UX (≤640px) — bottom tab bar, swipeable scroll-snap board with a tap action sheet instead of drag, master-detail for the list views, single-select space dropdown instead of chips. Added as progressive enhancement over the same shell, **never a second frontend**: all of it lives behind one CSS breakpoint (`static/css/mobile.css`) and `isMobile()` guards, so the PC experience is byte-identical by construction (verified by pixel diff). The rationale, the rejected React/Vite rewrite, and the traps are in `topics/responsive-mobile.md` — read it before touching mobile layout or proposing a frontend rewrite. - **Assistant destination** (always on when `SIMPLER_ASSISTANT_MOUNTED`, i.e. served by `asgi.py`): 6th tab (`6`, `#assistant`) iframing the embedded Chainlit app same-origin at `/assistant` (chat history in `instance/chainlit.db`). The iframe loads lazily; a `#assistantSpaceChips` toolbar prints the active Space scope into prompts. No second login: identity is bridged from the Flask session cookie by signature (`chat/auth_bridge.py`); single-user app ⇒ one fixed Chainlit identifier `"owner"`. When `src/app.py` runs Flask alone, the Assistant tab/views/help row are template-conditionalized away via `assistant_url is None`. The agent acts on the app through the MCP sidecar (`SIMPLER_MCP_URL`), never the iframe. - **API_TOKEN bearer auth**: optional second auth path in `auth.py::login_required` (`Authorization: Bearer`, `hmac.compare_digest`); sets `g.actor='agent'` which `audit.record_change()` uses as default actor (explicit actors like the parse paths' `'ai'` win). Unset ⇒ byte-identical to session-only. - **Board / Overview**: the two Tasks subviews — kanban (primary) and grouped-by-space overview (secondary); toggle persisted. The Overview's "Show done" toggle (persisted) lists finished tasks by `completed_at` desc. diff --git a/.opencode/context/topics/responsive-mobile.md b/.opencode/context/topics/responsive-mobile.md new file mode 100644 index 0000000..e59b1d2 --- /dev/null +++ b/.opencode/context/topics/responsive-mobile.md @@ -0,0 +1,47 @@ +# Responsive / phone layer + +> Phone UX for the shell, added 2026-07 as **progressive enhancement over the existing vanilla-JS shell** — not a second frontend. CSS in `src/static/css/mobile.css` (everything inside `@media (max-width: 640px)`), JS in `isMobile()`-gated branches in `src/static/js/app.js` (+ small hooks in `notes.js`). The desktop UI is the project's crown jewel and stays **byte-identical**. + +## Why this shape (read before proposing a rewrite) + +The obvious framing — "the app is unusable on phone, so we need a proper frontend, maybe React/Vite, and a cleaner front/back split" — was explored and **rejected on evidence**: + +- **The front/back split already exists.** All domain logic sits behind the Flask `/api/*` blueprints, and `mcp_server/` already consumes that exact contract as an independent HTTP client over the `API_TOKEN` bearer. A new frontend would "unlock" a boundary we already have, so that argument for rewriting is void. +- **Chainlit was never the problem.** It only backs the Assistant tab, and its own React UI is already responsive. Keeping it costs nothing; only the shell chrome *around* the iframe needed to fit. "Escape Chainlit" is not a reason to rewrite. +- **The desktop-first part is the shell** (`index.html` + `app.js` + `style.css`): fixed-pixel sidebars, a 4-column board, hover-only affordances, modifier-click conventions, ~30 keyboard shortcuts. + +So the actual problem was narrow, and a second frontend would have cost a duplicated client (feature drift), a JS build pipeline against the project's deliberate **no-build-step** ethos, and re-implementations of the FullCalendar / EasyMDE / board / mail clients. Progressive enhancement was the cheaper and lower-risk answer. + +**A separate mobile app stays a documented escape hatch** if progressive enhancement ever hits a real UX ceiling — but re-argue it from evidence, not from the framing above. + +## The invariant (do not break this) + +*"Without tampering AT ALL with PC usability"* was the hard requirement, so it is enforced **structurally rather than by care**: + +- Every mobile rule lives inside the one phone breakpoint. The only top-level rules in `mobile.css` are `display:none` defaults for elements that **do not exist in the desktop design** (the card action button, the master-detail back buttons, the space dropdown) plus `.mobile-sheet-*` styling, which matches nothing on a desktop page. +- Every mobile JS path is behind `isMobile()`, added as a *new* branch. Desktop code paths were never restructured — on PC the drag init, calendar config and click delegation run the same code as before. +- `body.is-mobile` is toggled reactively (`matchMedia` change listener) for anything that needs the state in JS rather than CSS. + +Two failure modes to watch when extending: **a rule escaping the breakpoint**, and **structural selectors shifting** — `display:none` elements still count for `:first-child`/`:nth-child`, so injecting hidden nodes into desktop DOM can silently reflow it. Both were audited; keep auditing. + +## Decisions worth knowing + +- **The board swipes, it doesn't stack.** Full-width scroll-snap columns preserve the kanban mental model (four named columns you move between); a single stacked list would have dissolved it. +- **Drag is desktop-only on purpose.** Touch-dragging fights both the scroll-snap track and page scroll, so SortableJS is simply *not initialized* on phones. Status changes go through a per-card action sheet instead. This is why the mobile board needs no drag polish — the gesture is deliberately absent, not broken. +- **The action sheet is plain DOM, not a Bootstrap modal.** The shell's global Ctrl+Enter handler and several flows key off `.modal.show`; making the sheet a real modal would have entangled it with that plumbing for no benefit. +- **Modifier-click conventions have no touch equivalent**, so everything they express (done / freeze / advance / edit) is reachable from that sheet as visible buttons. Hover-revealed affordances are pinned visible on coarse pointers for the same reason. +- **Space filter is a dropdown on phone, single-select only.** With ~10 spaces the chip row ate most of the screen. Ctrl+click multi-space and Alt+click exclude stay desktop-only (KISS was the explicit ask), and picking from the dropdown clears exclusions. A filter built on desktop that the dropdown cannot express renders a disabled *"Multiple spaces"* entry — **the UI must not claim "All spaces" while the board is actually filtered**. One shared helper (`syncMobileSpaceSelect`) is called from each of the three chip renderers with an `onPick` mirroring that view's existing plain-click branch, so filter semantics are never duplicated. +- **Master-detail for Notes / Mail / Spaces**, because their fixed 260–380px sidebars have no phone equivalent. The detail overlay is reset on destination switch so you never land back in a stale editor. + +## Caveats + +- **`mobile.css` cascades on top of the older tablet breakpoints in `style.css` (768/900/992px), which still apply at phone widths.** This bit once: the 992px rule sets `.board { flex-wrap: wrap }`, which stacked the full-width columns vertically until the phone rule set `nowrap` explicitly. When a mobile layout misbehaves, look for an earlier breakpoint before debugging the new rule. +- The Assistant tab's *inside* is Chainlit's own responsive UI — we only fit the surrounding toolbar and hide the workspace drawer. Do not try to restyle the iframe's contents from here; `chat/public/simpler.css` is the seam. +- **PWA / installable / offline capture was deliberately deferred** (manifest + service worker + an offline capture queue). It is a strong fit for on-the-go ADHD capture and the natural next step, but it was scoped out, not forgotten. +- Keyboard shortcuts and the `#helpModal` table are desktop concerns and were left alone; if a mobile interaction ever changes a shortcut, the help table is still the single source of truth (see `CONTEXT.md`). + +## Verifying a change here + +The regression net for "PC is untouched" is a **pixel diff**: screenshot the desktop at several widths across every destination, before vs after, and require **0 differing pixels** (it has held for every change so far, including with 10 spaces seeded). Pair it with an interactive check of the desktop conventions the change is near — a static render can look identical while a handler is dead. On the mobile side, assert no horizontal overflow (`scrollWidth <= innerWidth`) per destination. + +Two environment traps when driving the app headless: the shell's CDN assets (Bootstrap / FullCalendar / SortableJS / EasyMDE / Font Awesome) may be blocked by an egress policy — install the same packages from npm and route the CDN URLs to those local copies, or the board renders empty and the failure looks like a code bug. And a `goto` that only changes the URL hash does **not** reload, so the destination never switches; reload after navigating. diff --git a/doc/PROJECT_DESCRIPTION.md b/doc/PROJECT_DESCRIPTION.md index 0e33705..96cae3d 100644 --- a/doc/PROJECT_DESCRIPTION.md +++ b/doc/PROJECT_DESCRIPTION.md @@ -36,6 +36,7 @@ An **Assistant destination** (6th tab, shortcut `6`) embeds a first-party [Chain - **Calendar**: FullCalendar.js - **Drag & Drop**: SortableJS (kanban columns + task list) - **Markdown editor**: EasyMDE (CodeMirror 5) +- **Phone layer**: a mobile stylesheet (`static/css/mobile.css`) scoped entirely to `@media (max-width: 640px)`, loaded after `style.css`, plus `isMobile()`-gated branches in `app.js`. The desktop experience is byte-identical by construction (every mobile rule is inside the phone breakpoint; every mobile JS branch is guarded). See **Mobile / phone layer** under Frontend Architecture. ### Infrastructure - **Docker Compose** deploy, port 53000, `./instance` volume for the SQLite file @@ -244,6 +245,20 @@ One page, one header: - **Mail** (`mail.js`, `MailView` module, lazy init): mailbox sidebar + add/edit modal, live inbox list, click a message → reader modal (full plain-text body, still read-only server-side), right-click (or Task button) → AI draft → shared confirm modal. - **`task_draft_modal.js`**: the shared "confirm this AI task draft" modal used by both promote-to-task and email-to-task (drafts are never silently persisted). +### Mobile / phone layer (`static/css/mobile.css` + `isMobile()` branches in `app.js`) + +A progressive-enhancement layer for phones (`≤640px`), added without touching the desktop UX. **Hard rule**: all mobile CSS lives inside `@media (max-width: 640px)` (the only top-level rules are `display:none` defaults for brand-new mobile-only elements), and all mobile JS is gated behind `isMobile()` (`matchMedia('(max-width: 640px)')`) — so the PC experience is byte-identical. `body.is-mobile` is toggled reactively for any JS that needs it. + +- **Navigation**: the header nav reflows into a fixed **bottom tab bar** (icons + tiny labels, `env(safe-area-inset-bottom)`-padded); quick-capture becomes a full-width row; the page scrolls (desktop is height-locked, phones are not). +- **Space filter**: a chip row for ~10 spaces would eat most of a phone screen, so every `.space-chips` container (board, Notes, Assistant) gets a companion ` for each .space-chips row (app.js syncMobileSpaceSelect). + Desktop keeps the chips and never shows this. */ +.space-select-mobile { display: none; } + +/* --- Card action sheet (built by app.js openCardActionSheet, phone-only). + These nodes are only ever created on phones, so styling them at top level + cannot affect the desktop DOM. --- */ +.mobile-sheet-backdrop { + position: fixed; + inset: 0; + z-index: 1080; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: flex-end; + animation: mobileSheetFade 0.15s ease; +} +@keyframes mobileSheetFade { from { opacity: 0; } to { opacity: 1; } } +.mobile-sheet { + width: 100%; + background: #fff; + border-radius: 18px 18px 0 0; + padding: 12px 12px calc(16px + env(safe-area-inset-bottom)); + box-shadow: 0 -6px 24px rgba(0, 0, 0, 0.18); + animation: mobileSheetUp 0.2s ease; +} +@keyframes mobileSheetUp { from { transform: translateY(24px); } to { transform: translateY(0); } } +.mobile-sheet-title { + font-weight: 700; + color: #2d3748; + padding: 6px 8px 12px; + text-align: center; + border-bottom: 1px solid #edf2f7; + margin-bottom: 10px; +} +.mobile-sheet-group { + display: flex; + gap: 8px; + margin-bottom: 10px; +} +.mobile-sheet-group .mobile-sheet-btn { flex: 1 1 0; } +.mobile-sheet-btn { + min-height: 46px; + border: 1px solid #e0e6ed; + border-radius: 12px; + background: #f7fafc; + color: #2d3748; + font-weight: 600; + font-size: 14px; + cursor: pointer; +} +.mobile-sheet-btn.current { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: #fff; + border-color: transparent; +} +.mobile-sheet-cancel { + width: 100%; + margin-top: 2px; + background: #fff; + color: #718096; +} + +/* ===================================================================== */ +@media (max-width: 640px) { + + /* ---- Scroll model: the desktop shell is height-locked + (body/overflow:hidden, app-main = 100vh-58px). On phones we let the + page scroll naturally and pin the nav to the bottom. ---- */ + html, body { overflow-y: auto; height: auto; -webkit-text-size-adjust: 100%; } + + .app-main { + height: auto; + min-height: calc(100dvh - 58px); + overflow: visible; + } + + /* ---- Header: brand + action icons on row 1, full-width capture on + row 2. The tab nav is lifted out to a fixed bottom bar (below). ---- */ + .app-header { + height: auto; + flex-wrap: wrap; + gap: 8px 10px; + padding: 8px 12px; + position: sticky; + top: 0; + z-index: 1020; + } + .app-brand { order: 1; font-size: 16px; } + .app-actions { order: 2; margin-left: auto; gap: 2px; } + .app-action-btn { min-width: 40px; min-height: 40px; } + .quick-capture { + order: 3; + flex: 1 1 100%; + min-width: 0; + max-width: none; + margin: 0; + } + .quick-capture input { min-height: 42px; } + + /* ---- Bottom tab bar: reuse the existing #appNav markup, reflowed. ---- */ + .app-nav { + position: fixed; + left: 0; right: 0; bottom: 0; + z-index: 1030; + display: flex; + gap: 0; + justify-content: space-around; + align-items: stretch; + background: #fff; + border-top: 2px solid #e0e6ed; + padding: 4px 2px calc(4px + env(safe-area-inset-bottom)); + box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.06); + } + .nav-tab { + flex: 1 1 0; + flex-direction: column; + gap: 2px; + padding: 4px 2px; + font-size: 10px; + font-weight: 600; + border-width: 0; + border-radius: 10px; + min-height: 46px; + justify-content: center; + } + .nav-tab i { font-size: 17px; } + .nav-tab kbd { display: none; } + .nav-tab.active { + background: transparent; + color: #667eea; + } + .nav-tab.active i { color: #667eea; } + /* keep the assistant pin badge legible on the small tab */ + .nav-tab .nav-pin-badge { + position: absolute; + transform: translate(14px, -10px); + } + .nav-tab { position: relative; } + + /* Leave room so no view hides behind the fixed bottom bar. */ + .app-view { + height: auto; + min-height: 0; + overflow: visible; + padding: 12px 12px calc(72px + env(safe-area-inset-bottom)); + } + + /* ---- Toolbars & chips: allow horizontal scroll instead of squashing ---- */ + .tasks-toolbar, + .notes-toolbar, + .assistant-toolbar { flex-wrap: wrap; gap: 8px; row-gap: 8px; } + /* Space filter: a chip row for ~10 spaces would eat most of the screen, so + phones get the companion dropdown instead (single-select — see + syncMobileSpaceSelect). The chips stay in the DOM, just hidden. */ + .space-chips { display: none; } + .space-select-mobile { + display: block; + flex: 1 1 auto; + min-width: 0; + max-width: 16rem; + min-height: 38px; + } + + /* Bigger default tap targets for the small controls. */ + .btn, .board-col-add, .view-tab { min-height: 38px; } + + /* =================== TASKS / kanban =================== */ + /* Full-width columns in a horizontal scroll-snap track; a sliver of the + next column peeks to advertise swipeability. Status changes are + tap-based (the "move" button opens an action sheet) since drag is + disabled on touch (initBoardSortables is skipped on mobile). */ + .board { + height: calc(100dvh - 210px); + min-height: 360px; + gap: 10px; + /* Override the max-width:992px rule (flex-wrap:wrap) which would stack + the full-width columns vertically. Phones swipe horizontally. */ + flex-wrap: nowrap; + overflow-x: auto; + overflow-y: hidden; + scroll-snap-type: x mandatory; + -webkit-overflow-scrolling: touch; + scroll-padding: 0 8px; + /* Full-height columns you swipe between (override desktop flex-start). */ + align-items: stretch; + } + .board-col { + flex: 0 0 86%; + min-width: 86%; + max-width: 86%; + height: 100%; + scroll-snap-align: center; + } + .board-col-cards { overflow-y: auto; } + + /* Mobile card "move / actions" button (revealed here only). */ + .board-card-move { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border: none; + background: rgba(255, 255, 255, 0.85); + color: #4a5568; + border-radius: 8px; + font-size: 15px; + cursor: pointer; + } + /* Hover-only affordances don't exist on touch — pin them visible. */ + .board-card-assist { color: #718096 !important; } + .note-row-assist { color: #718096 !important; } + /* Roomier cards + tap targets on the board. */ + .board-card { padding: 12px; } + .board-card-priority { min-width: 26px; min-height: 26px; } + + /* Overview: single column. */ + #overviewView .space-overview-grid, + #overviewView .overview-cards { grid-template-columns: 1fr !important; } + + /* =================== master / detail (Notes · Mail · Spaces) =========== + The list is the master (full-width, page scrolls). Opening an item + promotes the detail panel to a full-screen overlay with a Back button + (injected by app.js initMobileMasterDetail). ==================== */ + .notes-layout, + .mail-layout, + .spaces-layout { + display: block; + height: auto; + gap: 0; + } + .notes-list-panel, + .mail-sidebar, + .spaces-list-panel { + width: 100%; + max-height: none; + border-radius: 12px; + } + + /* Detail panels are out of the flow until their layout is opened. */ + .notes-editor-panel, + .mail-main, + .spaces-editor-panel { display: none; } + + .mobile-detail-open .notes-editor-panel, + .mobile-detail-open .mail-main, + .mobile-detail-open .spaces-editor-panel { + display: block; + position: fixed; + inset: 0; + z-index: 1050; + background: #f5f7fa; + overflow-y: auto; + padding: 12px 12px calc(20px + env(safe-area-inset-bottom)); + } + + /* The injected Back button, shown at the top of an open overlay. */ + .mobile-detail-open .mobile-back-btn { + display: inline-flex; + align-items: center; + gap: 6px; + margin-bottom: 12px; + padding: 8px 14px; + border: 1px solid #cbd5e0; + border-radius: 10px; + background: #fff; + color: #2d3748; + font-weight: 600; + } + + .notes-editor-panel #noteTitle { max-width: 100%; } + .EasyMDEContainer .editor-toolbar { flex-wrap: wrap; } + .CodeMirror { min-height: 300px; } + + /* =================== Calendar =================== */ + /* Sidebar stacks above a list-view calendar (initCalendar picks the + mobile config). The page scrolls; nothing is height-locked. */ + .calendar-layout { flex-direction: column; height: auto; gap: 12px; } + .calendar-sidebar { width: 100%; max-height: 40vh; } + .calendar-main { overflow: visible; } + .calendar-legend { font-size: 11px; } + .fc .fc-toolbar.fc-header-toolbar { flex-wrap: wrap; gap: 6px; } + .fc .fc-toolbar-title { font-size: 1.05rem; } + + /* =================== Assistant =================== */ + .app-view-assistant { height: calc(100dvh - 200px); min-height: 400px; } + .assistant-toolbar { padding: 8px 12px; } + /* The workspace file drawer is a desktop convenience; hide on phones. */ + #workspaceDrawer { display: none !important; } + + /* =================== Modals → full-screen sheets =================== */ + .modal-dialog { + margin: 0; + max-width: 100%; + min-height: 100%; + } + .modal-content { + min-height: 100dvh; + border: 0; + border-radius: 0; + } + .modal-body { overflow-y: auto; } +} + +/* Coarse-pointer safety net (tablets/phones that report a wide viewport but + no hover): keep the hover-only affordances reachable. Scoped to NEW-only + selectors + hover reveals so desktop mouse behaviour is untouched. */ +@media (hover: none) and (pointer: coarse) { + .board-card-assist, + .note-row-assist, + .note-row:hover .note-row-assist { color: #718096; } +} diff --git a/src/static/js/app.js b/src/static/js/app.js index 9d43f20..76cc759 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -34,6 +34,58 @@ let selectedTaskIds = new Set(); const TASK_STATUSES = ['todo', 'doing', 'blocked', 'done']; const DONE_COLUMN_LIMIT = 30; +// ===== Mobile layer (phone ≤640px) ===== +// Every branch guarded by isMobile() is additive: on desktop these paths are +// never taken, so the PC experience is byte-identical. The CSS half lives in +// static/css/mobile.css (scoped to @media (max-width: 640px)). +const MOBILE_MQ = window.matchMedia('(max-width: 640px)'); +function isMobile() { return MOBILE_MQ.matches; } + +function applyMobileBodyClass() { + document.body.classList.toggle('is-mobile', MOBILE_MQ.matches); +} + +// ===== Mobile: space filter as a dropdown ===== +// A chip row for ~10 spaces eats most of a phone screen, so every +// .space-chips container gets a companion