Skip to content

Mobile Friendly Projects

Ed Mozley edited this page Oct 7, 2026 · 5 revisions

Mobile-Friendly: Projects

Scope: all four pages - the portfolio, a project's page (Overview, Plan, People, Scope, RACI, RAID, Gates, Connections, History, and its six dialogs), Projects -> Settings, and the guide. Layers: mobile.css LAYERS 44a-44q, and mobile.js LAYERS 44a (the portfolio's filter sheet), 44b (a project's sticky footer), 44c (the phase picker), 44d (the scope priority picker) and 44e (the RACI cards' member names). Parent: Mobile-Friendly Β· Techniques: Β§1, Β§3, Β§4, Β§14, Β§20, Β§22, Β§26, Β§28, Β§30, Β§31, Β§32, Β§10, Β§11, Β§21


Where it started

Projects (3.2.0) was built after the rollout, so it arrived with the pattern's invisible parts right: mobile.css linked after projects.css (Β§9), mobile.js last, and data-mobile-module / data-mobile-page on all three pages. Nothing spilled off the document on any page - docScrollW was 360 everywhere.

Two of the three pages were still unusable. Measured at 360x740 through an explicit-width iframe (Β§8, Β§30), then looked at:

Page Found Answer
Portfolio A 240px flex-shrink: 0 sidebar left .prj-main 120px wide, holding a 300px card grid and four figures in 23px columns Sidebar -> a Β§4 sheet behind a Filter bar that shows the current view; one card per row
A project The banner's icon, text and 92px health ring in one flex row left the text 10px wide: the name one word per line, the goal one word per line, a banner 755px tall Icon and ring share the first line, the text takes the full width below
Lane heads the same shape: the phase name 41px wide Kind chip and name on line one, dates / status / Start-Finish / edit on a line of their own
Task rows: seven things in one row, the title cut to "Install switches a..." The title wraps on line one with the picker and the remove cross; who and when on line two
Moving a task between phases is a drag A phase picker on each row (44c, below)
"All projects" a 17px text link; Edit and Delete at the foot of the banner A sticky footer, Domains 41i's shape
Four tabs wider than the screen One row that swipes (41g's idiom)
The remove and unlink crosses only appear on :hover Revealed, 36px (Β§26)
πŸ”΄ Connections: every panel cut off at the right minmax(0, 1fr) - see below
Dialogs Method cards three 100px columns, field grid two-up Both one column; LAYER 3 already made them sheets
Guide - Already answered by the shared guide layer
Every page Every field 13px 16px module-wide (Β§3)

The scrolling shells (44a)

Both pages are height: calc(100vh - 62px) shells - .prj-layout on the portfolio, .prj-page on a project. On a phone the header is not 62px and 100vh runs under Safari's address bar. LAYER 2 already makes <body> a 100dvh flex column, so each shell takes the remainder - flex: 1 1 auto; height: auto; min-height: 0 - and the min-height: 0 is the part that matters (Β§28). Both reserve their footer's measured height at the bottom, so the last card is never behind it.

The portfolio: the sidebar as a sheet (44c CSS, 44a JS)

The Domains register's answer, under this module's names. mobile.js moves the real .prj-sidebar into a full-screen sheet opened from a bottom Filter bar, and moves it home when the viewport leaves mobile - so every listener projects-portfolio.js attached keeps working, and desktop never builds the sheet or the bar.

The bar carries the current view ("Filter Β· Live projects"), harvested from the active view's own label, so no string was invented. The sheet closes on a view (an arrival) and stays open while you type in the search box.

The view listener is in the capture phase, as Domains learned the hard way. This page only toggles classes on its views today, so a bubbling listener would work - but capture costs nothing and survives the day the renderer starts redrawing the list.

The figures were already two-up (projects.css's own 1100px block) and got the house density; the card grid became one minmax(0, 1fr) column, because minmax(300px, 1fr) fits one 300px track in 336px and a grid item's min-width: auto would still have held it (Β§14). The cards' lift-on-hover is switched off, since it sticks after a tap.

A project's page

The banner (44d)

Icon (52px) and ring share the first line - the ring at the right with its caption beside it (flex-direction: row-reverse), so "On track / Worked out from the plan" reads into the number - and .prj-banner-text takes flex: 1 1 100% below. The ring keeps its 92px: it is set inline, and the only way to shrink it would have been !important against the renderer's own style, for no gain. The banner went from 755px to 273px.

The actions in a sticky footer (44h CSS, 44b JS)

Exactly the Domains view's 41i: the real back link, #pvEdit and #pvDelete move into a footer (each is bound by id in projects-view.js, so a copy would do nothing), Back becomes a chevron square, each one's own label becomes its aria-label, the height is measured into --prj-vbar-h, and above 768px every node goes back to its exact place with its exact markup.

Two differences, both deliberate:

  • Edit and Delete keep their words. Domains has four actions and went icons-only; two actions leave room, so each is icon + its own single word.
  • Template (3.2.0, save as a template) joined later as an icon square like Back, between Edit and Delete. Its word stays in aria-label and the span is hidden; it keeps its [hidden] for anyone without the Templates permission (#pvTemplate.prj-vb-btn[hidden] { display: none } - the footer is outside #prjPage, so the module's own [hidden] rule does not reach it). mobile.css v184, mobile.js v78.
  • The banner's buttons are drawn for a dark gradient (white text on translucent white), so in a white bar they would be invisible. In the footer Edit takes the module's gradient and Delete a red outline, set apart on the right.

Until the project has loaded - or when it is not found - the banner is still [hidden], so the footer hides Edit and Delete with :has(#prjBanner[hidden]): there is nothing to edit.

⭐ Moving a task to another phase without a drag (44c)

On desktop a task is dragged between lanes. Β§22 is clear that this kind of drag - HTML5 draggable="true" - can fire on iOS from a long press, so "drag does not work on a phone" is not the reason. The reasons are this page's:

  • the grip only appears on :hover (Β§26);
  • most of the row is the task's link, and a long press on a link opens the link menu;
  • a 360px screen shows about one lane at a time, so the lane you want is usually off-screen - a drag would have to scroll the page mid-gesture.

So each row gets a small picker button, and it opens a bottom sheet listing the plan's own lanes, with the task's current lane ticked and not choosable.

πŸ”‘ Not one line of projects-view.js changed. Choosing a lane dispatches the events the page already listens for - a dragstart on the row and a drop on the lane - so the page's own moveTask() makes the API call and redraws the plan. That is the Network Mapper move (Β§1 applied to a gesture). The events carry a stub dataTransfer defined on a plain Event, so no browser needs the DataTransfer constructor (iOS before 14.1 has none). When the plan redraws with the task in its new lane, the page's toast says "Moved" - an existing string.

Zero new strings. The sheet's title is the task's own title, the options are harvested from each lane's kind chip and heading, and the button's accessible name is the plan's own word for a lane ("Phase", "Stage" or "Sprint", from the first kind chip). Rows are rebuilt on every change, so the plan is watched with a MutationObserver; nothing is injected above 768px, and the buttons are removed when the viewport leaves mobile. A plan with no phases gets no picker - there is nowhere to move to.

The lane hint "Drag a task onto a phase to file it there" is hidden on a phone, because it describes the desktop gesture.

The first version was a full-screen sheet, and a screenshot showed four short choices at the top of an empty screen. It is now a bottom sheet over the dimmed plan, and a tap on the dimmed part closes it.

Task rows

The title is the row's first line, wrapping instead of being cut, with the picker and the remove cross beside it; who and when (plus the subtask count and the priority, when there are any) start the second line. That needs two of the Domains feed's tricks (41e): a zero-height full-width flex item placed by order as the line break, and the status dot absolutely positioned in the corner with the row indented past it, so whichever chip comes first lines up under the title. Only order moves; the markup is the page's own.

The first version put the title alone on line one and everything else below - a screenshot showed a whole line spent on a 22px avatar, 85px per task.

πŸ”΄ Connections: the grid that kept its floor

.prj-conn-grid   w=328   cols=452.156px     <- one track, wider than the pane
SECTION.prj-conn L12 R464                   <- every panel cut off at the right
docScrollW = 360                            <- green

projects.css stacks the Connections and Overview grids at 1100px with a bare 1fr, and a bare 1fr track has an automatic minimum of min-content. One linked record's nowrap label set it. And because the shell clips sideways (overflow-x: hidden, 44a), the document measured a perfectly contained 360 - Β§30 again. It is the Branding lesson in the Techniques list, almost word for word: "when a page stacks its own grid at a breakpoint, check it kept the minmax(0, …)". Both grids now take minmax(0, 1fr) on a phone.

⬜ The same bare 1fr is in projects.css at every width from 769 to 1100px, so a long enough label could do this on a narrow desktop window too. At 1000px with today's data it measures fine (940px track), and the round's rule is that desktop stays byte-identical, so it is reported rather than changed: a one-word fix in projects.css (minmax(0, 1fr)) for whoever next touches that file.

Overview, History, the add-a-task form

The Overview and Connections grids were already one column; panels take the house 14px padding, "See all" and the link chips get tap height, and Next up's titles wrap. History needed nothing. The add-a-task form wraps: what needs doing on its own line, then who, then the due date beside Add. The date field carries the Β§20 reset, because it is not in a dialog and LAYER 3's copy does not reach it.

The dialogs (44i)

Both are .modal-content, so LAYER 3 already makes them full-screen sheets - .prj-form-modal's own max-width: 640px !important loses the tie to LAYER 3's on load order, as it should. Inside, the three method cards (100px columns, a description wrapping a word a line) and the field grid stack to one column, set with grid-template-columns because both are grids (Β§27). Measured: all fields inside, all 16px, the three date fields still inside when forced to the 210px an iPhone needs, the body scrolls to its end, and the dialogs (z-index 2000) sit over the footer (1200).

Verification

At 360x740, on the portfolio and on every tab of five projects (staged, agile, one with no phases): nothing off either edge (left < 0 included); the page's scroller found, scrolled to the end, and what is under the finger changed (Β§13, Β§28); every field 16px; no script errors.

Driven, not just measured (Β§31):

  • Filter sheet: one sidebar, inside the sheet; opens full-screen; typing in search filtered the list to 0 and the sheet stayed open; Everything closed it and took the list from 4 cards to 5, Finished to 1, and the bar's label followed each time.
  • Phase picker: 8 buttons on 8 rows; a task moved from "Build the network" to "Survey and design" through the page's own API and redraw, the toast said "Moved", and it was moved back the same way. A tap on the dimmed area closes the sheet.
  • Connections search: results inside the panel, 46px rows.

Desktop control at 1100px - fingerprint every element's box, disable mobile.css in place, fingerprint again - on the portfolio, three tabs of a project and the guide: identical, 4,427 boxes. Plus the Β§30 blind spot: no bar, sheet, footer or picker visible at desktop width, the sidebar in its home, Back and Edit in their own places with no aria-label added.

⚠️ The harness could not leave mobile - so it got a stub

The "viewport leaves mobile" path is where every node goes home, and it could not be exercised: a matchMedia change event never fires in a headless iframe, old or new headless, with or without virtual time. The Domains register in the same harness, a shipped and device-tested layer, failed identically (matches false, a fresh listener never called), which settled that it was the harness - the same family as requestAnimationFrame not running there (Domains' confirm overlay).

So a second harness fetches the real page, injects a controllable matchMedia stub ahead of every page script, renders it through srcdoc with a <base>, then widens the frame and fires the stub's change. Phone -> desktop -> phone, on both pages: the footer emptied and hid, Back / Edit / Delete went back to their exact places with their exact text and no aria-label, the pickers were removed, the sidebar went home at 240px, the custom properties were cleared - and all of it came back on the way down.

πŸ”‘ When the harness cannot deliver an event, deliver it yourself - but only after proving on a known-good page that the harness is the reason. The stub is only trustworthy because the control failed first.

Phase 2: the tools (44j-44p, JS 44d-44e)

Phase 2 added five tool tabs to a project's page - People, Scope, RACI, RAID, Gates - four dialogs, and a Projects -> Settings page. Measured the same way (360x740, explicit-width iframe), then looked at, on the temporary test project that has demo data on every tab:

Screen Found Answer
Tab row Up to nine tabs, 704px in 328 Nothing to do - 44e's swipeable row already holds it; scrolled to the end, the last tab sits fully inside and opens its panel
People Already one card per row - on a minmax(280px, 1fr) floor a 300px phone would not meet; the "Project manager" badge broke across two lines once the name kept clear of the remove cross minmax(0, 1fr); badge nowrap; role picker 40px
Scope The five-column MoSCoW board two-up at 158px (projects.css's own 1100px block): titles a word or two a line, every add field cut to "A deliverable o" beside a 20px "+"; moving a card is a drag One column per row - the scope read in priority order, Must first; add fields full width at 40px; a priority picker on each card (44d, below)
RACI 700px of matrix in a 328px scroller, and the sticky 220px deliverable column left room for one member at a time A card per deliverable, cells two-up, each button followed by the member's name (44l + 44e, below)
RAID Heat map fine at 40px squares; the type filter wrapped 4 + 2 at 27px tall; titles squeezed past an 84px type column; hover scale and fade on squares and rows Filter as three even 40px columns (Β§10); the type a kicker above a full-width title, score in the corner; hover effects off (Β§7)
Gates Already one column; Save and Decide 24px tall 40px
Dialogs All four already sheets (LAYER 3), every field 16px (44b), nothing off-edge, bodies scroll to the end; the RAID type picker wrapped 4 + 1 at 27px, the People one left-aligned at 27px Both even rows of 40px buttons, three across (RAID's five as 3 + 2)
Settings Shared settings layer already answers it - set-rows one column, tabs swipe, fields 16px, scroller reaches the end; the role list's grip only appears on :hover under an intro that says "Drag to reorder" Grip revealed with touch-action: none (Β§26); the row is HTML5 draggable, the kind Β§22 says iOS drives from a long press

⭐ Scope: changing a card's priority without a drag (44d)

Tapping a card already opened its dialog, and the dialog has a Priority field - so the move was possible. It was not the drag's equivalent: that is one gesture, and this was tap, find the field, choose, Save. And with the board stacked, the drag itself is out for the same reasons as the plan's lanes (44c): the column you want is a screen away, so a drag would have to scroll the page mid-gesture.

So each card gets 44c's picker, in its corner, opening 44c's bottom sheet with the five columns and the card's own one ticked. Same move as 44c, too: choosing a column dispatches the events projects-tools.js already listens for - dragstart on the card, then dragover on the column, drop, dragend - and its own item_move call and redraw do the rest. The dragover is not decoration: the page's drop handler sends the column's card order, and it is the dragover handler that moves the card into the column first. Leave it out and the order sent would not contain the card.

Zero new strings: column names from the column headings' text nodes (so the count badge stays behind), the sheet's title the card's own title, the button's name the dialog's own Priority label, the toast the plan's existing Moved. Only cards the analyst may change carry draggable="true", so only they get a picker, and .prj-readonly already hides .prj-move-btn. The capture-phase click on the picker stops the tap before the card's own handler opens the dialog; a tap anywhere else on the card still opens it.

The intro still says "Drag a card between columns". It is one translated sentence with the useful first half ("What the project delivers, sorted by priority"), so it stays; the lane hint in 44g could be hidden because it was only the gesture.

RACI as a card feed (44l, 44e)

Β§11's first table points at a scroller - the columns are user-chosen (the project's members). Its follow-up question settles it the other way: can they compare, on this screen? With the sticky deliverable column taking 220 of 328px, the screen showed one member at a time, so the comparison the matrix exists for was not available either way. And a RACI is read a deliverable at a time - who does it, who owns it - which is also where its warning lives ("Nobody accountable" is per row).

Each deliverable is a card: its title, then its cells two-up, each the page's own 40px button followed by the member's name. A tap still cycles R, A, C, I through the page's own handler.

⚠️ The names cannot use the shared data-mobile-label. The first screenshot showed every name twice - a grey one before the button and mine after it - because the §21 CSS prints td[data-mobile-label]::before for every feed in the app. The shared harvester was no use either: it reads the header's text nodes (these headers are all spans), and it rightly refuses to label an empty cell - which here is exactly the cell you tap to assign somebody. So 44e stamps the matching header's .prj-raci-name into its own attribute, data-prj-member, on every cell, re-run by a MutationObserver because renderRaci() replaces the table on every tap.

Phase 2 verification

At 360x740 on all five tool tabs and the settings page's four tabs: nothing off either edge, docScrollW 360, the scroller found and scrolled to the end with what is under the finger changing, every field 16px (the RAID "Show closed" checkbox is the one 13px input, and is a checkbox), no script errors.

Driven (Β§31):

  • Scope picker: 8 cards, 8 pickers named "Priority". A tap on the picker opened the sheet (title the card's, five options, Should ticked) and did not open the dialog; choosing Could moved the card through the page's own API and redraw, the toast said "Moved", and it was moved back to Should the same way, ending in its original place. A tap on the dimmed area closes the sheet; a tap on the card still opens the dialog with Priority filled in.
  • RACI: 35 of 35 cells named (and 0 data-mobile-label); one empty cell in a row with no Accountable tapped five times - R, A, C, I, empty - with all 35 names surviving every redraw, so it ended where it began.
  • RAID: the filter took the list 8 -> 4 risks -> 8; six buttons 107x40, none overflowing; a heat-map square filtered to its 1 risk and the 40px "clear" put it back.
  • Dialogs: all four full-screen, 0 small fields, 0 off-edge; People picker 3 x 107x40, RAID 3 + 2 at 105x40, Gate choices 328x62.
  • Phone -> desktop -> phone (the stubbed matchMedia harness above): 8 scope pickers, 35 RACI names and 8 plan pickers before; 0, 0, 0 at desktop width; all back on the way down.

Desktop control at 1100px, mobile.css toggled off in place and every element's box compared, on all nine tabs of the project, the portfolio, all four settings tabs and the guide: identical, 21,201 boxes. The Β§30 blind spot at desktop width: 0 pickers, 0 data-prj-member, 0 data-mobile-label, no sheet, filter bar or footer visible, Back in its own place with no aria-label. The only page edits in the round were the mobile.css / mobile.js version strings.

Asset targets (44q)

The Overview's Asset targets panel and its two dialogs, measured at 390 wide through an explicit-width iframe (a 390px headless window is cropped from a ~500px layout, not laid out at 390 - it looked as if everything spilled off the right):

Screen Found Answer
Target cards An auto-fill grid with a 260px floor - already one card per row Nothing to do; the pencil was a 22px target - 40px, and N still to do 40px tall
Target dialog Two three-across rows (Type / Where / Contains, Field / Is / Value) at about 90px each; the Matching / Linked picker sat in 44o's three-column grid with a hole One column; the two pickers two across
What's left A five-column table in a 340px sheet Each row a card: the asset name as its heading, then a labelled line per filled column (td[data-label]), empty ones hidden

Desktop untouched: every rule sits inside the LAYER's max-width: 768px block and is scoped to body[data-mobile-page="projects-view"] or body[data-mobile-module="projects"].

Still owed

  • The Scope intro's "Drag a card between columns" is wrong on a phone and cannot be hidden without losing the sentence before it. A separate scope.intro_touch string - or splitting the intro in two - is an i18n change, not a layer.
  • A device pass. Everything here is headless; iOS gesture handling, the real address bar and the picker's feel on a thumb are not.
  • projects.css's bare 1fr at 769-1100px (above) - latent on desktop, not changed this round.
  • A proper name for the picker. Its accessible name is the plan's word for a lane ("Stage"), which names what you are choosing but not the action. A plan.move_to string ("Move to a {timebox}") would be better, and needs the i18n pipeline rather than a mobile round.

See also: Projects Β· Mobile-Friendly Β· Techniques Β· Mobile: Domains (the filter sheet and footer this copies) Β· Mobile: Network Mapper (the dispatched-event move)

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally