Skip to main content

max / goingson

Split the conflated width and capability axes ui-mode was one signal answering two questions. It was set at boot from a user-agent sniff, so an iPad was "mobile" and a desktop window dragged narrow was not, and every layout rule that meant "there is no room here" was scoped to a guess about the device. Width now comes from makeover-geometry's SizeClass. build.rs takes a direct dependency and emits the narrow table pass under SizeClass::Compact.media_condition(); the 226 hand-written .ui-mode-* selectors in styles.css move to (max-width: 599px) for the compact shell and (min-width: 600px) for the wide one. Capability stays where it already was, on (hover: none), (pointer: coarse), matching what the generated geometry.css keys touch density on; section 60's query widens to include pointer: coarse for that reason. .ui-mode-mobile no longer appears in styles.css at all. It survives only as the explicit user override that geometry.css layers last, and bootstrap-uimode.js no longer detects anything: with no ?ui= or localStorage choice, no class is added. js/viewport.js is deleted; its two consumers now ask the question each actually wanted. Four rules were relying on the mode prefix for specificity rather than scoping, which only showed once the prefix came off. The project-dashboard-grid and day-plan-sidebar responsive overrides moved to sit with their components; a saved-views-sidebar width and a day-plan-sidebar max-height turned out to have been dead already and are gone. The tablet block gains a lower bound, since that range was previously unreachable by a narrow window. Three follow-ups from the same work, sharing these files: build.rs now fails the build if a hand-written breakpoint in styles.css or any js file names a width that is not a SizeClass edge or a documented tuning width. The boundary lives in one place only if something checks that, and nothing did. The kanban gate becomes .no-card-drag, set from (any-pointer: fine) and read by both styles.css and task-board.js. It is a feature question, not a width or a density one: a laptop with a touchscreen also has a mouse and drags fine. task-board.js was gating on isTouchDevice, which gets that case wrong. events-calendar.js holds its MediaQueryList and re-renders on change instead of asking per call, so crossing the boundary with the week view open no longer leaves a single-day DOM laid out as a week. The calendar consumer is keyed on width, not capability as the task specified: seven day columns either fit or they do not, and keying on touch would hand a landscape tablet the single-day DOM while the stylesheet lays it out as a week. Not in scope, unchanged: the two navigation shells stay forked. Their rules move onto size classes without changing which shell appears.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-01 18:27 UTC
Signed with PGP, not checked
Commit: be55760307af5680422f4e1cbc2a18f3d9c34628
Parent: ba5229d
12 files changed, +540 insertions, -291 deletions
M CONTRIBUTING.md +21 -13
@@ -235,26 +235,34 @@
235 235 - **Never** edit `styles.min.css` directly; it's auto-generated via clean-css-cli
236 236 - The HTML loads `styles.min.css`
237 237
238 - ## UI Modes
238 + ## Width and capability
239 239
240 - GoingsOn has two UI modes: `desktop` and `mobile`. The mode is decided once at boot by an inline script in `index.html` (runs before the stylesheet loads, so no flash) and exposed two ways:
240 + There is no "UI mode". There are two independent questions, and every responsive rule answers exactly one of them. A tablet in landscape is wide and touch; a desktop window dragged narrow is compact and pointer. One signal cannot name four combinations.
241 241
242 - - **CSS:** `<html class="ui-mode-desktop">` or `<html class="ui-mode-mobile">`. Mobile-specific rules: `.ui-mode-mobile .foo { ... }`. Desktop-specific layout: `.ui-mode-desktop .foo { ... }`.
243 - - **JS:** `GoingsOn.viewport.isMobile()` / `isDesktop()` from `js/viewport.js`.
242 + **Width: how much room there is.** The boundaries are `makeover_geometry::SizeClass`, quoted from Material 3's window size classes: Compact under 600px, Medium 600 to 839, Expanded 840 and up. Only Compact and its complement carry weight today.
244 243
245 - Mode does **not** change at runtime. Desktop binaries stay desktop even when the window is narrowed; mobile binaries stay mobile. The mode is a property of the build, not the viewport size.
244 + ```css
245 + @media (max-width: 599px) { .foo { ... } } /* compact shell */
246 + @media (min-width: 600px) { .foo { ... } } /* wide shell */
247 + ```
246 248
247 - **Detection precedence** (`index.html` inline script):
248 - 1. `?ui=mobile|desktop` URL param, for dev / testing / bug repros.
249 - 2. `localStorage.goingson.uiMode`, the dev Settings toggle.
250 - 3. `navigator.userAgentData.mobile` (UA Client Hints) when available.
251 - 4. UA regex (`iPhone OS|iPad|Android`) with iPad-as-Mac fallback (`navigator.maxTouchPoints > 1` on a Mac-reporting platform).
249 + `src-tauri/build.rs` reads those numbers off `SizeClass` for the generated `tables.css`; the hand-written ones in `styles.css` are the same numbers typed out, so they change together. Other widths (1024px, 1400px) are ordinary tuning inside the wide shell, not a third shell.
252 250
253 - **Dev preview:** `?ui=mobile` URL param, or `GoingsOn.viewport.setOverride('mobile')` from the console.
251 + In JS, ask the same question the same way: `window.matchMedia('(max-width: 599px)').matches`, per call rather than cached, so a resized window is right at the next render.
254 252
255 - **Adding mobile rules:** prefix the selector with `.ui-mode-mobile`. Do **not** add new `@media (max-width: ...)` queries to switch UI modes; that path is gone. Intra-mode responsive queries (e.g. wide-vs-narrow desktop) are allowed, but their selectors must be `.ui-mode-desktop`-prefixed inside the query.
253 + **Capability: what is pointing at it.**
256 254
257 - **Input capability is a separate axis.** Use `@media (hover: none)` only for hover suppression. Use `GoingsOn.touch.isTouchDevice` only for input-model decisions (drag vs long-press, tap targets). Never use either for visibility or layout; that's what UI mode is for.
255 + ```css
256 + @media (hover: none), (pointer: coarse) { .foo { ... } }
257 + ```
258 +
259 + The same condition the generated `geometry.css` keys touch density on. Use it for hover suppression, touch-target sizing, and gestures that need a pointer to perform. `GoingsOn.touch.isTouchDevice` is the JS side. Never use capability for visibility or layout that is really about room.
260 +
261 + **`.ui-mode-mobile` is the explicit override and nothing else.** Set from `?ui=mobile` or `localStorage.goingson.uiMode`, and `geometry.css` layers it last so a deliberate choice beats detection. Nothing sniffs a user agent any more; with no override asked for, no class is added. Do not reach for the class to mean "small".
262 +
263 + **Dev preview:** `?ui=mobile` in the URL, or resize the window past 600px to move between shells.
264 +
265 + **Feature gates are neither axis.** A rule that hides something because the feature is broken in that context (the List / Board toggle, which needs kanban drag) gets its own condition named for what it means. Do not fold it into width or capability.
258 266
259 267 ## SyncKit Integration
260 268
M Cargo.lock +2 -1
@@ -1387,7 +1387,7 @@
1387 1387
1388 1388 [[package]]
1389 1389 name = "docengine"
1390 - version = "0.3.5"
1390 + version = "0.4.0"
1391 1391 dependencies = [
1392 1392 "ammonia",
1393 1393 "pulldown-cmark",
@@ -2250,6 +2250,7 @@
2250 2250 "mailparse",
2251 2251 "makeover",
2252 2252 "makeover-build",
2253 + "makeover-geometry",
2253 2254 "makeover-layout",
2254 2255 "makeover-webview",
2255 2256 "notify",
@@ -147,9 +147,10 @@
147 147 carry the look only, never the hiding.
148 148
149 149 A control that is always visible does not wear `.row-actions` — the project card's
150 - kebab is one, which is why its card is not a `.row`. In mobile UI mode and on touch
151 - there is no hover, so `.row-actions` is shown unconditionally; any such override must
152 - restore `pointer-events` as well as `opacity`.
150 + kebab is one, which is why its card is not a `.row`. Where there is no hover
151 + `.row-actions` is shown unconditionally; that is a capability question, so the
152 + override lives under `(hover: none), (pointer: coarse)` and not under a width, and it
153 + must restore `pointer-events` as well as `opacity`.
153 154
154 155 ### Row text parts: `.row-primary` / `.row-secondary` / `.row-meta`
155 156 Generated too, and they are only the three content colours (`--content`,
@@ -15,14 +15,16 @@
15 15 [build-dependencies]
16 16 tauri-build = { workspace = true }
17 17 # Materialises all three generated files: themes/, geometry.css, layout.css.
18 - # The build script needs nothing else; makeover and makeover-geometry reach it
19 - # through here. Path dep while unpublished.
20 18 makeover-build = "0.3.1"
21 19 # The table CSS is generated here too: the columns are this app's, so the
22 20 # shared helper cannot know them, but the tracks and the narrowing rules come
23 21 # from the description rather than from hand-written nth-child cuts.
24 22 makeover-webview = "0.9.0"
25 23 makeover-layout = "0.6.0"
24 + # Width. Direct rather than through makeover-webview, because the narrow table
25 + # pass keys off SizeClass::Compact and a boundary reached transitively is a
26 + # boundary nobody pinned.
27 + makeover-geometry = "0.4.0"
26 28
27 29 [dependencies]
28 30 goingson-core = { workspace = true }
M src-tauri/build.rs +209 -10
@@ -2,6 +2,7 @@
2 2 use std::fs;
3 3 use std::path::Path;
4 4
5 + use makeover_geometry::SizeClass;
5 6 use makeover_layout::{Column, Priority, Width};
6 7 use makeover_webview::Emit;
7 8 use makeover_webview::list::{Sizing, narrowing_css};
@@ -212,7 +213,7 @@
212 213 fallback: "",
213 214 };
214 215 // The narrow pass carries its own lengths: the columns that survive are not
215 - // the same size on a phone as on a desktop.
216 + // the same size in a compact window as in an expanded one.
216 217 let task_narrow = Sizing {
217 218 lengths: &[
218 219 ("description", "0"),
@@ -292,21 +293,23 @@
292 293 &opts,
293 294 ));
294 295
295 - // Scoped under the mode class the geometry half already keys touch
296 - // density on, so one signal decides both.
297 - css.push('\n');
298 - let narrow_selector = selector
299 - .split(", ")
300 - .map(|s| format!(".ui-mode-mobile {s}"))
301 - .collect::<Vec<_>>()
302 - .join(", ");
296 + // Width, and only width. A column comes out because there is no room
297 + // for it, which is a question about the viewport and not about what is
298 + // pointing at it: a desktop window dragged narrow drops the same
299 + // columns a phone does, and a tablet in landscape keeps them.
300 + let _ = write!(
301 + css,
302 + "\n@media {} {{\n",
303 + SizeClass::Compact.media_condition()
304 + );
303 305 css.push_str(&narrowing_css(
304 306 columns,
305 - &narrow_selector,
307 + selector,
306 308 narrow,
307 309 Priority::Secondary,
308 310 &opts,
309 311 ));
312 + css.push_str("}\n");
310 313 }
311 314
312 315 // The print pass keeps every column -- a printed table is read without a
@@ -328,6 +331,197 @@
328 331 css
329 332 }
330 333
334 + /// Widths that are tuning inside the wide shell, not a shell boundary.
335 + ///
336 + /// A shell boundary is a [`SizeClass`] edge and belongs to makeover-geometry.
337 + /// These two are something else: the point where the project dashboard drops
338 + /// from three columns to two, and the point where the content pane stops being
339 + /// width-capped. Nothing switches shells at either, so neither wants to move
340 + /// when a size class does, and neither should be derived from one.
341 + const TUNING_WIDTHS: &[u16] = &[1024, 1400];
342 +
343 + /// Every width a hand-written media query is allowed to name.
344 + ///
345 + /// Read out of [`SizeClass::media_condition`] rather than typed, which is the
346 + /// whole point: this is the one place the numbers come from, and a bump in
347 + /// makeover-geometry has to reach the stylesheet through here.
348 + fn allowed_widths() -> Vec<u16> {
349 + let mut widths: Vec<u16> = SizeClass::all()
350 + .iter()
351 + .flat_map(|c| media_widths(&c.media_condition()))
352 + .collect();
353 + widths.extend_from_slice(TUNING_WIDTHS);
354 + widths.sort_unstable();
355 + widths.dedup();
356 + widths
357 + }
358 +
359 + /// The pixel values in a media condition, in the order they appear.
360 + fn media_widths(condition: &str) -> Vec<u16> {
361 + let mut out = Vec::new();
362 + let mut rest = condition;
363 + while let Some(i) = rest.find("-width:") {
364 + rest = &rest[i + "-width:".len()..];
365 + let digits: String = rest
366 + .trim_start()
367 + .chars()
368 + .take_while(char::is_ascii_digit)
369 + .collect();
370 + if let Ok(px) = digits.parse() {
371 + out.push(px);
372 + }
373 + }
374 + out
375 + }
376 +
377 + /// Fail the build if a hand-written breakpoint has drifted from [`SizeClass`].
378 + ///
379 + /// The generated files cannot drift: they ask makeover-geometry for the
380 + /// number. The hand-written ones state it, and until 2026-08-01 they stated a
381 + /// different thing entirely -- a `.ui-mode-mobile` class off a user-agent
382 + /// sniff -- so the boundary living in one place is new and worth keeping.
383 + ///
384 + /// Without this, moving `SizeClass::Medium::min_px` regenerates tables.css and
385 + /// silently leaves 16 media queries and one `matchMedia` call behind, and what
386 + /// you get is not an error but a stylesheet that disagrees with itself at the
387 + /// old boundary. Cheaper to read a panic naming the line.
388 + ///
389 + /// Deliberately an assertion and not a substitution. Generating the queries
390 + /// would mean styles.css became a template, and it is worth something that you
391 + /// can still open it in a browser and have it work.
392 + fn check_breakpoints(frontend: &Path) {
393 + let allowed = allowed_widths();
394 + let mut stale: Vec<String> = Vec::new();
395 +
396 + let css_path = frontend.join("css/styles.css");
397 + let css = fs::read_to_string(&css_path).expect("read styles.css");
398 + // Comments first: a note about the 768px breakpoint that used to be here
399 + // is prose, not a rule, and should not fail a build.
400 + let css = strip_block_comments(&css);
401 + for (offset, condition) in media_conditions(&css) {
402 + for px in media_widths(condition) {
403 + if !allowed.contains(&px) {
404 + stale.push(format!(
405 + " css/styles.css:{} @media{condition} ({px}px)",
406 + line_of(&css, offset)
407 + ));
408 + }
409 + }
410 + }
411 +
412 + let js_dir = frontend.join("js");
413 + let mut js_files: Vec<_> = fs::read_dir(&js_dir)
414 + .expect("read js/")
415 + .filter_map(Result::ok)
416 + .map(|e| e.path())
417 + .filter(|p| p.extension().is_some_and(|x| x == "js"))
418 + .collect();
419 + js_files.sort();
420 + for path in &js_files {
421 + let src = fs::read_to_string(path).expect("read js file");
422 + // No declarations in JS, so any width condition is a media query.
423 + for (offset, px) in js_widths(&src) {
424 + if !allowed.contains(&px) {
425 + stale.push(format!(
426 + " js/{}:{} ({px}px)",
427 + path.file_name().unwrap().to_string_lossy(),
428 + line_of(&src, offset)
429 + ));
430 + }
431 + }
432 + }
433 +
434 + assert!(
435 + stale.is_empty(),
436 + "hand-written breakpoints disagree with makeover_geometry::SizeClass.\n\n\
437 + Allowed: {allowed:?}\n\
438 + ({:?} come from SizeClass; {TUNING_WIDTHS:?} are TUNING_WIDTHS in build.rs.)\n\n\
439 + Stale:\n{}\n\n\
440 + If a size class moved, update these to match. If one of these is a new\n\
441 + tuning width inside the wide shell rather than a shell boundary, add it\n\
442 + to TUNING_WIDTHS with a note saying what it tunes.",
443 + allowed
444 + .iter()
445 + .filter(|px| !TUNING_WIDTHS.contains(px))
446 + .collect::<Vec<_>>(),
447 + stale.join("\n")
448 + );
449 +
450 + println!("cargo:rerun-if-changed={}", css_path.display());
451 + for path in &js_files {
452 + println!("cargo:rerun-if-changed={}", path.display());
453 + }
454 + }
455 +
456 + /// `(byte offset of the `@media`, the condition text before the `{`)`.
457 + fn media_conditions(css: &str) -> Vec<(usize, &str)> {
458 + let mut out = Vec::new();
459 + let mut at = 0;
460 + while let Some(i) = css[at..].find("@media") {
461 + let start = at + i;
462 + let after = start + "@media".len();
463 + match css[after..].find('{') {
464 + Some(j) => {
465 + out.push((start, &css[after..after + j]));
466 + at = after + j;
467 + }
468 + None => break,
469 + }
470 + }
471 + out
472 + }
473 +
474 + /// `(byte offset, pixel value)` for every `(max-width: Npx)` in a JS source.
475 + ///
476 + /// The parentheses are the whole test, and they have to be: a media condition
477 + /// is always parenthesized and a CSS declaration never is, so `'max-width:
478 + /// 320px'` in an inline-style string is not a breakpoint and must not read as
479 + /// one. shared-updater.js builds exactly that, and the first version of this
480 + /// check failed the build on it.
481 + fn js_widths(src: &str) -> Vec<(usize, u16)> {
482 + let mut out = Vec::new();
483 + for pat in ["(max-width:", "(min-width:"] {
484 + let mut at = 0;
485 + while let Some(i) = src[at..].find(pat) {
486 + let start = at + i;
487 + let rest = src[start + pat.len()..].trim_start();
488 + let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
489 + if let Ok(px) = digits.parse()
490 + && rest[digits.len()..].starts_with("px)")
491 + {
492 + out.push((start, px));
493 + }
494 + at = start + pat.len();
495 + }
496 + }
497 + out
498 + }
499 +
500 + /// Replace every `/* ... */` with spaces, so byte offsets still line up.
501 + fn strip_block_comments(css: &str) -> String {
502 + let bytes = css.as_bytes();
503 + let mut out = String::with_capacity(css.len());
504 + let mut i = 0;
505 + while i < bytes.len() {
506 + if bytes[i..].starts_with(b"/*") {
507 + let end = css[i..].find("*/").map_or(bytes.len(), |j| i + j + 2);
508 + for c in css[i..end].chars() {
509 + out.push(if c == '\n' { '\n' } else { ' ' });
510 + }
511 + i = end;
512 + } else {
513 + let c = css[i..].chars().next().unwrap();
514 + out.push(c);
515 + i += c.len_utf8();
516 + }
517 + }
518 + out
519 + }
520 +
521 + fn line_of(src: &str, offset: usize) -> usize {
522 + src[..offset].matches('\n').count() + 1
523 + }
524 +
331 525 fn main() {
332 526 // All three generated files: themes/, geometry.css, layout.css. The
333 527 // geometry emitter moved out too once density selection was settled:
@@ -345,6 +539,11 @@
345 539 fs::write(frontend.join("css/tables.css"), table_css()).expect("write tables.css");
346 540 fs::write(frontend.join("tables.columns.json"), table_columns_json())
347 541 .expect("write tables.columns.json");
542 +
543 + // The generated files above cannot drift from SizeClass. The hand-written
544 + // ones can, so they are checked rather than trusted.
545 + check_breakpoints(&frontend);
546 +
348 547 println!("cargo:rerun-if-changed=build.rs");
349 548
350 549 tauri_build::build();
@@ -15,17 +15,18 @@
15 15 <link rel="icon" type="image/png" sizes="32x32" href="../icons/32x32.png">
16 16 <link rel="apple-touch-icon" href="../icons/128x128.png">
17 17
18 - <!-- UI mode detection. Runs BEFORE the stylesheet so the class is on
19 - <html> when CSS evaluates (no flash). Precedence:
18 + <!-- Explicit UI-mode override. Runs BEFORE the stylesheet so the class is
19 + on <html> when CSS evaluates (no flash). There is no detection left
20 + here: it only reads a choice the user made, in this order:
20 21 1. ?ui=mobile|desktop URL param (dev/testing, bug repros)
21 22 2. localStorage.goingson.uiMode (dev Settings toggle)
22 - 3. navigator.userAgentData.mobile (UA Client Hints, modern)
23 - 4. UA regex with iPad-as-Mac fallback (Safari iOS/iPadOS, legacy)
24 - Works in both browser dev and Tauri-mobile webviews: WKWebView (iOS)
25 - and Android WebView both expose iOS/Android in the UA string. iPad
26 - on Safari 13+ reports as Mac by default; the maxTouchPoints branch
27 - catches that. Production lockdown of overrides is deferred to
28 - phase 5 of ui_mode_separation_plan.md. -->
23 + With neither set, no class is added. What used to be branches 3 and 4
24 + -- UA Client Hints and a UA regex with an iPad-as-Mac fallback -- are
25 + gone: width is answered by media queries against makeover-geometry's
26 + size classes, and touch by (hover: none), (pointer: coarse). Both are
27 + facts the browser already knows, so nothing here has to guess, and an
28 + iPad gets touch density without being forced into the compact shell.
29 + See the TWO AXES section at the top of css/styles.css. -->
29 30 <script src="js/bootstrap-uimode.js"></script>
30 31
31 32 <!-- Generated from makeover-geometry by src-tauri/build.rs. First, so the
@@ -652,7 +653,6 @@
652 653 <script src="js/goingson.js"></script>
653 654 <script src="js/config.js"></script>
654 655 <script src="js/dispatch.js"></script>
655 - <script src="js/viewport.js"></script>
656 656
657 657 <!-- Core Layer -->
658 658 <script src="js/api.js"></script>
@@ -118,9 +118,9 @@
118 118 53. Import Wizard 56. Project Milestones
119 119 54. Plugin Manager (57 intentionally unused)
120 120
121 - BAND: MOBILE LAYER (see RESPONSIVE STRATEGY)
121 + BAND: COMPACT SHELL + CAPABILITY (see TWO AXES)
122 122 58. Mobile Navigation (Nav Dot, Dial, Bottom Sheets)
123 - 59. Mobile Responsive (768px overrides, late pass)
123 + 59. Compact Responsive (late pass)
124 124 60. Touch Device Hover Disable
125 125
126 126 BAND: FEATURE SCREENS (C)
@@ -178,27 +178,58 @@
178 178 Intents: .button--primary / --secondary / --danger
179 179 Shapes: .field--compact / --ghost
180 180
181 - UI MODE STRATEGY (do not casually merge)
181 + TWO AXES (do not casually merge)
182 182
183 - UI mode is set ONCE at boot by index.html's inline script and exposed
184 - as `<html class="ui-mode-desktop">` or `<html class="ui-mode-mobile">`.
185 - Viewport width does NOT switch UI mode; desktop binaries stay desktop
186 - even when the window is narrowed.
183 + There is no "UI mode". There are two independent questions, and a rule
184 + answers one of them. A tablet in landscape is wide and touch; a desktop
185 + window dragged narrow is compact and pointer. One signal cannot name
186 + four combinations, and trying to is what this section used to describe.
187 187
188 - §25 Mobile (base pass) | early `.ui-mode-mobile` rules; applies before
189 - feature CSS so feature rules can override.
190 - §59 Mobile (late pass) | late `.ui-mode-mobile` rules; runs AFTER
188 + WIDTH -- how much room there is. Boundaries are makeover-geometry's
189 + `SizeClass`, quoted from Material 3's window size classes:
190 +
191 + @media (max-width: 599px) Compact
192 + @media (min-width: 600px) and (max-width: 839px) Medium
193 + @media (min-width: 840px) Expanded
194 +
195 + Only Compact and its complement are load-bearing here, so most rules
196 + read `(max-width: 599px)` for the compact shell or `(min-width: 600px)`
197 + for the wide one. src-tauri/build.rs takes those numbers from
198 + `SizeClass` directly for the generated tables.css; the hand-written
199 + ones below are the same numbers typed out, so change them there and
200 + here together. Other widths (1024px, 1400px) are ordinary responsive
201 + tuning inside the wide shell, not a third shell.
202 +
203 + CAPABILITY -- what is pointing at it.
204 +
205 + @media (hover: none), (pointer: coarse)
206 +
207 + Same condition the generated geometry.css keys touch density on. Use
208 + it for hover suppression, touch-target sizing, and gestures that need
209 + a pointer to perform. Never for visibility or layout that is really
210 + about room.
211 +
212 + §25 Compact (base pass) | early `(max-width: 599px)` rules; applies
213 + before feature CSS so feature rules can
214 + override.
215 + §59 Compact (late pass) | late `(max-width: 599px)` rules; runs AFTER
191 216 feature CSS so it wins conflicts. Add new
192 - mobile overrides here unless you need them
217 + compact overrides here unless you need them
193 218 early.
194 - §60 Touch hover disable | `@media (hover: none)`. INPUT-CAPABILITY axis,
195 - not UI mode. Reserved for hover suppression
196 - only. Never put visibility or layout rules
197 - here; they belong under `.ui-mode-mobile`.
219 + §60 Touch capability | the capability axis. Not a shell.
198 220
199 - Intra-mode responsive: `@media (max-width: ...)` queries are allowed
200 - INSIDE a UI mode (e.g. `.ui-mode-desktop` rules at narrow desktop
201 - widths). Do not use a media query to switch BETWEEN UI modes.
221 + `.ui-mode-mobile` appears nowhere in this file, and should not. It still
222 + exists as the explicit user override -- `?ui=mobile` or
223 + `goingson.uiMode` in localStorage -- but the only thing that reads it is
224 + the generated geometry.css, which layers it last so a deliberate choice
225 + beats detection. Nothing sniffs a user agent any more. Do not reach for
226 + the class to mean "small".
227 +
228 + FEATURE GATES are neither axis, and there is one: `.no-card-drag`, set
229 + in js/bootstrap-uimode.js, hiding the List / Board toggle where kanban
230 + cards cannot be dragged. It is a class rather than a media query only
231 + because js/task-board.js needs the same answer. If you add another, name
232 + it for the feature and say next to it why neither axis fits.
202 233
203 234 PRINT STRATEGY
204 235 §48 Global print rules | early.
@@ -440,51 +471,56 @@
440 471 user-select: text;
441 472 }
442 473
443 - /* 6. Header (desktop UI only)
444 - The top app header is part of desktop layout. Mobile UI hides it
445 - wholesale; rules below are scoped so they cannot leak in.
474 + /* 6. Header (wide shell only) WIDTH
475 + The top app header is part of the wide shell. The compact shell hides it
476 + wholesale and uses the bottom tab bar instead, so these rules are scoped
477 + to a viewport that is not compact and cannot leak into it.
446 478
447 479 The header is the tab strip, not a band of its own. It sits on the page
448 480 ground in --surface-sunken, carries the line that the pane hangs from
449 481 (border-bottom), and shares the pane's width so the two read as one
450 482 object. §7 has the tab semantic; §8 has the pane. */
451 - .ui-mode-desktop .app-header {
452 - width: 100%;
453 - max-width: var(--width-container);
454 - margin: 0 auto;
455 - background: var(--surface-sunken);
456 - border-bottom: var(--border-width) solid var(--border);
457 - padding: var(--gap-peer) var(--gap-peer) 0;
458 - display: flex;
459 - align-items: flex-end;
460 - gap: var(--gap-section);
461 - }
483 + @media (min-width: 600px) {
484 + .app-header {
485 + width: 100%;
486 + max-width: var(--width-container);
487 + margin: 0 auto;
488 + background: var(--surface-sunken);
489 + border-bottom: var(--border-width) solid var(--border);
490 + padding: var(--gap-peer) var(--gap-peer) 0;
491 + display: flex;
492 + align-items: flex-end;
493 + gap: var(--gap-section);
494 + }
462 495
463 - .ui-mode-desktop .header-content {
464 - display: flex;
465 - align-items: center;
466 - gap: var(--gap-bound);
467 - }
496 + .header-content {
497 + display: flex;
498 + align-items: center;
499 + gap: var(--gap-bound);
500 + }
468 501
469 - /* Utility controls ride the strip. Lifted off the line by the same step the
470 - tabs stand on, so they do not read as sitting in the pane. */
471 - .ui-mode-desktop .header-actions {
472 - display: flex;
473 - align-items: center;
474 - gap: var(--gap-peer);
475 - padding-bottom: var(--gap-peer);
476 - /* The tabs own the left edge; everything else is pushed to the far end.
477 - Not space-between: .header-content is empty on desktop and would take
478 - the left slot, floating the tabs off the pane's edge. */
479 - margin-left: auto;
502 + /* Utility controls ride the strip. Lifted off the line by the same step
503 + the tabs stand on, so they do not read as sitting in the pane. */
504 + .header-actions {
505 + display: flex;
506 + align-items: center;
507 + gap: var(--gap-peer);
508 + padding-bottom: var(--gap-peer);
509 + /* The tabs own the left edge; everything else is pushed to the far
510 + end. Not space-between: .header-content is empty in the wide shell
511 + and would take the left slot, floating the tabs off the pane's
512 + edge. */
513 + margin-left: auto;
514 + }
480 515 }
481 516
482 517 .mobile-view-title {
483 518 display: none;
484 519 }
485 520
486 - /* 7. Tab Navigation (desktop UI only)
487 - The top tab strip is desktop-only; mobile UI uses the bottom tab bar.
521 + /* 7. Tab Navigation (wide shell only) WIDTH
522 + The top tab strip belongs to the wide shell; the compact shell uses the
523 + bottom tab bar.
488 524
489 525 Folder tabs, not toggles. A pressed toggle says "this control is on" and
490 526 says nothing about what is below it; a folder tab's selected state removes
@@ -492,58 +528,61 @@
492 528 is the whole semantic, and it is why the tab carries no bevel: a bevel
493 529 makes it an object sitting on the strip rather than the front edge of the
494 530 pane. Tabs run from the left edge, which is the pane's left edge. */
495 - .ui-mode-desktop .tab-navigation {
496 - display: flex;
497 - align-items: flex-end;
498 - gap: var(--gap-bound);
499 - }
531 + @media (min-width: 600px) {
532 + .tab-navigation {
533 + display: flex;
534 + align-items: flex-end;
535 + gap: var(--gap-bound);
536 + }
500 537
501 - /* Fills come from the generated layout.css: makeover-layout 0.3.0 describes
502 - both halves of a selector, so an unchosen tab is Depth::Sunken and the chosen
503 - one is Depth::Raised. Those are the same two tokens this file used to spell by
504 - hand, so nothing moves visually.
538 + /* Fills come from the generated layout.css: makeover-layout 0.3.0
539 + describes both halves of a selector, so an unchosen tab is
540 + Depth::Sunken and the chosen one is Depth::Raised. Those are the same
541 + two tokens this file used to spell by hand, so nothing moves visually.
505 542
506 - The border stays here, and so does the bevel suppression below. A folder tab
507 - needs its bottom edge open to merge with the pane, and a four-sided bevel
508 - cannot do that -- which is why these tabs were drawn with a real border in the
509 - first place. */
510 - .ui-mode-desktop .tab {
511 - display: flex;
512 - align-items: center;
513 - gap: var(--gap-bound);
514 - padding: var(--gap-peer) var(--gap-section);
515 - text-decoration: none;
516 - color: var(--content-secondary);
517 - border: var(--border-width) solid var(--border);
518 - border-bottom: 0;
519 - border-radius: var(--radius-sm) var(--radius-sm) 0 0;
520 - font-weight: 600;
521 - }
543 + The border stays here, and so does the bevel suppression below. A
544 + folder tab needs its bottom edge open to merge with the pane, and a
545 + four-sided bevel cannot do that -- which is why these tabs were drawn
546 + with a real border in the first place. */
547 + .tab {
548 + display: flex;
549 + align-items: center;
550 + gap: var(--gap-bound);
551 + padding: var(--gap-peer) var(--gap-section);
552 + text-decoration: none;
553 + color: var(--content-secondary);
554 + border: var(--border-width) solid var(--border);
555 + border-bottom: 0;
556 + border-radius: var(--radius-sm) var(--radius-sm) 0 0;
557 + font-weight: 600;
558 + }
522 559
523 - .ui-mode-desktop .tab:hover {
524 - color: var(--content);
525 - }
560 + .tab:hover {
561 + color: var(--content);
562 + }
526 563
527 - /* The selected tab is filled with the pane's own surface and overlaps the
528 - strip's bottom line, breaking it: tab and pane become one shape. The
529 - specimen writes the overlap as `top: 1px` on the unselected tabs; a
530 - negative bottom margin says the same thing with the direction explicit,
531 - and the matching padding keeps every tab's label on one baseline.
564 + /* The selected tab is filled with the pane's own surface and overlaps the
565 + strip's bottom line, breaking it: tab and pane become one shape. The
566 + specimen writes the overlap as `top: 1px` on the unselected tabs; a
567 + negative bottom margin says the same thing with the direction explicit,
568 + and the matching padding keeps every tab's label on one baseline.
532 569
533 - box-shadow: none is load-bearing, not tidying. The generated .tab.chosen
534 - carries Bevel::Raised, whose dark half lands on the bottom edge and would
535 - draw a line straight across the join this rule exists to break. */
536 - .ui-mode-desktop .tab.chosen {
537 - position: relative;
538 - color: var(--content);
539 - box-shadow: none;
540 - margin-bottom: calc(-1 * var(--border-width));
541 - padding-bottom: calc(var(--gap-peer) + var(--border-width));
542 - }
570 + box-shadow: none is load-bearing, not tidying. The generated
571 + .tab.chosen carries Bevel::Raised, whose dark half lands on the bottom
572 + edge and would draw a line straight across the join this rule exists to
573 + break. */
574 + .tab.chosen {
575 + position: relative;
576 + color: var(--content);
577 + box-shadow: none;
578 + margin-bottom: calc(-1 * var(--border-width));
579 + padding-bottom: calc(var(--gap-peer) + var(--border-width));
580 + }
543 581
544 - .ui-mode-desktop .tab-label {
545 - font-weight: 600;
546 - font-size: var(--font-size-base);
582 + .tab-label {
583 + font-weight: 600;
584 + font-size: var(--font-size-base);
585 + }
547 586 }
548 587
549 588 /* --- Tab Groups & Sub-Views (shared) --- */
@@ -551,47 +590,49 @@
551 590 display: none;
552 591 }
553 592
554 - /* --- Pill Sub-Navigation (desktop UI only, mobile uses slide menu) --- */
593 + /* --- Pill Sub-Navigation (wide shell only; compact uses the slide menu) --- */
555 594 /* An underline strip, not a second row of folder tabs. Two tab rows put two
556 595 competing "this contains what follows" claims on the screen and give back
557 596 the ambiguity the top row just resolved, so the sub-level says selected
558 597 with a rule under the label and nothing else. Classic Platinum did nest
559 - tabs; this is a call, not a constraint (decided 2026-07-28). */
560 - .ui-mode-desktop .pill-nav {
561 - display: flex;
562 - align-items: stretch;
563 - gap: var(--gap-section);
564 - padding: 0;
565 - margin-bottom: var(--gap-section);
566 - min-height: 2rem;
567 - border-bottom: var(--border-width) solid var(--border);
568 - }
598 + tabs; this is a call, not a constraint (decided 2026-07-28). WIDTH */
599 + @media (min-width: 600px) {
600 + .pill-nav {
601 + display: flex;
602 + align-items: stretch;
603 + gap: var(--gap-section);
604 + padding: 0;
605 + margin-bottom: var(--gap-section);
606 + min-height: 2rem;
607 + border-bottom: var(--border-width) solid var(--border);
608 + }
569 609
570 - .ui-mode-desktop .pill {
571 - padding: var(--gap-bound) var(--gap-peer);
572 - border: 0;
573 - /* Sits on the strip's own line, so it has to outweigh it when lit. */
574 - border-bottom: calc(var(--border-width) * 2) solid transparent;
575 - margin-bottom: calc(-1 * var(--border-width));
576 - border-radius: 0;
577 - background: none;
578 - box-shadow: none;
579 - color: var(--content-secondary);
580 - font-family: var(--font-sans);
581 - font-size: var(--font-size-sm);
582 - font-weight: 600;
583 - cursor: pointer;
584 - }
610 + .pill {
611 + padding: var(--gap-bound) var(--gap-peer);
612 + border: 0;
613 + /* Sits on the strip's own line, so it has to outweigh it when lit. */
614 + border-bottom: calc(var(--border-width) * 2) solid transparent;
615 + margin-bottom: calc(-1 * var(--border-width));
616 + border-radius: 0;
617 + background: none;
618 + box-shadow: none;
619 + color: var(--content-secondary);
620 + font-family: var(--font-sans);
621 + font-size: var(--font-size-sm);
622 + font-weight: 600;
623 + cursor: pointer;
624 + }
585 625
586 - .ui-mode-desktop .pill:hover {
587 - background: none;
588 - color: var(--content);
589 - }
626 + .pill:hover {
627 + background: none;
628 + color: var(--content);
629 + }
590 630
591 - .ui-mode-desktop .pill.active {
592 - background: none;
593 - color: var(--content);
594 - border-bottom-color: var(--action);
631 + .pill.active {
632 + background: none;
633 + color: var(--content);
634 + border-bottom-color: var(--action);
635 + }
595 636 }
596 637
597 638 /* 8. Main Content & Page Header */
@@ -605,11 +646,14 @@
605 646
606 647 /* The pane the tab strip belongs to. Same width as the strip (§6), so the
607 648 two share a left and a right edge; no top border, because the strip's
608 - border-bottom already is that line and drawing it twice would show. */
609 - .ui-mode-desktop .main-content {
610 - background: var(--surface-raised);
611 - border: var(--border-width) solid var(--border);
612 - border-top: 0;
649 + border-bottom already is that line and drawing it twice would show. Follows
650 + the strip: no strip in a compact window, so no pane either. WIDTH */
651 + @media (min-width: 600px) {
652 + .main-content {
653 + background: var(--surface-raised);
654 + border: var(--border-width) solid var(--border);
655 + border-top: 0;
656 + }
613 657 }
614 658
615 659 /* Page Header */
@@ -2396,7 +2440,7 @@
2396 2440 .bulk-modal-prompt--wide {
2397 2441 margin-bottom: var(--gap-section);
2398 2442 }
2399 - /* Marker class kept for .ui-mode-mobile padding override only. Base styling: .button.button--sm + .w-full.text-left. */
2443 + /* Marker class kept for the compact padding override only. Base styling: .button.button--sm + .w-full.text-left. */
2400 2444 .bulk-modal-scroll {
2401 2445 max-height: 300px;
2402 2446 overflow-y: auto;
@@ -2926,11 +2970,18 @@
2926 2970 font-weight: 600;
2927 2971 }
2928 2972
2929 - /* Hide the List / Board toggle in mobile UI. Kanban drag-drop has no touch
2930 - fallback (Phase 6 #1): showing the toggle would expose a broken feature.
2931 - Keyed off UI mode rather than `hover: none` so a desktop touch laptop
2932 - with a working mouse still gets the toggle. Re-enable when touch-drag lands. */
2933 - .ui-mode-mobile #task-view-toggle { display: none; }
2973 + /* FEATURE GATE: no drag, no board. Kanban drag-drop has no touch fallback
2974 + (Phase 6 #1), so offering the List / Board toggle would advertise a broken
2975 + feature. Neither axis answers this. Not width -- a phone rotated landscape
2976 + still cannot drag a card -- and not touch density, because a laptop with a
2977 + touchscreen has a mouse as well and drags fine.
2978 + The class is set in js/bootstrap-uimode.js from (any-pointer: fine), which
2979 + is "is there a precise pointer at all" and is the question that actually
2980 + decides whether a card can be picked up. It is a class rather than a media
2981 + query here because js/task-board.js has to read the same answer, and a
2982 + class is the only form both can see. Delete all three together when
2983 + touch-drag lands. */
2984 + .no-card-drag #task-view-toggle { display: none; }
2934 2985
2935 2986 /* Phase 7 Tier 3 #11, swipe peek-labels.
2936 2987 Injected into a row on touch start, removed on touch end. Show the action
@@ -3306,131 +3357,113 @@
3306 3357 }
3307 3358 .form-alt-path .button--link { padding: 0; font-size: inherit; }
3308 3359
3309 - /* 24. Responsive - Large Screens & Tablet */
3360 + /* 24. Responsive - Large Screens & Tablet WIDTH
3310 3361
3311 - /* Wide desktop, intra-desktop responsive (only desktop UI gets these). */
3362 + Both blocks below used to carry a `.ui-mode-desktop` prefix, which was
3363 + doing two jobs: saying "wide shell only", and quietly outweighing the
3364 + unprefixed component rules further down the file. The first job is now the
3365 + media query's, and rules that were relying on the second have moved to sit
3366 + with the component they override (see §35 and §38). */
3367 +
3368 + /* Wide desktop. Nothing narrower than 1400px can be compact, so the query is
3369 + the whole condition. */
3312 3370 @media (min-width: 1400px) {
3313 - .ui-mode-desktop .main-content {
3371 + .main-content {
3314 3372 max-width: none;
3315 3373 }
3316 3374
3317 - .ui-mode-desktop .cards-grid {
3375 + .cards-grid {
3318 3376 grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
3319 3377 }
3320 3378
3321 - .ui-mode-desktop .project-dashboard-grid {
3322 - gap: var(--gap-pane);
3323 - }
3324 -
3325 - .ui-mode-desktop .day-plan-sidebar {
3326 - width: 320px;
3327 - }
3328 -
3329 - .ui-mode-desktop .modal-container {
3379 + .modal-container {
3330 3380 max-width: 640px;
3331 3381 }
3332 3382 }
3333 3383
3334 - /* Tablet, intra-desktop responsive (desktop UI on narrow desktops/iPads).
3335 - Scoped to .ui-mode-desktop so it never affects mobile UI. */
3336 - @media (max-width: 1024px) {
3337 - .ui-mode-desktop .saved-views-sidebar {
3338 - width: 180px;
3339 - }
3384 + /* Tablet and narrow desktop. Bounded below at the compact boundary: this
3385 + range used to be unreachable by a narrow window because the shell was
3386 + picked from the user agent, and now a window dragged under 600px gets the
3387 + compact treatment in §25 instead of this. */
3388 + @media (min-width: 600px) and (max-width: 1024px) {
3389 + /* No .saved-views-sidebar narrowing here. It set 180px and never took
3390 + effect: §46 sets 200px on the same specificity further down the file
3391 + and won. Deleted rather than repaired, so the sidebar is one width in
3392 + the wide shell and the stylesheet says so once. */
3340 3393
3341 - .ui-mode-desktop .day-plan-sidebar {
3342 - width: 240px;
3343 - }
3344 -
3345 - .ui-mode-desktop .project-dashboard-grid {
3346 - grid-template-columns: 1fr 1fr;
3347 - gap: var(--gap-group);
3348 - }
3349 -
3350 - .ui-mode-desktop .project-dashboard-grid .dashboard-column:last-child {
3351 - grid-column: span 2;
3352 - }
3353 -
3354 - .ui-mode-desktop .filter-bar {
3394 + .filter-bar {
3355 3395 flex-wrap: wrap;
3356 3396 }
3357 3397
3358 - .ui-mode-desktop .filter-actions {
3398 + .filter-actions {
3359 3399 width: 100%;
3360 3400 justify-content: flex-end;
3361 3401 margin-top: var(--gap-peer);
3362 3402 }
3363 3403 }
3364 3404
3365 - /* 25. Responsive - Mobile (768px) */
3366 - .ui-mode-mobile .tab-navigation {
3367 - flex-wrap: wrap;
3368 - }
3405 + /* 25. Responsive - Compact (base pass) WIDTH
Lines truncated
@@ -1,6 +1,29 @@
1 - /* UI-mode bootstrap. Runs before the stylesheet so the ui-mode class is on
2 - <html> when CSS evaluates. Externalized from an inline <script> so the CSP
3 - script-src can drop 'unsafe-inline'. */
1 + /* Boot classes on <html>. Runs before the stylesheet so both are in place when
2 + CSS evaluates. Externalized from an inline <script> so the CSP script-src
3 + can drop 'unsafe-inline'.
4 +
5 + Two classes, and neither is a guess.
6 +
7 + .ui-mode-mobile / -desktop is the EXPLICIT OVERRIDE and nothing else:
8 + ?ui=mobile for a bug repro, or the dev toggle in Settings. This used to
9 + sniff the user agent and always set one of the two; it no longer detects
10 + anything, because width is answered by media queries against
11 + makeover-geometry's size classes and touch by (hover: none),
12 + (pointer: coarse), and CSS does both without asking a string what device it
13 + is. geometry.css layers the class last so a deliberate choice beats
14 + detection. With no override asked for, no class is added, which is the
15 + point: an iPad gets touch density without being forced into the compact
16 + shell.
17 +
18 + .no-card-drag is a FEATURE GATE, named for the feature rather than for the
19 + device. Kanban cards are dragged with a pointer and there is no touch
20 + fallback yet, so where no precise pointer exists at all the board is not
21 + offered. Not the touch-density question: a laptop with a touchscreen also
22 + has a mouse, drags fine, and keeps the board. Not a width either. It has to
23 + be set here rather than written as a media query because both the
24 + stylesheet and js/task-board.js need the same answer, and a class is the
25 + only form both can read. Delete it, its rule in styles.css, and the guard
26 + in setViewMode together when touch-drag lands. */
4 27 (function () {
5 28 var mode;
6 29 try {
@@ -13,18 +36,11 @@
13 36 if (ls === 'mobile' || ls === 'desktop') mode = ls;
14 37 } catch (e) {}
15 38 }
16 - if (!mode) {
17 - var uad = navigator.userAgentData;
18 - if (uad && typeof uad.mobile === 'boolean') {
19 - mode = uad.mobile ? 'mobile' : 'desktop';
39 + if (mode) document.documentElement.classList.add('ui-mode-' + mode);
40 +
41 + try {
42 + if (!window.matchMedia('(any-pointer: fine)').matches) {
43 + document.documentElement.classList.add('no-card-drag');
20 44 }
21 - }
22 - if (!mode) {
23 - var ua = navigator.userAgent;
24 - var isMobileUA = /iPhone OS|iPad|Android/.test(ua);
25 - var isIPadAsMac = /Mac/.test(navigator.platform || '') && navigator.maxTouchPoints > 1;
26 - mode = (isMobileUA || isIPadAsMac) ? 'mobile' : 'desktop';
27 - }
28 - document.documentElement.classList.add('ui-mode-' + mode);
29 - window.__GO_UI_MODE__ = mode;
45 + } catch (e) {}
30 46 })();
@@ -36,8 +36,11 @@
36 36 function onPaintStart(event, slotIndex, slotTime) {
37 37 if (event.button !== 0) return;
38 38 if (event.target.closest('.timeline-item')) return;
39 - // Mobile UI doesn't expose drag-paint, tap-to-add is the touch path.
40 - if (GoingsOn.viewport?.isMobile()) return;
39 + // Capability, not width: drag-paint is a pointer gesture, and where
40 + // there is no pointer tap-to-add is the path. The same condition
41 + // hides the hint and the grab cursor in styles.css §60, so the
42 + // affordance and the behaviour cannot disagree.
43 + if (window.matchMedia('(hover: none), (pointer: coarse)').matches) return;
41 44
42 45 event.preventDefault();
43 46
@@ -17,13 +17,35 @@
17 17 let weekEvents = [];
18 18 let weekSwipeCleanup = null;
19 19
20 - function isMobileView() {
21 - // Route through the central UI-mode helper. The previous 600px
22 - // threshold was arbitrary and disagreed with the 768px breakpoint
23 - // used elsewhere; mode-based switching is the canonical signal now.
24 - return !!GoingsOn.viewport?.isMobile();
20 + // Width, and the same width the stylesheet uses: seven day columns either
21 + // fit or they do not, and that is a question about the window rather than
22 + // about the pointer. A tablet in landscape shows the week grid; a desktop
23 + // window dragged narrow shows one day.
24 + //
25 + // 599px is makeover-geometry's SizeClass::Compact upper bound, which is
26 + // where .cal-mobile-day is styled. The two have to agree, or this renders
27 + // a single-day DOM the stylesheet is laying out as a week; src-tauri's
28 + // build.rs fails the build if this string and SizeClass part ways.
29 + //
30 + // Held and watched rather than asked per call. The week view is two
31 + // different DOM trees, so crossing the boundary with one already on screen
32 + // leaves markup the stylesheet is not laying out; nothing else re-renders
33 + // on resize, and until this listener existed you got that until the next
34 + // navigation.
35 + const compact = window.matchMedia('(max-width: 599px)');
36 +
37 + function isCompactView() {
38 + return compact.matches;
25 39 }
26 40
41 + compact.addEventListener('change', () => {
42 + // Only if the week view is the one on screen. Rebuilding it while the
43 + // month grid is up would drop the swipe handlers on a hidden node and
44 + // fight whatever the user is actually looking at.
45 + const container = document.getElementById('week-calendar-grid');
46 + if (container && container.offsetParent !== null) loadWeek();
47 + });
48 +
27 49 // Date Helpers
28 50
29 51 function getMonday(date) {
@@ -250,7 +272,7 @@
250 272 renderWeekGrid(monday);
251 273 const label = document.getElementById('week-calendar-label');
252 274 if (label) {
253 - label.textContent = isMobileView()
275 + label.textContent = isCompactView()
254 276 ? currentWeekDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
255 277 : formatWeekLabel(monday);
256 278 }
@@ -259,7 +281,7 @@
259 281 function renderWeekGrid(monday) {
260 282 const container = document.getElementById('week-calendar-grid');
261 283 if (!container) return;
262 - if (isMobileView()) {
284 + if (isCompactView()) {
263 285 renderMobileDay(container);
264 286 return;
265 287 }
@@ -361,12 +383,12 @@
361 383 }
362 384
363 385 function prevWeek() {
364 - const step = isMobileView() ? 1 : 7;
386 + const step = isCompactView() ? 1 : 7;
365 387 currentWeekDate.setDate(currentWeekDate.getDate() - step);
366 388 loadWeek();
367 389 }
368 390 function nextWeek() {
369 - const step = isMobileView() ? 1 : 7;
391 + const step = isCompactView() ? 1 : 7;
370 392 currentWeekDate.setDate(currentWeekDate.getDate() + step);
371 393 loadWeek();
372 394 }
@@ -25,8 +25,12 @@
25 25 */
26 26 function setViewMode(mode) {
27 27 // Phase 7 Tier 3 #10, Kanban has no touch drag-drop fallback yet
28 - // (Phase 6 #1). Force list mode on touch devices regardless of input.
29 - if (mode === 'board' && GoingsOn.touch?.isTouchDevice) {
28 + // (Phase 6 #1). The same gate that hides the toggle in styles.css, so
29 + // a board reached by any other route (a saved view, a restored
30 + // preference) lands on list rather than on a board that cannot be
31 + // used. Not isTouchDevice: a laptop with a touchscreen reports true
32 + // there and drags perfectly well with its mouse.
33 + if (mode === 'board' && document.documentElement.classList.contains('no-card-drag')) {
30 34 mode = 'list';
31 35 }
32 36 viewMode = mode;
@@ -1,44 +1,0 @@
1 - /**
2 - * GoingsOn - Viewport / UI Mode Module
3 - *
4 - * Single source of truth for which UI mode the app is rendering.
5 - * Mode is set ONCE at boot by the inline detection script in index.html;
6 - * never changes at runtime. Code that wants to make layout decisions
7 - * (which DOM to render, which behavior path to take) should consult this
8 - * module instead of measuring window.innerWidth or sniffing user agents.
9 - *
10 - * Input-capability decisions (hover suppression, drag vs long-press) are
11 - * SEPARATE and live in GoingsOn.touch, do not collapse them into here.
12 - *
13 - * Dev override: set ?ui=mobile or ?ui=desktop in the URL, or run
14 - * localStorage.setItem('goingson.uiMode', 'mobile')
15 - * and reload. Production builds will eventually ignore these (phase 5).
16 - */
17 -
18 - (function () {
19 - 'use strict';
20 -
21 - var mode = window.__GO_UI_MODE__ === 'mobile' ? 'mobile' : 'desktop';
22 -
23 - GoingsOn.viewport = {
24 - mode: mode,
25 - isMobile: function () { return mode === 'mobile'; },
26 - isDesktop: function () { return mode === 'desktop'; },
27 -
28 - /**
29 - * Dev helper, set the UI mode override and reload. Available from
30 - * the JS console for testing.
31 - * @param {'mobile'|'desktop'|null} m - null clears the override
32 - */
33 - setOverride: function (m) {
34 - if (m === null) {
35 - GoingsOn.config.remove('goingson.uiMode');
36 - } else if (m === 'mobile' || m === 'desktop') {
37 - GoingsOn.config.set('goingson.uiMode', m);
38 - } else {
39 - return;
40 - }
41 - location.reload();
42 - },
43 - };
44 - })();