max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
12 files changed,
+540 insertions,
-291 deletions
| @@ -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 |
| @@ -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 } |
| @@ -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 | - | })(); |