diff --git a/helpers/HTMLView.js b/helpers/HTMLView.js
index a72f26dae..1176f64d0 100644
--- a/helpers/HTMLView.js
+++ b/helpers/HTMLView.js
@@ -8,7 +8,7 @@ import showdown from 'showdown' // for Markdown -> HTML from https://github.com/
 import { hasFrontMatter } from '@helpers/NPFrontMatter'
 import { getFolderFromFilename } from '@helpers/folders'
 import { clo, logDebug, logError, logInfo, logWarn, JSP, timer } from '@helpers/dev'
-import { getStoredWindowRect, getWindowFromCustomId, isHTMLWindowOpen, storeWindowRect } from '@helpers/NPWindows'
+import { getStoredWindowRect, getWindowFromCustomId, getWindowIdFromCustomId, isHTMLWindowOpen, storeWindowRect } from '@helpers/NPWindows'
 import { generateCSSFromTheme, RGBColourConvert } from '@helpers/NPThemeToCSS'
 import { isTermInEventLinkHiddenPart, isTermInNotelinkOrURI, isTermInMarkdownPath } from '@helpers/paragraph'
 import { RE_EVENT_LINK, RE_SYNC_MARKER, formRegExForUsersOpenTasks } from '@helpers/regex'
@@ -716,7 +716,10 @@ export async function sendToHTMLWindow(windowId: string, actionType: string, dat
 
     const windowExists = isHTMLWindowOpen(windowId)
     if (!windowExists) logWarn(`sendToHTMLWindow`, `Window ${windowId} does not exist; setting NPWindowID = undefined`)
-    const windowIdToSend = windowExists ? windowId : undefined // for iphone/ipad you have to send undefined
+    // runJavaScript expects the window's internal id; resolve customId to actual id when present
+    // TEST: this change identified by Cursor
+    // TEST: Not sure the comment about iphone/ipad is still relevant, but leaving it in for now.
+    const windowIdToSend = windowExists ? (getWindowIdFromCustomId(windowId) || windowId) : undefined // for iphone/ipad you have to send undefined
 
     const dataWithUpdated = {
       ...data,
diff --git a/helpers/NPFrontMatter.js b/helpers/NPFrontMatter.js
index e90f9ff6e..42f3e8925 100644
--- a/helpers/NPFrontMatter.js
+++ b/helpers/NPFrontMatter.js
@@ -572,7 +572,7 @@ export function endOfFrontmatterLineIndex(note: CoreNoteFields): number {
   try {
     const paras = note.paragraphs
     const lineCount = paras.length
-    logDebug(`paragraph/endOfFrontmatterLineIndex`, `total paragraphs in note (lineCount) = ${lineCount}`)
+    // logDebug(`paragraph/endOfFrontmatterLineIndex`, `total paragraphs in note (lineCount) = ${lineCount}`)
     // Can't have frontmatter as less than 2 separators
     if (paras.filter((p) => p.type === 'separator').length < 2) {
       return 0
@@ -590,7 +590,7 @@ export function endOfFrontmatterLineIndex(note: CoreNoteFields): number {
     while (lineIndex < lineCount) {
       const p = paras[lineIndex]
       if (p.type === 'separator') {
-        logDebug(`paragraph/endOfFrontmatterLineIndex`, `-> line ${lineIndex} of ${lineCount}`)
+        // logDebug(`paragraph/endOfFrontmatterLineIndex`, `-> line ${lineIndex} of ${lineCount}`)
         return lineIndex
       }
       lineIndex++
@@ -983,8 +983,12 @@ export function determineAttributeChanges(
  * @param {string} value - The attribute value to normalize.
  * @returns {string} - The normalized value.
  */
-export function normalizeValue(value: string): string {
-  return value.replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1')
+export function normalizeValue(value: mixed): string {
+  if (value == null) {
+    return ''
+  }
+  const asString = typeof value === 'string' ? value : String(value)
+  return asString.replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1')
 }
 
 /**
@@ -1010,13 +1014,35 @@ export function updateFrontMatterVars(note: TEditor | TNote, newAttributes: { [s
     logDebug('updateFrontMatterVars', `updateFrontMatterVars: note has ${note.paragraphs.length} paragraphs after ensureFrontmatter`)
 
     const existingAttributes = { ...getFrontmatterAttributes(note) } || {}
+    const existingKeyByLowercase: { [string]: string } = {}
+    // Build lookup from raw frontmatter lines to preserve original key casing.
+    const existingFrontmatterParas = getFrontmatterParagraphs(note, false)
+    if (existingFrontmatterParas && existingFrontmatterParas.length > 0) {
+      existingFrontmatterParas.forEach((para) => {
+        const colonIndex = para.content.indexOf(':')
+        if (colonIndex > 0) {
+          const rawKey = para.content.slice(0, colonIndex).trim()
+          if (rawKey !== '') {
+            existingKeyByLowercase[rawKey.toLowerCase()] = rawKey
+          }
+        }
+      })
+    }
+    // Fallback to parsed attributes map when needed.
+    Object.keys(existingAttributes).forEach((existingKey) => {
+      const lcKey = existingKey.toLowerCase()
+      if (!existingKeyByLowercase[lcKey]) {
+        existingKeyByLowercase[lcKey] = existingKey
+      }
+    })
     // Normalize newAttributes before comparison
     clo(existingAttributes, `updateFrontMatterVars: existingAttributes`)
     const normalizedNewAttributes: { [string]: any } = {}
     clo(Object.keys(newAttributes), `updateFrontMatterVars: Object.keys(newAttributes) = ${JSON.stringify(Object.keys(newAttributes))}`)
-    Object.keys(newAttributes).forEach((key: string) => {
-      const value = newAttributes[key]
-      logDebug('updateFrontMatterVars', `newAttributes key: ${key}, value: ${value}`) // ✅
+    Object.keys(newAttributes).forEach((rawKey: string) => {
+      const canonicalKey = existingKeyByLowercase[rawKey.toLowerCase()] || rawKey
+      const value = newAttributes[rawKey]
+      logDebug('updateFrontMatterVars', `newAttributes key: ${rawKey}, value: ${value}`) // ✅
 
       // Handle null/undefined - skip them (they won't be in normalizedNewAttributes,
       // so if deleteMissingAttributes is true, they will be deleted)
@@ -1027,7 +1053,7 @@ export function updateFrontMatterVars(note: TEditor | TNote, newAttributes: { [s
       let normalizedValue: string
       if (typeof value === 'object') {
         normalizedValue = JSON.stringify(value)
-      } else if (key === 'triggers') {
+      } else if (canonicalKey.toLowerCase() === 'triggers') {
         normalizedValue = value.trim()
       } else {
         const trimmedValue = value.trim()
@@ -1035,10 +1061,10 @@ export function updateFrontMatterVars(note: TEditor | TNote, newAttributes: { [s
         // quoteTextIfNeededForFM will handle empty strings correctly (returns '' without quotes)
         normalizedValue = quoteTextIfNeededForFM(trimmedValue)
       }
-      logDebug('updateFrontMatterVars', `normalizedValue for key: ${key} = ${normalizedValue}`)
+      logDebug('updateFrontMatterVars', `normalizedValue for key: ${canonicalKey} = ${normalizedValue}`)
 
       // $FlowIgnore
-      normalizedNewAttributes[key] = normalizedValue
+      normalizedNewAttributes[canonicalKey] = normalizedValue
     })
 
     const { keysToAdd, keysToUpdate, keysToDelete } = determineAttributeChanges(existingAttributes, normalizedNewAttributes, deleteMissingAttributes)
@@ -1062,7 +1088,8 @@ export function updateFrontMatterVars(note: TEditor | TNote, newAttributes: { [s
     keysToUpdate.forEach((key: string) => {
       // $FlowIgnore
       const attributeLine = `${key}: ${normalizedNewAttributes[key]}`
-      const paragraph = note.paragraphs.find((para) => para.content.startsWith(`${key}:`))
+      const keyPrefixRe = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:`, 'i')
+      const paragraph = note.paragraphs.find((para) => keyPrefixRe.test(para.content))
       if (paragraph) {
         logDebug('updateFrontMatterVars', `updateFrontMatterVars: updating paragraph "${paragraph.content}" with "${attributeLine}"`)
         paragraph.content = attributeLine
@@ -1104,18 +1131,19 @@ export function updateFrontMatterVars(note: TEditor | TNote, newAttributes: { [s
           return false
         }
       } else {
-        throw new Error(`Failed to find closing '---' in note "${note.filename || ''}" could not add new attribute "${key}".`)
+        logError('updateFrontMatterVars', `Failed to find closing '---' in note "${note.filename || ''}" for new attribute "${key}".`)
       }
     })
 
     // Delete attributes that are no longer present
     const paragraphsToDelete = []
     keysToDelete.forEach((key) => {
-      const paragraph = note.paragraphs.find((para) => para.content.startsWith(`${key}:`))
+      const keyPrefixRe = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:`, 'i')
+      const paragraph = note.paragraphs.find((para) => keyPrefixRe.test(para.content))
       if (paragraph) {
         paragraphsToDelete.push(paragraph)
       } else {
-        throw new Error(`Failed to find paragraph for key "${key}".`)
+        logWarn('updateFrontMatterVars', `Couldn't find paragraph for key "${key}" while deleting; will continue.`)
       }
     })
     if (paragraphsToDelete.length > 0) {
diff --git a/helpers/NPThemeToCSS.js b/helpers/NPThemeToCSS.js
index 0399db361..246858e9d 100644
--- a/helpers/NPThemeToCSS.js
+++ b/helpers/NPThemeToCSS.js
@@ -37,7 +37,8 @@ function loadThemeData(themeNameIn: string = ''): { themeName: string, themeJSON
       themeName = themeNameIn
       logDebug('loadThemeData', `Reading theme '${themeName}'`)
       themeJSON = matchingThemeObjs[0].values
-      currentThemeMode = Editor.currentTheme.mode
+      currentThemeMode = themeJSON?.mode ?? 'light'
+      logDebug('loadThemeData', `-> mode '${currentThemeMode}'`)
     } else {
       logWarn('loadThemeData', `Theme '${themeNameIn}' is not in list of available themes. Will try to use current theme instead.`)
     }
@@ -51,6 +52,7 @@ function loadThemeData(themeNameIn: string = ''): { themeName: string, themeJSON
     if (themeName !== '') {
       themeJSON = Editor.currentTheme.values
       currentThemeMode = Editor.currentTheme.mode
+      logDebug('loadThemeData', `-> mode '${currentThemeMode}'`)
     } else {
       logWarn('loadThemeData', `Cannot get settings for your current theme '${themeName}'`)
     }
@@ -65,6 +67,7 @@ function loadThemeData(themeNameIn: string = ''): { themeName: string, themeJSON
       logDebug('loadThemeData', `Reading your dark theme '${themeName}'`)
       themeJSON = matchingThemeObjs[0].values
       currentThemeMode = 'dark'
+      logDebug('loadThemeData', `-> mode '${currentThemeMode}'`)
     } else {
       logWarn('loadThemeData', `Cannot get settings for your dark theme '${themeName}'`)
     }
@@ -158,10 +161,10 @@ export function generateCSSFromTheme(themeNameIn: string = ''): string {
       rootSel.push(`--fg-warn-color: color-mix(in oklch, var(--fg-main-color), orange 20%)`)
       rootSel.push(`--fg-error-color: color-mix(in oklch, var(--fg-main-color), red 20%)`)
       rootSel.push(`--fg-ok-color: color-mix(in oklch, var(--fg-main-color), green 20%)`)
-      rootSel.push(`--bg-info-color: color-mix(in oklch, var(--bg-main-color), blue 20%)`)
+      rootSel.push(`--bg-info-color: color-mix(in oklch, var(--bg-main-color), blue 10%)`)
       rootSel.push(`--bg-warn-color: color-mix(in oklch, var(--bg-main-color), orange 20%)`)
-      rootSel.push(`--bg-error-color: color-mix(in oklch, var(--bg-main-color), red 20%)`)
-      rootSel.push(`--bg-ok-color: color-mix(in oklch, var(--bg-main-color), green 20%)`)
+      rootSel.push(`--bg-error-color: color-mix(in oklch, var(--bg-main-color), red 15%)`)
+      rootSel.push(`--bg-ok-color: color-mix(in oklch, var(--bg-main-color), green 15%)`)
       rootSel.push(`--bg-disabled-color: color-mix(in oklch, var(--bg-main-color), gray 20%)`)
       rootSel.push(`--fg-disabled-color: color-mix(in oklch, var(--fg-main-color), gray 20%)`)
     }
diff --git a/helpers/NPdateTime.js b/helpers/NPdateTime.js
index 077c5cb75..f87fc87e8 100644
--- a/helpers/NPdateTime.js
+++ b/helpers/NPdateTime.js
@@ -1143,7 +1143,7 @@ export function getRelativeDates(useISODailyDates: boolean = false): Array<{ rel
     const relativeDates = []
     const todayMom = moment()
 
-    logInfo('NPdateTime::getRelativeDates', `Starting, with DataStore: ${typeof DataStore}`)
+    // logInfo('NPdateTime::getRelativeDates', `Starting, with DataStore: ${typeof DataStore}`)
     if (!DataStore || typeof DataStore !== 'object') {
       // A further test for DataStore.calendarNoteByDateString, as that can sometimes fail even when DataStore is available
       if (!DataStore.calendarNoteByDateString) {
diff --git a/helpers/__tests__/NPFrontMatter/NPFrontMatterAttributes.test.js b/helpers/__tests__/NPFrontMatter/NPFrontMatterAttributes.test.js
index 536d7a338..edd32acc7 100644
--- a/helpers/__tests__/NPFrontMatter/NPFrontMatterAttributes.test.js
+++ b/helpers/__tests__/NPFrontMatter/NPFrontMatterAttributes.test.js
@@ -341,6 +341,49 @@ describe(`${PLUGIN_NAME}`, () => {
         expect(barParagraph).toBeDefined()
         expect(barParagraph.content).toEqual('bar: newBaz')
       })
+
+      test('should update existing key regardless of case and not add duplicate key', () => {
+        const note = new Note({
+          content: '---\ntitle: foo\nDue: 2026-03-01\n---\n',
+          paragraphs: [
+            { type: 'separator', content: '---', lineIndex: 0 },
+            { content: 'title: foo', lineIndex: 1 },
+            { content: 'Due: 2026-03-01', lineIndex: 2 },
+            { type: 'separator', content: '---', lineIndex: 3 },
+          ],
+          title: 'foo',
+        })
+
+        const result = f.updateFrontMatterVars(note, { title: 'foo', due: '2026-03-09' })
+        expect(result).toEqual(true)
+
+        const dueParagraphs = note.paragraphs.filter((p) => /^due:/i.test(p.content))
+        expect(dueParagraphs.length).toEqual(1)
+        expect(dueParagraphs[0].content).toEqual('Due: 2026-03-09')
+      })
+
+      test('should treat lower-case incoming key as matching existing mixed-case key when deleting missing attributes', () => {
+        const note = new Note({
+          content: '---\ntitle: foo\nDue: 2026-03-01\nOld: remove_me\n---\n',
+          paragraphs: [
+            { type: 'separator', content: '---', lineIndex: 0 },
+            { content: 'title: foo', lineIndex: 1 },
+            { content: 'Due: 2026-03-01', lineIndex: 2 },
+            { content: 'Old: remove_me', lineIndex: 3 },
+            { type: 'separator', content: '---', lineIndex: 4 },
+          ],
+          title: 'foo',
+        })
+
+        const result = f.updateFrontMatterVars(note, { title: 'foo', due: '2026-03-09' }, true)
+        expect(result).toEqual(true)
+
+        const dueParagraph = note.paragraphs.find((p) => /^due:/i.test(p.content))
+        expect(dueParagraph).toBeDefined()
+        expect(dueParagraph?.content).toEqual('Due: 2026-03-09')
+        const oldParagraph = note.paragraphs.find((p) => /^old:/i.test(p.content))
+        expect(oldParagraph).toBeUndefined()
+      })
     })
   })
 })
diff --git a/helpers/dateTime.js b/helpers/dateTime.js
index 79ccf2884..7a612a79b 100644
--- a/helpers/dateTime.js
+++ b/helpers/dateTime.js
@@ -331,6 +331,11 @@ export function convertISODateFilenameToNPDayFilename(dailyNoteFilename: string)
 
 // Note: ? This does not work to get reliable date string from note.date for daily notes
 export function toISODateString(dateObj: Date): string {
+  // Guard against null/invalid Date objects to avoid runtime errors
+  if (dateObj == null || !(dateObj instanceof Date) || isNaN(dateObj.getTime())) {
+    logDebug('dateTime / toISODateString', `Invalid Date object passed: ${String(dateObj)}`)
+    return ''
+  }
   // logDebug('dateTime / toISODateString', `${dateObj.toISOString()} // ${toLocaleDateTimeString(dateObj)}`)
   return dateObj.toISOString().slice(0, 10)
 }
@@ -1233,7 +1238,7 @@ export function calcOffsetDate(baseDateStrIn: string, interval: string): Date |
 
     // calc offset (Note: library functions cope with negative nums, so just always use 'add' function)
     const baseDateMoment = moment(baseDateStrIn, momentDateFormat)
-    const newDate = unit !== 'b' ? baseDateMoment.add(num, unitForMoment) : momentBusiness(baseDateMoment).businessAdd(num).toDate()
+    const newDate = unit !== 'b' ? baseDateMoment.add(num, unitForMoment).toDate() : momentBusiness(baseDateMoment).businessAdd(num).toDate()
 
     // logDebug('dateTime / cOD', `for '${baseDateStrIn}' interval ${num} / ${unitForMoment} -> ${String(newDate)}`)
     return newDate
diff --git a/jgclark.Reviews/CHANGELOG.md b/jgclark.Reviews/CHANGELOG.md
index e25d8b41e..07e9d799b 100644
--- a/jgclark.Reviews/CHANGELOG.md
+++ b/jgclark.Reviews/CHANGELOG.md
@@ -1,9 +1,76 @@
 # What's changed in 🔬 Projects + Reviews plugin?
 See [website README for more details](https://github.com/NotePlan/plugins/tree/main/jgclark.Reviews), and how to configure.under-the-hood fixes for integration with Dashboard plugin
 
+## [2.0.0.b13] - 2026-03-26
+- when invalid frontmatter metadata values are detected (like `review: @review()` or `due: @due()`), automatically remove the affected frontmatter key.
+- normalize mention-style date frontmatter values (e.g. `due: @due(2026-03-09)`) to plain date values (`due: 2026-03-09`) during Project constructor processing.
+- Handle frontmatter fields in a case-insensitive manner.
+
+## [2.0.0.b12] - 2026-03-22
+- improve multi-column layout
+- remove two config settings that should have been removed earlier.
+- dev: streamline CSS definitions
+
+## [2.0.0.b11] - 2026-03-20
+### Project Metadata & Frontmatter
+Project metadata can now be fully stored in frontmatter, either as a single configurable key (project:) or as separate keys for individual fields (start, due, reviewed, etc.). Migration is automatic — when any command updates a note with body-based metadata, it moves it to frontmatter and cleans up the body line. After a review is finished, any leftover body metadata line is replaced with a migration notice, then removed on the next finish.
+### Modernised Project List Design
+The Rich project list has been significantly modernised with a more compact, calmer layout showing more metadata at a glance.
+### New Controls
+An "Order by" control has been added to the top bar (completed/cancelled/paused projects sort last unless ordering by title). Automatic refresh for the Rich project list is available via a new "Automatic Update interval" setting (in minutes; 0 to disable).
+### Progress Reporting
+Weekly per-folder progress CSVs now use full folder paths consistently and include a totals row. This data can also be visualised as two heatmaps — notes progressed per week and tasks completed per week.
+### Other
+The "Group by folder" now defaults to off.
+<!--
+## [1.4.0.b10] - 2026-03-20
+- Add 'Order by' control to the Filter dropdown menu. Note: completed/cancelled/paused projects are shown last, unless you request ordering by title.
+
+## [1.4.0.b9] - 2026-03-20
+- Modernised layout significantly for Rich project list, including add more metadata in a compact and calmer look
+- Default for "Group by folder" is now off, for the best look.
+
+## [1.4.0.b8] - 2026-03-16
+- When finishing a review, if project metadata is in frontmatter, any existing body metadata line is replaced with the message "Project metadata has been migrated to frontmatter". On the next finish, that message line is removed.
+
+## [1.4.0.b7] - 2026-03-13
+- Status lozenge changes
+  - Shorten text and add icons
+  - Drop lozenges when not further out than 2 weeks
+  - Improved colouring, re-using earlier error/warn/info colours
+  - Move to column 2 (via hidden setting 'statusLozengesInColumn2')
+- Improved alignment of text and icons in column 2
+- Fixed bug where a specifically-set theme could pick up the wrong light/dark mode.
+
+## [1.4.0.b6] - 2026-03-12
+- New: Weekly per-folder Area/Project progress CSVs now use full folder paths consistently and include a totals row at the bottom of each table.
+- New: Weekly per-folder Area/Project progress can now be viewed as two heatmaps (notes progressed per week and tasks completed per week), using data from the CSVs.
+
+## [1.4.0.b5] - 2026-02-27
+- Project metadata can now be fully stored in frontmatter as well as in the note body. You have two options:
+  - You can now use a configurable frontmatter key name (default `project:`), to store it all in a single value.
+  - Or you can use separate frontmatter keys (`start`, `due`, `reviewed`, `completed`, `cancelled`, `review`, `nextReview`).  when they already exist, and keeps them in sync when they are present, without creating new keys for notes that don’t use them.
+    - Note: if you have configured different phrases for these strings, they are used instead (without the leading `@` character)
+- New: When any command updates a project note that had metadata in the body, it now writes to frontmatter and removes that body line. Migration includes all tags in the metadata line (e.g. `#project` or `#area`) as well as the dates in mentions (e.g. `@due(2026-08-22)`.
+
+## [1.4.0.b4] - 2026-02-26
+- New: Automatic refresh for the Project List display (Rich window only). To use this, set the new setting "Automatic Update interval" to the number of minutes. Leave at 0 to turn off.
+
+## [1.4.0.b3] - 2026-02-24
+- Dev: Refactor reviews.js significantly to better separate logic from display concerns. Adds new file `reviewsHTMLTemplates.js`.
+
+## [1.4.0.b2] - 2026-02-24
+- New: Added a 'Demo' mode, which swaps in a separate `allProjectsDemoListDefault.json` to display details of real (or potentially fictitious) projects for demo.
+
+## [1.4.0.b1] - 2026-02-22
+- Change: Rich project list: column 3 (metadata column) now shows review and due status as coloured lozenges, plus project tag(s), #sequential when applicable, and all hashtags from the note's metadata line and frontmatter `project` value. New Project field `allProjectTags` holds this combined list.
+- Dev: Project class now uses ISO date strings (YYYY-MM-DD) for startDate, dueDate, reviewedDate, completedDate, and cancelledDate instead of JavaScript Date objects; removes Date/string conversion in constructor, allProjectsListHelpers, and reviewHelpers.
+-->
+
 ## [1.3.1] - 2026-02-26
 - New setting "Theme to use for Project Lists": if set to a valid installed Theme name, the Rich project list window uses that theme instead of your current NotePlan theme. Leave blank to use your current theme.
 - Fixed edge case with adding progress updates and frontmatter.
+- Fixed malformed frontmatter mentions (e.g. `@review()` or `@due()`) causing repeated runtime processing; now logs at WARN level and safely ignores empty bracket values.
 
 ## [1.3.0] - 2026-02-20
 ### Display Improvements
diff --git a/jgclark.Reviews/README.md b/jgclark.Reviews/README.md
index 0b64f462a..c11aaad5a 100644
--- a/jgclark.Reviews/README.md
+++ b/jgclark.Reviews/README.md
@@ -13,7 +13,7 @@ After each project name (the title of the note) is an edit icon, which when clic
 
 ![Edit dialog](edit-dialog-1.1.png)
 
-User George (@george65) has recorded two video walkthroughs that show most of what the plugin does (recorded using an earlier version of the plugin, so the UI is different):
+User George (@george65) has recorded two video walkthroughs that show most of what the plugin does (recorded using a rather earlier version of the plugin, so the UI is different):
 
 - [Inside Look: How George, CMO of Verge.io, Uses NotePlan for Effective Project Management](https://www.youtube.com/watch?v=J-FlyffE9iA) featuring this and my Dashboard plugin.
 
@@ -32,7 +32,7 @@ Each **Project** is described by a separate note, and has a lifecycle something
 
 ![project lifecycle](project-flowchart_bordered.jpg)
 
-Each such project contains the `#project` hashtag, `@review(...)` and some other **metadata** fields (see below for where to put them).  For example:
+Each such project contains some **metadata** fields including `#project` hashtag, `@review(...)` and some others (see below for more details).  For example:
 
 ```markdown
 # Secret Undertaking
@@ -77,13 +77,89 @@ Aim: Make sure 007's Aston Martin continues to run well, is legal etc.
 ```
 (Note: This example uses my related [Repeat Extensions plugin](https://github.com/NotePlan/plugins/tree/main/jgclark.RepeatExtensions/) to give more flexibility than the built-in repeats.)
 
+## v2 changes
+New Filter & Order options in a dropdown:
+
+![New Filter & Order options in a dropdown:](filter+order-v2.0.0.b11.png)
+
+
+Each Project row show the following details:
+
+![Each Project row show the following details:](project-detail-numbered.png)
+1. Title, with its icon
+2. Edit button, brings up edit dialog
+3. Any hashtags defined on the project
+4. Folder it lives in
+5. The review interval
+6. Notes if the project or reviews are overdue or due soon.
+7. % completion (as before, but now shown in a more compact way)
+8. Latest 'progress' you've noted for the project
+9. Any 'next action' on the project
+
 ## Where you can put the project data (metadata fields)
-The plugin tries to be as flexible as possible about where project metadata can go. It looks in order for:
-- the first line starting 'project:' or 'medadata:' in the note or its frontmatter
-- the first line containing a @review() or @reviewed() mention
-- the first line starting with a #hashtag.
+The plugin tries to be as flexible as possible about where project metadata can go.
+
+From **v2.0** it supports both:
+
+- **Body metadata line** (legacy and still supported), and
+- **Frontmatter metadata**, which over time becomes the main source of truth.
+
+When looking for project metadata it checks, in order:
+
+- the first line starting `project:` or `metadata:` in the note or its frontmatter
+- the first line containing a `@review()` or `@reviewed()` mention
+- the first line starting with a `#hashtag`.
 
-If these can't be found, then the plugin creates a new line after the title, or if the note has frontmatter, a 'metadata:' line in the frontmatter.
+If these can't be found, then the plugin creates a new line after the title, or if the note has frontmatter, a new field in frontmatter under the configured key (see below).
+
+### Using frontmatter for project metadata
+
+If your note has a frontmatter block, the plugin can store project metadata there as well as (or instead of) in the body. There are two parts to this:
+
+- A **combined metadata field** containing the whole metadata line.
+- Optional **separate fields** for individual dates/values.
+
+#### Combined frontmatter key (default `project`)
+
+The **Frontmatter metadata key** setting controls which frontmatter key is used to store the combined metadata line. By default this is `project:`, but you can set it to any string you like (for example `metadata`).
+
+Internally this combined field stores exactly the same content as the body metadata line, for example:
+
+```yaml
+---
+title: Project Title
+project: #project @review(2w) @reviewed(2021-07-20) @start(2021-04-05) @due(2021-11-30)
+---
+```
+
+When a note still has a metadata line in the body but **no** value in the combined frontmatter key, the plugin will migrate that body line into the configured frontmatter key and **remove the metadata line from the body**. When any command later updates that project note, it writes to frontmatter and removes the previous body metadata line if present. All tags such as `#project` are preserved during migration.
+
+#### Separate frontmatter fields (if present)
+
+If you prefer, you can also use separate frontmatter fields for the different dates and values. The names of these fields are derived from your **metadata @mention settings**, by stripping any leading `@` or `#`. The equivalent would then read:
+
+```yaml
+---
+title: Project Title
+project: #project
+start: 2021-04-05
+due: 2021-11-30
+reviewed: 2021-07-20
+review: 2w
+nextReview: 2021-08-03
+---
+```
+
+The plugin:
+
+- **Reads** from these separate fields if they already exist in frontmatter (using whatever key names your current settings imply), and overlays them on top of what it finds in the combined line.
+- **Writes back** to these fields **only if they already exist**. It will not create new separate keys on its own; it simply keeps any existing ones in sync when it updates metadata, again using the key names derived from your current `*MentionStr` settings.
+
+You can therefore:
+
+- Use only the combined frontmatter key, or
+- Use both the combined key and any separate fields you choose to add, or
+- Continue to use just the body metadata line (the plugin will migrate it into frontmatter and remove it from the body when it next needs to update metadata).
 
 The first hashtag in the note defines its type, so as well as `#project`, `#area` you could have a `#goal` or whatever makes most sense for you. 
 
@@ -93,7 +169,7 @@ Other notes:
 - If there are multiple copies of a metadata field, only the first one is used.
 - I'm sometimes asked why I use `@reviewed(2021-06-25)` rather than `@reviewed/2021-06-25`. The answer is that while the latter form is displayed in a neater way in the sidebar, the date part isn't available in the NotePlan API as the part after the slash is not a valid @tag as it doesn't contain an alphabetic character.
 
-_The next major release of the plugin will make it possible to migrate all this metadata to the Frontmatter block that has become properly supported since NotePlan 3.16.3._
+_From v1.4.0.b5 the plugin migrates this metadata into the Frontmatter block (if present) and removes the body metadata line, leaving the note body cleaner._
 
 ## Selecting notes to include
 There are 2 parts of this:
@@ -153,6 +229,7 @@ The settings relating to Progress are:
 
 ## Other Plugin settings
 - Open Project Lists in what sort of macOS window?: (from v1.3) Choose whether the Rich project list opens in NotePlan's main window or in a separate window.
+- **Automatic Update interval**: (from v1.4.0.b4) If set to any number > 0, the Rich Project Lists window will automatically refresh when it has been idle for that many minutes. Set to 0 to disable. When the list refreshes (manually or automatically), the current scroll position is preserved as closely as possible.
 - Next action tag(s): optional list of #hashtags to include in a task or checklist to indicate its the next action in this project (comma-separated; default '#next'). If there are no tagged items and the note has `project: #sequential` in frontmatter, the first open task/checklist is shown as the next action. Only the first matching item is shown.
 - Display next actions in output? This requires the previous setting to be set (or use #sequential). Toggle is in the Filter… menu as "Show next actions?".
 - Folders to Include (optional): Specify which folders to include (which includes any of their sub-folders) as a comma-separated list. This match is done anywhere in the folder name, so you could simply say `Project` which would match for `Client A/Projects` as well as `Client B/Projects`. Note also: 
@@ -211,6 +288,19 @@ Progress: <num>@YYYY-MM-DD <short description>
 ```
 It will also update the project's `@reviewed(date)`.
 
+### "/heatmaps for weekly Projects Progress" command
+The **/weeklyProjectsProgress heatmaps** command scans your Area/Project folders by week, and shows a pair of heatmaps in new windows:
+
+- one heatmap for notes progressed per week per folder of notes (where a project note counts as being progressed if one or more tasks are completed)
+- one heatmap for tasks completed per week per folder of notes
+
+For those with lots of different projects or project groups, this is a handy way of seeing over time which of them are getting more or less attention.
+
+<!-- The **/weeklyProjectsProgress** command scans your Area/Project folders and writes two CSV files into the plugin’s hidden data folder:
+
+- one with the number of distinct notes progressed per folder per week
+- one with the total number of completed tasks per folder per week -->
+
 ## Capturing and Displaying 'Next Actions'
 Part of the "Getting Things Done" methodology is to be clear what your 'next action' is. If you put a standard tag on such actionable tasks/checklists (e.g. `#next` or `#na`) and set that in the plugin settings, the project list shows that next action after the progress summary. Only the first matching item is shown; if there are no tagged items and the note has `project: #sequential` in frontmatter, the first open task/checklist is shown instead. You can set several next-action tags (e.g. `#na` for things you can do, `#waiting` for things you're waiting on others).
 
diff --git a/jgclark.Reviews/filter+order-v2.0.0.b11.png b/jgclark.Reviews/filter+order-v2.0.0.b11.png
new file mode 100644
index 000000000..412003e34
Binary files /dev/null and b/jgclark.Reviews/filter+order-v2.0.0.b11.png differ
diff --git a/jgclark.Reviews/plugin.json b/jgclark.Reviews/plugin.json
index 65faa8c8c..db2844eb6 100644
--- a/jgclark.Reviews/plugin.json
+++ b/jgclark.Reviews/plugin.json
@@ -9,9 +9,9 @@
   "plugin.author": "Jonathan Clark",
   "plugin.url": "https://noteplan.com/plugins/jgclark.Reviews",
   "plugin.changelog": "https://github.com/NotePlan/plugins/blob/main/jgclark.Reviews/CHANGELOG.md",
-  "plugin.version": "1.3.1",
-  "plugin.releaseStatus": "full",
-  "plugin.lastUpdateInfo": "1.3.1: Fixed edge case with adding progress updates and frontmatter.\n1.3.0: Please see CHANGELOG.md for details of the many Display improvements, Processing improvements and fixes.",
+  "plugin.version": "2.0.0.b13",
+  "plugin.releaseStatus": "beta",
+  "plugin.lastUpdateInfo": "2.0.0: Frontmatter metadata support, including configurable combined key and migration from body.\nSignificantly modernised layout for Rich project list.\n1.3.1: Fixed edge case with adding progress updates and frontmatter.\n1.3.0: Please see CHANGELOG.md for details of the many Display improvements, Processing improvements and fixes.",
   "plugin.script": "script.js",
   "plugin.dependsOn": [
     {
@@ -57,6 +57,12 @@
         "iconColor": "orange-600"
       }
     },
+    {
+      "hidden": true,
+      "name": "toggle demo mode for project lists",
+      "description": "Toggle demo mode for project lists. When true, '/project lists' shows fixed demo data (allProjectsDemoList.json), without recalculating from notes",
+      "jsFunction": "toggleDemoModeForProjectLists"
+    },
     {
       "hidden": true,
       "name": "generateProjectListsAndRenderIfOpen",
@@ -163,11 +169,6 @@
       "description": "prompts for a short description and percentage completion number for the open project note, and writes it to the metadata area of the note",
       "jsFunction": "addProgressUpdate"
     },
-    {
-      "name": "Projects: update plugin settings",
-      "description": "Settings interface (even for iOS)",
-      "jsFunction": "updateSettings"
-    },
     {
       "hidden": false,
       "name": "weeklyProjectsProgress",
@@ -175,6 +176,13 @@
       "description": "Generate per-folder Area/Project progress stats as CSV files in the plugin data folder",
       "jsFunction": "writeProjectsWeeklyProgressToCSV"
     },
+    {
+      "hidden": false,
+      "name": "heatmaps for weekly Projects Progress",
+      "alias": [],
+      "description": "Show per-folder Area/Project progress as two weekly heatmaps (notes progressed and tasks completed) in HTML windows",
+      "jsFunction": "showProjectsWeeklyProgressHeatmaps"
+    },
     {
       "hidden": true,
       "name": "removeAllDueDates",
@@ -233,6 +241,11 @@
       "description": "no operation - testing way to stop losing plugin context",
       "jsFunction": "NOP"
     },
+    {
+      "name": "Projects: update plugin settings",
+      "description": "Settings interface (even for iOS)",
+      "jsFunction": "updateSettings"
+    },
     {
       "name": "test:redToGreenInterpolation",
       "description": "test red - green interpolation",
@@ -360,7 +373,7 @@
     {
       "key": "preferredWindowType",
       "title": "Open 'Rich' Project List in what sort of window?",
-      "description": "On NotePlan v3.20+ on macOS only, you can open the 'Rich' output window in different ways: 'New Window' for a separate window; 'Main Window' to take over the main window; 'Split View' for a split view in the main window.",
+      "description": "On NotePlan v3.20+, you can open the 'Rich' output window in different ways: 'New Window' for a separate window; 'Main Window' to take over the main window; 'Split View' for a split view in the main window.",
       "type": "string",
       "default": "New Window",
       "choices": [
@@ -404,7 +417,7 @@
       "title": "Show projects grouped by folder?",
       "description": "Whether to group the projects by their folder.",
       "type": "bool",
-      "default": true,
+      "default": false,
       "required": true
     },
     {
@@ -455,6 +468,14 @@
       "default": true,
       "required": true
     },
+    {
+      "key": "autoUpdateAfterIdleTime",
+      "title": "Automatic Update interval",
+      "description": "If set to any number > 0, the Project List will automatically refresh when the window is idle for a certain number of minutes. Set to 0 to disable.",
+      "type": "number",
+      "default": 0,
+      "required": true
+    },
     {
       "key": "width",
       "title": "Window width",
@@ -636,6 +657,14 @@
       "default": "@nextReview",
       "required": true
     },
+    {
+      "key": "projectMetadataFrontmatterKey",
+      "title": "Frontmatter metadata key",
+      "description": "Frontmatter key used to store the combined project metadata string (defaults to 'project'; 'metadata' is a common alternative).",
+      "type": "string",
+      "default": "project",
+      "required": true
+    },
     {
       "type": "separator"
     },
@@ -673,6 +702,14 @@
       "type": "bool",
       "default": false,
       "required": true
+    },
+    {
+      "key": "useDemoData",
+      "title": "Use demo data?",
+      "description": "If set, then the project lists will use demo data instead of live data.",
+      "type": "bool",
+      "default": false,
+      "required": true
     }
   ]
 }
\ No newline at end of file
diff --git a/jgclark.Reviews/project-detail-numbered.png b/jgclark.Reviews/project-detail-numbered.png
new file mode 100644
index 000000000..f1cab5056
Binary files /dev/null and b/jgclark.Reviews/project-detail-numbered.png differ
diff --git a/jgclark.Reviews/projects-list-v1.4.0.b1 2@2x.png b/jgclark.Reviews/projects-list-v1.4.0.b1 2@2x.png
new file mode 100644
index 000000000..53c4aa3c6
Binary files /dev/null and b/jgclark.Reviews/projects-list-v1.4.0.b1 2@2x.png differ
diff --git a/jgclark.Reviews/projects-list-v2.0.0.b11.png b/jgclark.Reviews/projects-list-v2.0.0.b11.png
new file mode 100644
index 000000000..42c87c5aa
Binary files /dev/null and b/jgclark.Reviews/projects-list-v2.0.0.b11.png differ
diff --git a/jgclark.Reviews/requiredFiles/HTMLWinCommsSwitchboard.js b/jgclark.Reviews/requiredFiles/HTMLWinCommsSwitchboard.js
index 1e77bfeb1..af3686518 100644
--- a/jgclark.Reviews/requiredFiles/HTMLWinCommsSwitchboard.js
+++ b/jgclark.Reviews/requiredFiles/HTMLWinCommsSwitchboard.js
@@ -1,6 +1,6 @@
 //--------------------------------------------------------------------------------------
 //  HTMLWinCommsSwitchboard.js - in the HTMLWindow process data and logic to/from the plugin
-// Last updated: 2026-02-07 for v1.3.0.b8 by @jgclark
+// Last updated: 2026-02-26 for v1.4.0.b4 by @jgclark
 //--------------------------------------------------------------------------------------
 /** 
  * This file is loaded by the browser via <script> tag in the HTML file
@@ -21,6 +21,21 @@ async function delay(time) {
   return new Promise(resolve => setTimeout(resolve, time))
 }
 
+/**
+ * Get current vertical scroll position in the window
+ * @returns {number}
+ */
+function getScrollPos() {
+  if (typeof window.pageYOffset !== 'undefined') {
+    return window.pageYOffset
+  } else if (document.documentElement && typeof document.documentElement.scrollTop !== 'undefined') {
+    return document.documentElement.scrollTop
+  } else if (document.body && typeof document.body.scrollTop !== 'undefined') {
+    return document.body.scrollTop
+  }
+  return 0
+}
+
 /**
  * Routes the data returned from the plugin (a 'type' and 'data' object).
  * This function is just a switch/router. Based on the type, call a function to process the data.
@@ -155,7 +170,7 @@ async function completeTaskInDisplay(data) {
     if (numItemsRemaining === 1 && doesIDExist(`${sectionID}-Filter`)) {
       // We need to un-hide the lower-priority items: do full refresh
       console.log(`We need to un-hide the lower-priority items: doing full refresh`)
-      sendMessageToPlugin('refresh', { itemID: '', type: '', filename: '', rawContent: '' }) // = actionName, data
+      sendMessageToPlugin('refresh', { itemID: '', type: '', filename: '', rawContent: '', scrollPos: getScrollPos() }) // = actionName, data
     }
 
     // See if we now have no remaining items at all
@@ -200,7 +215,7 @@ async function completeChecklistInDisplay(data) {
     if (numItemsRemaining === 1 && doesIDExist(`${sectionID}-Filter`)) {
       // We need to un-hide the lower-priority items: do full refresh
       console.log(`We need to un-hide the lower-priority items: doing full refresh`)
-      sendMessageToPlugin('refresh', { itemID: '', type: '', filename: '', rawContent: '' }) // = actionName, data
+      sendMessageToPlugin('refresh', { itemID: '', type: '', filename: '', rawContent: '', scrollPos: getScrollPos() }) // = actionName, data
     }
 
     // See if we now have no remaining items at all
@@ -243,7 +258,7 @@ async function cancelTaskInDisplay(data) {
   if (numItemsRemaining === 1 && doesIDExist(`${sectionID}-Filter`)) {
     // We need to un-hide the lower-priority items: do full refresh
     console.log(`We need to un-hide the lower-priority items: doing full refresh`)
-    sendMessageToPlugin('refresh', { itemID: '', type: '', filename: '', rawContent: '' }) // actionName, data
+    sendMessageToPlugin('refresh', { itemID: '', type: '', filename: '', rawContent: '', scrollPos: getScrollPos() }) // actionName, data
   }
 }
 
@@ -272,7 +287,7 @@ async function cancelChecklistInDisplay(data) {
   if (numItemsRemaining === 1 && doesIDExist(`${sectionID}-Filter`)) {
     // We need to un-hide the lower-priority items: do full refresh
     console.log(`We need to un-hide the lower-priority items: doing full refresh`)
-    sendMessageToPlugin('refresh', { itemID: '', type: '', filename: '', rawContent: '' }) // actionName, data
+    sendMessageToPlugin('refresh', { itemID: '', type: '', filename: '', rawContent: '', scrollPos: getScrollPos() }) // actionName, data
   }
 }
 
diff --git a/jgclark.Reviews/requiredFiles/projectList.css b/jgclark.Reviews/requiredFiles/projectList.css
index 063fcf7d2..a51a134bc 100644
--- a/jgclark.Reviews/requiredFiles/projectList.css
+++ b/jgclark.Reviews/requiredFiles/projectList.css
@@ -1,16 +1,37 @@
 /**
  * CSS specific to reviewList() from jgclark.Reviews plugin
- * Last updated 2026-02-16 for v1.3.0.b12, @jgclark 
+ * Last updated 2026-03-22 for v2.0.0.b12, @jgclark 
  */
 
+/* ============================================================
+   Scope: plugin tokens and page shell (most general)
+   ============================================================ */
+
 body {
-  margin: 0px;
+  /* Colours specific to this plugin */
 	--teamspace-color: #269F54;
-  --project-pause-color: #AAA;
   --project-no-percent-color: #BBB;
   --project-zero-progress-color: #9F2626;
+  --project-completed-color: var(--project-no-percent-color); /* just for the new border-left style */
+  --project-cancelled-color: var(--project-no-percent-color); /* just for the new border-left style */
+  --project-paused-color: #AAA;
+  --fg-dimmer-color: rgb(from var(--fg-main-color) r g b / 0.6);
+  --bg-lozenge-color: rgb(from var(--bg-main-color) r g b / 0.8);
+  --bg-hover-color: rgb(from var(--fg-main-color) r g b / 0.03);
+  --fg-due-color: hsla(45, 85%, 38%, 1); 
+  --fg-overdue-color: hsla(0, 85%, 38%, 1); 
+  --fg-soon-color: hsla(210, 85%, 38%, 1); 
+  --fg-not-due-color: hsla(120, 85%, 30%, 1); 
+  
+  /* Settings for overall body */
+  margin: 0px;
+  background-color: var(--bg-alt-color);
 }
 
+/* ============================================================
+   Base element defaults (type selectors)
+   ============================================================ */
+
 /* turn off special colouring and underlining for links -- turn on later when desired */
 p {
   margin-block-start: 0.5rem; margin-block-end: 0.5rem;
@@ -23,35 +44,152 @@ a, a:visited, a:active {
   color: inherit; text-decoration-line: none;
 }
 
-/* Remove margin-bottom and top from .h3 from last table */
-h3, .h3 {
-  margin-bottom: unset !important;
-  margin-top: unset !important;
+/* -------------------------------------------------------- */
+
+/* ============================================================
+   Form controls: shared base, then buttons, then specific widgets
+   ============================================================ */
+
+@layer A { /* all buttons, but at lower priority */
+	button, input {
+		color: var(--fg-main-color);
+		background-color: var(--bg-mid-color);
+		border: 1px solid rgb(from var(--fg-main-color) r g b / 0.3);
+		box-shadow: 1px 1px 1px 0px rgb(from var(--fg-main-color) r g b / 0.2);
+    border-radius: 4px;
+    font-weight: 500;
+
+    /* set backgrounds a little lighter on hover */
+    &:hover {
+      /* background-color: hsl(from var(--bg-sidebar-color) h s calc(l*1.4)); TODO(later): revert to this */
+      /* background-color: color(var(--bg-sidebar-color) lightness(40%)); */
+      filter: brightness(103%);
+    }
+	}
+}
+
+/* Show click pointer over buttons */
+button {
+	cursor: pointer;
+}
+
+/* For buttons that trigger callbacks */
+.PCButton {
+	color: var(--fg-main-color);
+	background-color: var(--bg-alt-color);
+  /* add a clearer border to all buttons */
+  border: 1px solid rgb(from var(--fg-main-color) r g b / 0.3);
+	box-shadow: 1px 1px 1px 0px rgb(from var(--fg-main-color) r g b / 0.2);
+  padding: 2px 4px 1px 4px;
+  margin: 3px 3px 3px 0px;
+	white-space: nowrap;
+	cursor: pointer;
+	font-size: 0.9rem;
+  border-radius: 6px;
+
+  /* color FA icons in these Buttons */
+  i {
+    color: var(--tint-color);
+  }
+
+  /* set backgrounds a little lighter on hover */
+  &:hover {
+    filter: brightness(103%);
+  }
+}
+
+.dialogTrigger {
+	color: rgb(from var(--fg-main-color) r g b / 0.5);
+	cursor: pointer;
+}
+
+/* ---- TOGGLES ------------------------------------------- */
+/* For fancy toggle as checkbox */
+/* from [Pure CSS3 iOS switch checkbox.](https://codeburst.io/pure-css3-input-as-the-ios-checkbox-8b6347d5cefb) */
+
+input.apple-switch {
+  appearance: none;
+  position: relative;
+	vertical-align: baseline;
+  outline: none;
+  width: 2rem;
+  height: 1.2rem;
+  background-color: var(--bg-apple-switch-color);
+  border: 1px solid rgb(from var(--fg-main-color) r g b / 0.3);
+  box-shadow: none;
+  border-radius: 1.9rem;
+  margin-top: 0px;
+	margin-right: 4px;
+	transition: background-color 0.2s;
 }
+input.apple-switch:after {
+  content: "";
+  vertical-align: top;
+  position: absolute;
+  top: 1px;
+  left: 1px;
+  background-color: var(--fg-apple-switch-color);
+  width: 0.95rem;
+  height: 0.95rem;
+  border-radius: 50%;
+	box-shadow: 0px 0px 2px var(--divider-color);
+  margin-right: 1rem;
+	transition: left 0.2s ease, background-color 0.2s ease;
+}
+input.apple-switch:checked {
+  vertical-align: top;
+  background-color: hsl(from var(--tint-color) h s l); /* Note: in dark mode making this a little darker looks good, but not in light mode */
+  transition: background-color 0.3s ease;
+}
+input.apple-switch:checked:after {
+  vertical-align: top;
+  left: 0.85rem;
+  background-color: var(--fg-apple-switch-color);
+  box-shadow: 0px 0px 2px var(--divider-color);
+}
+input, label {
+  vertical-align: top;
+}
+
+/* ============================================================
+   Layout: top bar
+   ============================================================ */
 
 /* TOPBAR ------------------------------------------------- */
 
-/* Keep a header stuck to top middle of window */
+/* Keep a header stuck to top; spread items evenly across the top bar */
 .topbar {
-  display: flex;
-  flex-wrap: wrap;
-  justify-content: space-between;
+  display: grid;
+  grid-template-columns: auto auto auto auto;
+  /* grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); */
   align-items: baseline;
+  column-gap: 0.5rem;
   position: sticky;
   top: 0px;
-  gap: 0.6rem;
   background-color: var(--bg-sidebar-color); 
   border-top: 1px solid var(--divider-color);
   border-bottom: 1px solid var(--divider-color);
-  line-height: 1.5rem; /* any less than this and the apple switches don't line up vertically properly */
-  padding: 0.4rem 0.8rem 0.3rem 0.8rem;
+  line-height: 1.2rem;
+  padding: 0.2rem 0.6rem 0.1rem 0.6rem;
   font-size: 0.95rem;
   z-index: 5;
-  row-gap: 0.3rem;
 
   .perspective-name {
     font-weight: 600;
   }
+
+  .no-perspective {
+    grid-template-columns: auto auto auto;
+  }
+
+}
+.topbar-center-cluster {
+  justify-self: center;
+}
+
+.topbar-right-cluster {
+  justify-self: end;
+  min-width: 0;
 }
 
 .topbar-item {
@@ -60,6 +198,20 @@ h3, .h3 {
   min-width: max-content;
 }
 
+.topbar-select {
+  color: var(--fg-main-color);
+  background-color: var(--bg-alt-color);
+  border: 1px solid rgb(from var(--fg-main-color) r g b / 0.3);
+  border-radius: 6px;
+  font-size: 0.9rem;
+  padding: 0.2rem 0.35rem;
+  font-family: inherit; /* inherit from body font */
+}
+
+/* ============================================================
+   Layout: display filters dropdown (top bar child)
+   ============================================================ */
+
 /* Display filters: button uses .PCButton (same as Refresh), dropdown below */
 .display-filters-wrapper {
   position: relative;
@@ -70,13 +222,19 @@ h3, .h3 {
   top: 100%;
   right: 0;
   margin-top: 0.2rem;
-  min-width: 16rem;
+  min-width: max-content;
   background: var(--bg-main-color, #eff1f5);
   border: 1px solid var(--divider-color, #CDCFD0);
   border-radius: 6px;
   box-shadow: 0 4px 12px rgba(0,0,0,0.15);
   z-index: 20;
 }
+/* Centred topbar trigger: panel hangs under the middle of the button */
+.topbar-center-cluster .display-filters-dropdown {
+  left: 50%;
+  right: auto;
+  transform: translateX(-50%);
+}
 .display-filters-dropdown.is-open {
   display: block;
 }
@@ -99,26 +257,84 @@ h3, .h3 {
   padding-bottom: 0.1rem;
 }
 
-/* MAIN GRID (replaces table) ----------------------------- */
+/* Tag toggles inside Filters dropdown; label left, count in parentheses, toggle right */
+.display-filters-tag-toggles {
+  margin-bottom: 0.2rem;
+}
+.display-filters-option--tag-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 0.6rem;
+}
+.display-filters-option--tag-row .display-filters-option-text {
+  flex: 1 1 auto;
+}
+.display-filters-option-count {
+  color: var(--fg-dimmer-color);
+}
+.display-filters-option--tag-row .apple-switch {
+  flex: 0 0 auto;
+}
+.display-filters-divider {
+  border: 0;
+  border-top: 1px solid var(--fg-divider, var(--divider-color, #CDCFD0));
+  margin: 0.4rem 0;
+}
 
-.project-list-grid {
-  display: grid;
+.display-filters-order-row {
+  justify-self: stretch;
   width: 100%;
-  margin-bottom: 0rem;
+  display: flex;
+  align-items: center;
+  justify-content: end;
+  gap: 0.5rem;
+  padding-top: 0.15rem;
+}
+.display-filters-order-label {
+  font-weight: normal;
+  cursor: default;
+}
+.display-filters-order-select {
+  flex: 0 1 auto;
+  min-width: 7.5rem;
 }
 
-/* First column fixed width (same as original col 1); middle column flexes; last column fits content like a table */
-.project-list-grid--with-dates {
-  grid-template-columns: 3.2rem 2fr 1fr;
+/* ============================================================
+   Layout: project list grid and columns wrapper
+   ============================================================ */
+
+/* MAIN GRID (replaces table) ----------------------------- */
+
+.project-list-grid {
+  /* display: grid; */ /* Needs to be turned off to get multi-columns to flow properly */
+  background-color: var(--bg-alt-color);
+  width: 100%;
+  /* margin-bottom: 0rem; */
 }
 
-.project-list-grid--no-dates {
-  grid-template-columns: 3.2rem 1fr;
+/* allow multi-column flow: set max columns and min width, and some other bits and pieces */
+.multi-cols {
+  column-count: 3; 
+  column-width: 25rem;
+  column-gap: 0.3rem;
+  column-rule: 1px dotted rgb(from var(--tint-color) r g b / 0.5);
 }
 
 /* Rows: children participate in parent grid */
 .project-grid-row {
-  display: contents;
+  display: block;
+  background-color: var(--bg-mid-color);
+  border: 1px solid var(--divider-color);
+  border-radius: 6px;
+  margin: 0.2rem 0.5rem;
+  padding: 0.2rem;
+  /* apply to a <div> to avoid column break part-way through item */
+	break-inside: avoid;
+
+  &:hover {
+    background-color: var(--bg-hover-color);
+  }
 }
 
 .project-grid-cell {
@@ -128,18 +344,15 @@ h3, .h3 {
   padding-right: 0.4rem;
   font-size: 0.92rem;
   line-height: 1.35rem;
-  padding: 3px 0.2rem 2px 0.2rem;
+  padding: 5px 0.1rem 5px 0.1rem;
   border-left: none;
   border-right: none;
 }
 
-.project-grid-cell:first-child {
-  padding-left: 0.5rem;
-}
-
-/* Middle column: allow shrink so ellipsis can apply */
-.project-grid-cell--content {
-  overflow: hidden;
+.project-metadata-row {
+  padding-left: 2rem;
+  box-sizing: border-box;
+  width: 100%;
   min-width: 0;
 }
 
@@ -147,20 +360,6 @@ h3, .h3 {
   grid-column: span 2;
 }
 
-/* Legacy td/th kept for any remaining references; grid uses .project-grid-cell */
-td, th {
-  font-weight: normal;
-  text-align: left;
-  padding-right: 0.4rem;
-  font-size: 0.92rem;
-}
-th {
-  padding-bottom: 0.2rem;
-}
-td:first-child, th:first-child {
-  padding-left: 0.5rem;
-}
-
 /* Display of Number of projects */
 .folder-header-text {
   font-size: 1.0rem;
@@ -169,383 +368,214 @@ td:first-child, th:first-child {
   margin-left: 1rem;
 }
 
-details {
-  overflow: hidden;
-}
-
-/* Remove default disclosure triangle */
-details > summary {
-  list-style: none;
-}
-details > summary::-webkit-details-marker {
-  display: none;
-}
-
-/* Style for the container */
-details > .content {
-  display: grid;
-  grid-template-rows: 0fr;
-  transition: grid-template-rows 800ms ease-out;
-}
-
-/* Rotate triangle when open */
-details[open] > summary::after {
-  transform: rotate(90deg);
-}
-
-/* FIXME: Create inner wrapper for smooth animation */
-details > .details-content > * {
-  overflow: hidden;
-}
-
-/* When open, set full height */
-details[open] > .content {
-  grid-template-rows: 1fr;
-}
-
-/* Style the <summmary> tag in the <details> section */
-summary {
-  /* Create custom triangle with pseudo-element */
-  display: flex;
-  justify-content: start;
-  align-items: end;
-  cursor: pointer;
-  padding: 8px;
-  column-gap: 0.4rem;
-}
-  /* Animate the triangle icon */
-  /* summary i {
-    transition: transform 0.2s ease;
-  }
-  details[open] summary i {
-    transform: rotate(180deg);
-  }
-  summary::-webkit-details-marker {
-    display: none;
-  }
-  */
-
-/* Animate the triangle icon */
-summary::after {
-  content: '▶';
-  transition: all 0.2s ease;
-}
-
-.project-grid-row.projectRow .project-grid-cell--content {
-  min-height: calc(var(--body-line-height, 1.35rem) * 2.5);
-}
-.project-grid-row.folder-header-row .project-grid-cell {
-  vertical-align: bottom;
-  background-color: var(--bg-main-color);
-  border-top: none;
-  border-bottom: 0.6px solid var(--tint-color);
-  padding-bottom: 0.2rem;
-}
-
 .folder-header {
-  color: var(--tint-color);
-  padding-top: 0.7rem;
-  padding-left: 0.5rem;
-  padding-right: 0.3rem;
-}
-
-.folder-name {
-  font-size: 1.0rem;
-  font-weight: bold;
-  padding-top: 0.8rem;
-  padding-right: 1.0rem;
-}
-
-/* column 1 space for icon and progress ring */
-.first-col-indicator {
-  padding: 0.5em 0.6rem 0.2rem 0.4rem; /* works but I don't understand why */
-  /* text-align: center; */
-  justify-self: center;
-  align-content: center; /* was baseline; */
-}
-
-/* Row borders: first row (folder header) top border; last row bottom border */
-.project-list-grid > .project-grid-row:first-child .project-grid-cell {
-  border-top: 1px solid rgb(from var(--tint-color) r g b / 0.3);
-}
-.project-list-grid > .project-grid-row:last-child .project-grid-cell {
-  border-bottom: 1px solid rgb(from var(--tint-color) r g b / 0.3);
-}
-/* Project row backgrounds (folder header is first child, so project rows are 2nd, 3rd, ...) */
-.project-list-grid > .project-grid-row.projectRow .project-grid-cell {
-  background-color: var(--bg-mid-color);
-}
-.project-list-grid > .project-grid-row.projectRow:nth-child(even) .project-grid-cell {
-  background-color: var(--bg-alt-color);
-}
-
-/* row class to indicate that a project is being reviewed */
-.project-grid-row.projectRow.reviewing .project-grid-cell {
-  background-color: rgba(from var(--tint-color) r g b / 0.15);
-  border: 1px solid rgba(from var(--tint-color) r g b / 1);
+  color: var(--h3-color);
+  font-size: 1.2rem !important; /* TODO: find better method for these than !important */
+  background-color: unset !important;
+  padding: 0.9rem 0.3rem 0.1rem 1.3rem;
 }
 
-/* in-line label to indicate that a project is being reviewed */
-/* Note: Not being used currently: using following instead */
-/* .underReviewLabel {
-  color: var(--bg-main-color);
-  background-color: var(--tint-color);
-  padding: 2px 4px 0px;
-  border-radius: 8px;
-  font-weight: 600;
-} */
-
-/* in-line label to indicate that a project is being reviewed */
-.underReviewText {
-  color: var(--tint-color);
-  font-weight: 600;
-}
-
-/* allow multi-column flow: set max columns and min width, and some other bits and pieces */
-.multi-cols {
-  column-count: 3; 
-  column-width: 30rem;
-  column-gap: 1rem;
-  column-rule: 1px dotted rgb(from var(--tint-color) r g b / 0.5);
-}
-
-/* -------------------------------------------------------- */
-
-/* 
- * Darken background under any dialogs when open (See https://developer.mozilla.org/en-US/docs/Web/CSS/::backdrop)
- */
-dialog::backdrop {
-  background: rgba(0,0,0,0.5);
-}
-
-/* Project control dialog: fit content width, cap at 80% of window */
-.projectControlDialog {
-  width: max-content;
-  max-width: 80vw;
-}
-
-.dialogProjectFolder {
-  color: var(--fg-placeholder-color);
-}
-
-.dialogProjectNoteLink {
-  cursor: pointer;
-  color: var(--tint-color);
+.folder-header-row {
+  vertical-align: bottom;
+  border: none;
+  background-color: unset;
 }
 
 /* -------------------------------------------------------- */
-@layer A { /* all buttons, but at lower priority */
-	button, input {
-		color: var(--fg-main-color);
-		background-color: var(--bg-mid-color);
-		border: 1px solid rgb(from var(--fg-main-color) r g b / 0.3);
-		box-shadow: 1px 1px 1px 0px rgb(from var(--fg-main-color) r g b / 0.2);
-    border-radius: 4px;
-    font-weight: 500;
-
-    /* set backgrounds a little lighter on hover */
-    &:hover {
-      /* background-color: hsl(from var(--bg-sidebar-color) h s calc(l*1.4)); TODO(later): revert to this */
-      /* background-color: color(var(--bg-sidebar-color) lightness(40%)); */
-      filter: brightness(103%);
-    }
-	}
-}
-
-/* Show click pointer over buttons and equivalent */
-button, .fake-button, .clickTarget {
-	cursor: pointer;
-}
-
-/* For buttons that trigger callbacks */
-.PCButton {
-	color: var(--fg-main-color);
-	background-color: var(--bg-alt-color);
-  /* add a clearer border to all buttons */
-  border: 1px solid rgb(from var(--fg-main-color) r g b / 0.3);
-	box-shadow: 1px 1px 1px 0px rgb(from var(--fg-main-color) r g b / 0.2);
-	padding: 2px 3px 0px 3px;
-	margin: 2px 4px 0px 0px;
-	white-space: nowrap;
-	cursor: pointer;
-	font-size: 0.9rem;
 
-  /* color FA icons in these Buttons */
-  i {
-    color: var(--tint-color);
-  }
+/* ============================================================
+   Project row content (title line, tags, status colours, sub-rows)
+   ============================================================ */
 
-  /* set backgrounds a little lighter on hover */
-  &:hover {
-    filter: brightness(103%);
-  }
-}
-
-.dialogTrigger {
-	color: rgb(from var(--fg-main-color) r g b / 0.5);
-	cursor: pointer;
-}
-
-/* a top-right fixed position */
-.fix-top-right {
-  position: absolute; top: 1.7rem; right: 1rem;
-}
-
-/* -------------------------------------------------------- */
 /* first line of column 2 */
-.projectTitle {
+.projectMainDetailsRow {
   display: flex;
   align-items: center;
   gap: 0.5rem;
+  line-height: 1.8rem;
 }
 /* make noteTitle icon + text bold and coloured, and indicate its clickable */
 .noteTitle {
   cursor: pointer;
   display: flex;
   align-items: baseline;
-  gap: 0.5rem;
+  /* gap: 0.5rem; */
   color: var(--tint-color);
-  font-size: 1.03rem; /* TODO: Gradually reduce from 1.1rem to 1.0rem? */
+  font-size: 1.05rem;
   font-weight: 500;
   text-decoration: none;
 }
-.noteTitle:hover {
-  /* placeholder */
-}
+
 .noteTitleIcon {
-  /* placeholder */
+  width: 2rem;
+  flex-shrink: 0;
+  /* display: inline-flex; */
+  display: flex;
+  justify-content: center;
+  align-items: baseline;
 }
 /* make noteTitle links underlined on mouse hover */
 .noteTitleText:hover {
   text-decoration: underline;
 }
 
-/* ---- ITEM COUNTS ----------------------------------------- */
-
-/* Task count badge (rounded square) */
-.openItemCount {
-  display: inline-flex;
-  justify-content: center;
-  /* min-width: 0.9rem; */
-  height: 0.85rem;
-  padding: 0.1rem 0.2rem 0rem 0.2rem;
-  border-radius: 6px;
-  color: rgb(from var(--fg-main-color) r g b / 0.7);
-  border: 1px solid rgb(from var(--fg-main-color) r g b / 0.4);
-  font-size: 0.7rem;
-  /* font-weight: 400; */
-  line-height: 0.8rem;
-  margin-left: 0.1rem;
+/* Stop any strikeout on completed/cancelled projects (which may come in from a theme) */
+.checked, .cancelled {
+  text-decoration: none !important;
 }
 
-/* Column 3: metadata (status lozenges + project tags). Status lozenges – colour from getIntervalReviewStatus / getIntervalDueStatus */
-.project-metadata-cell {
+/* Project tags in column 2 (after title / edit) */
+.projectTagsInline {
+  margin-left: 0.2rem;
+  gap: 0.2rem;
   display: flex;
-  align-items: center;
+  flex-direction: row;
+  flex-shrink: 1;
   flex-wrap: wrap;
-  overflow: auto;
-  white-space: nowrap;
-  gap: 0.15rem 0.3rem;
+  align-items: center;
+  align-content: center;
 }
+
+/* ---- METADATA LOZENGES ---------------------------------- */
+
+/* Project tag pills and review/due colour classes from getIntervalReviewStatus / getIntervalDueStatus */
 .metadata-lozenge {
   display: inline-block;
-  padding: 0.15rem 0.4rem;
-  border-radius: 6px;
+  color: rgb(from var(--fg-main-color) r g b / 0.5);
   font-size: 0.85rem;
   line-height: 1.1;
-  margin-right: 0.25rem;
+  border: 1px solid rgb(from var(--fg-main-color) r g b / 0.2);
+  padding: 0.15rem 0.4rem;
+  border-radius: 6px;
+  margin-right: 0.1rem;
   text-wrap: nowrap;
 }
-.metadata-lozenge--tag {
-  color: var(--fg-placeholder-color);
-  background-color: var(--bg-alt-color);
-  border: 1px solid rgb(from var(--fg-main-color) r g b / 0.2);
+.lozenge-general {
+  color: var(--fg-dimmer-color);
+  background-color: var(--bg-lozenge-color, #FFFFFFBB);
+  filter: saturate(80%);
 }
-.metadata-lozenge--red {
-  color: #b85450;
-  /* border: 1px solid #b85450; */
-  background-color:  #b8545010
+/* Note: color-mix() works, but needs to start with 'in oklch' in NP at March 2026 on macOS 15.7. */
+.overdue {
+  color: var(--fg-overdue-color);
 }
-.metadata-lozenge--orange {
-  color: #c76a40;
-  /* border: 1px solid #c76a00; */
-  background-color:  #c76a4010
+.due {
+  color: var(--fg-due-color);
 }
-.metadata-lozenge--blue {
-  color: #1e66f5;
-  /* border: 1px solid #1e66f5; */
-  background-color:  #1e66f510
+.soon {
+  color: var(--fg-soon-color);
 }
-.metadata-lozenge--green {
-  color: #269F54;
-  /* border: 1px solid #269F54; */
-  background-color:  #269F5410
+.not-due {
+  color: var(--fg-not-due-color);
 }
 
 /* ---- PROGRESS ----------------------------------------- */
 /* Progress line row: last progress comment, styled like Dashboard ProjectItem */
-.projectProgress {
-  color: var(--fg-placeholder-color);
+.projectProgressRow {
+  color: var(--fg-dimmer-color);
   font-size: 0.95rem;
-  display: block;
+  display: flex;
+  align-items: baseline;
   min-width: 0;
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
 }
-.progress {
+.progressText {
+  /* flex: 1; */
+  min-width: 0;
+  overflow: hidden;
+  /* FIXME: ellipsis isn't working any more */
+  text-overflow: ellipsis;
+}
+
+/* ---- FOLDER PATH ----------------------------------------- */
+/* Grouped-by-folder: folder path line under project title (column 2) */
+.projectFolderRow {
+  font-size: 0.92rem;
+  color: var(--fg-dimmer-color);
   display: flex;
   align-items: baseline;
-  gap: 0.5rem;
+  min-width: 0;
 }
-.progressIcon {
-  color: var(--tint-color);
+.projectFolderIcon {
+  width: 0.95rem;
+  flex-shrink: 0;
 }
-.progressText {
-  /* placeholder */
+.projectFolderText {
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
 /* ---- NEXT ACTIONS ----------------------------------------- */
-.nextAction {
-  display: block;
+.nextActionRow {
+  display: flex;
   align-items: baseline;
-  gap: 0.5rem;
-  overflow: hidden;
+  gap: 0;
+  /* overflow: hidden;
   white-space: nowrap;
-  text-overflow: ellipsis;
-}
-.nextActionIcon {
-  /* placeholder */
+  text-overflow: ellipsis; */
+  color: var(--fg-alt-color);
 }
 .nextActionText {
-  /* placeholder */
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
 }
 
-/* ---- ICONS etc. ----------------------------------------- */
-/* set all FA icons to default to tint color */
-i.fa-solid, i.fa-regular {
-  /* color: var(--tint-color); */
+/* ============================================================
+   State modifiers and compound selectors (more specific)
+   ============================================================ */
+
+/* row class to indicate that a project is being reviewed */
+.project-grid-row.projectRow.reviewing .project-grid-cell {
+  background-color: rgba(from var(--tint-color) r g b / 0.15);
+  border: 1px solid rgba(from var(--tint-color) r g b / 1);
 }
-/* make checkbox display larger, and like in the app. */
-.checkbox {
-  font-family: "noteplanstate"; font-size: 1.4rem;
+
+/* in-line label to indicate that a project is being reviewed */
+.underReviewText {
+  color: var(--tint-color);
+  font-weight: 600;
 }
-/* Set size of percent-display rings in first col */
-.percent-ring {
-  width: 2rem; height: 2rem;
+
+/* ============================================================
+   Dialog
+   ============================================================ */
+
+/* -------------------------------------------------------- */
+
+/* 
+ * Darken background under any dialogs when open (See https://developer.mozilla.org/en-US/docs/Web/CSS/::backdrop)
+ */
+dialog::backdrop {
+  background: rgba(0,0,0,0.5);
 }
-/* details of ring-circle that can be set in CSS */
-.percent-ring-circle {
-  transition: 0.5s stroke-dashoffset; transform: rotate(-90deg); transform-origin: 50% 50%;
+
+/* Project control dialog: fit content width, cap at 80% of window */
+.projectControlDialog {
+  width: max-content;
+  max-width: 80vw;
 }
-/* details of ring text that can be set in CSS */
-.circle-percent-text {
-  font-family: "Avenir Next"; font-size: 2.4rem; font-weight: 500; color: var(--fg-main-color);
+
+.dialogProjectFolder {
+  color: var(--fg-dimmer-color);
 }
-/* set size of FA icon used in place of percent-display rings in first col */
-.circle-icon {
-  font-size: 1.8rem;
+
+.dialogProjectNoteLink {
+  cursor: pointer;
+  color: var(--tint-color);
 }
+
+/* ============================================================
+   Utilities (single-purpose, broadly reusable)
+   ============================================================ */
+
+/* ---- ICONS etc. ----------------------------------------- */
 /* add space before/after items (particularly icons) */
 .pad-left {
 	padding-left: 0.3rem;
@@ -556,14 +586,17 @@ i.fa-solid, i.fa-regular {
 .pad-right {
 	padding-right: 0.2rem;
 }
-
 /* add more space before items */
-.pad-left-more {
-	margin-left: 0.5rem;
+.pad-right-larger {
+	padding-right: 0.5rem;
 }
 
-/* ---- TOOLTIPs ------------------------------------------- */
+/* ============================================================
+   Optional / reserved (least critical for current UI)
+   ============================================================ */
 
+/* ---- TOOLTIPs ------------------------------------------- */
+/* Note: Currently unused, but keeping here in case we want this in future. */
 /* Add tooltips to these buttons, thanks to CSS-only technique at https://www.youtube.com/watch?v=M4lQwiUvGlY&t=157s */
 .tooltip {
 	position: relative;
@@ -616,70 +649,3 @@ i.fa-solid, i.fa-regular {
 .tooltip:hover .tooltiptext {
   visibility: visible;
 }
-
-/* ---- TOGGLES ------------------------------------------- */
-/* For fancy toggle as checkbox */
-/* from [Pure CSS3 iOS switch checkbox.](https://codeburst.io/pure-css3-input-as-the-ios-checkbox-8b6347d5cefb) */
-
-input.apple-switch {
-  appearance: none;
-  position: relative;
-	vertical-align: baseline;
-  outline: none;
-  width: 2rem;
-  height: 1.2rem;
-  background-color: var(--bg-apple-switch-color);
-  border: 1px solid rgb(from var(--fg-main-color) r g b / 0.3);
-  box-shadow: none;
-  border-radius: 1.9rem;
-  margin-top: 0px;
-	margin-right: 4px;
-	transition: background-color 0.2s;
-}
-input.apple-switch:after {
-  content: "";
-  vertical-align: top;
-  position: absolute;
-  top: 1px;
-  left: 1px;
-  background-color: var(--fg-apple-switch-color);
-  width: 0.95rem;
-  height: 0.95rem;
-  border-radius: 50%;
-	box-shadow: 0px 0px 2px var(--divider-color);
-  margin-right: 1rem;
-	transition: left 0.2s ease, background-color 0.2s ease;
-}
-input.apple-switch:checked {
-  vertical-align: top;
-  background-color: hsl(from var(--tint-color) h s l); /* Note: in dark mode making this a little darker looks good, but not in light mode */
-  transition: background-color 0.3s ease;
-}
-input.apple-switch:checked:after {
-  vertical-align: top;
-  left: 0.85rem;
-  background-color: var(--fg-apple-switch-color);
-  box-shadow: 0px 0px 2px var(--divider-color);
-}
-input, label {
-  vertical-align: top;
-}
-
-ul {
-  display: flex;
-  padding: 0.4rem;
-  margin: 0rem;
-}
-li {
-  break-inside: avoid;
-  display: block;
-  padding-inline: 0.4rem;
-}
-
-.fake-checkbox {
-  font-size: 1.0rem;
-  }
-.fake-checkbox::before {
-  content: '●';
-}
-
diff --git a/jgclark.Reviews/requiredFiles/projectListDialog.css b/jgclark.Reviews/requiredFiles/projectListDialog.css
index 7f38bf0d2..170b543cb 100644
--- a/jgclark.Reviews/requiredFiles/projectListDialog.css
+++ b/jgclark.Reviews/requiredFiles/projectListDialog.css
@@ -89,7 +89,7 @@ dialog:modal {
 .dialogLatestProgressLabel {
   font-weight: normal;
   text-align: left;
-  color: var(--fg-placeholder-color);
+  color: var(--fg-dimmer-color);
 }
 
 .dialogLatestProgressText {
diff --git a/jgclark.Reviews/src/__tests__/projectClass.frontmatterParsing.test.js b/jgclark.Reviews/src/__tests__/projectClass.frontmatterParsing.test.js
new file mode 100644
index 000000000..4699fcaf6
--- /dev/null
+++ b/jgclark.Reviews/src/__tests__/projectClass.frontmatterParsing.test.js
@@ -0,0 +1,52 @@
+// @flow
+/* globals describe, expect, test */
+
+import { parseProjectFrontmatterValue, readRawFrontmatterField } from '../projectClass'
+
+type MockParagraph = {
+  content: string,
+}
+
+type MockNote = {
+  paragraphs: Array<MockParagraph>,
+}
+
+function makeNote(frontmatterLines: Array<string>): MockNote {
+  const paragraphs = frontmatterLines.map((line) => ({ content: line }))
+  return { paragraphs }
+}
+
+describe('projectClass frontmatter parsing helpers', () => {
+  describe('readRawFrontmatterField', () => {
+    test('distinguishes missing key from present key with empty value', () => {
+      const note = makeNote([
+        '---',
+        'title: Example',
+        'due:',
+        'review: @review()',
+        '---',
+        '# Note title',
+      ])
+
+      const missing = readRawFrontmatterField((note: any), 'start')
+      const presentEmpty = readRawFrontmatterField((note: any), 'due')
+      const presentInvalid = readRawFrontmatterField((note: any), 'review')
+
+      expect(missing).toEqual({ exists: false, value: undefined })
+      expect(presentEmpty).toEqual({ exists: true, value: '' })
+      expect(presentInvalid).toEqual({ exists: true, value: '@review()' })
+    })
+  })
+
+  describe('parseProjectFrontmatterValue', () => {
+    test('returns empty string for malformed empty mention values', () => {
+      expect(parseProjectFrontmatterValue('@due()')).toBe('')
+      expect(parseProjectFrontmatterValue('@review()')).toBe('')
+    })
+
+    test('returns unwrapped mention content for valid values', () => {
+      expect(parseProjectFrontmatterValue('@due(2026-03-26)')).toBe('2026-03-26')
+      expect(parseProjectFrontmatterValue('@review(2w)')).toBe('2w')
+    })
+  })
+})
diff --git a/jgclark.Reviews/src/allProjectsListHelpers.js b/jgclark.Reviews/src/allProjectsListHelpers.js
index facadaf50..52703e92a 100644
--- a/jgclark.Reviews/src/allProjectsListHelpers.js
+++ b/jgclark.Reviews/src/allProjectsListHelpers.js
@@ -4,7 +4,7 @@
 //-----------------------------------------------------------------------------
 // Supporting functions that deal with the allProjects list.
 // by @jgclark
-// Last updated 2026-02-20 for v1.3.0.b12, @jgclark
+// Last updated 2026-02-23 for v1.4.0.b2, @jgclark
 //-----------------------------------------------------------------------------
 
 import moment from 'moment/min/moment-with-locales'
@@ -25,6 +25,9 @@ import { smartPrependPara } from '@helpers/paragraph'
 // Settings
 const pluginID = 'jgclark.Reviews'
 const allProjectsListFilename = `../${pluginID}/allProjectsList.json` // fully specified to ensure that it saves in the Reviews directory (which wasn't the case when called from Dashboard)
+const allProjectsDemoListDefaultFilename = `../${pluginID}/allProjectsDemoListDefault.json`
+// Backwards-compatible alias for existing demo-list helper
+const allProjectsDemoListFilename = allProjectsDemoListDefaultFilename
 const maxAgeAllProjectsListInHours = 1
 const generatedDatePrefName = 'Reviews-lastAllProjectsGenerationTime'
 const MS_PER_HOUR = 1000 * 60 * 60
@@ -101,7 +104,8 @@ function findReadyProjects(projects: Array<Project>, maxCount: number = 0): Arra
 }
 
 /**
- * Build sorting specification array based on config
+ * Build sorting specification array based on config: projectTagOrder > folder > [dueDays | nextReviewDays | title],
+ * but with 'active' before 'finished' sorting applied as well, unless displayOrder is 'title'.
  * @param {ReviewConfig} config - Review configuration
  * @returns {Array<string>} Array of field names to sort by
  * @private
@@ -114,16 +118,18 @@ function buildSortingSpecification(
   if (config.displayGroupedByFolder) {
     sortingSpec.push('folder')
   }
-  sortingSpec.push('isCancelled', 'isCompleted', 'isPaused') // i.e. 'active' before 'finished'
 
   switch (config.displayOrder) {
     case 'review':
-      sortingSpec.push('nextReviewDays')
+      // sorting 'active' before 'finished'
+      sortingSpec.push('isCancelled', 'isCompleted', 'isPaused', 'nextReviewDays')
       break
     case 'due':
-      sortingSpec.push('dueDays')
+      // sorting 'active' before 'finished'
+      sortingSpec.push('isCancelled', 'isCompleted', 'isPaused', 'dueDays')
       break
     case 'title':
+      // Note: *not* sorting 'active' before 'finished'
       sortingSpec.push('title')
       break
   }
@@ -140,7 +146,7 @@ function stringifyProjectObjects(objArray: Array<any>): string {
    * Also normalizes any existing date strings to YYYY-MM-DD format
    * @returns {any}
    */
-  const dateFieldNames = ['startDate', 'dueDate', 'reviewedDate', 'completedDate', 'cancelledDate']
+  const dateFieldNames = ['startDate', 'dueDate', 'reviewedDate', 'completedDate', 'cancelledDate', 'nextReviewDateStr']
   const RE_ISO_DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/
   
   function stringifyReplacer(key: string, value: any) {
@@ -278,20 +284,28 @@ export async function logAllProjectsList(): Promise<void> {
 
 /**
  * Return as Project instances all projects that match config items 'foldersToInclude', 'foldersToIgnore', and 'projectTypeTags'.
+ * Note: These may be taken from the Perspective settings before being passed to this function.
  * @author @jgclark
- * @param {any} configIn
- * @param {boolean} runInForeground?
+ * @param {ReviewConfig} configIn
+ * @param {boolean} runInForeground? (default: false)
  * @returns {Array<Project>}
  */
-async function getAllMatchingProjects(configIn: any, runInForeground: boolean = false): Promise<Array<Project>> {
-  const config = configIn ? configIn : await getReviewSettings() // get config from passed config if possible
+async function getAllMatchingProjects(
+  configIn: ReviewConfig,
+  runInForeground: boolean = false,
+): Promise<Array<Project>> {
+  // get config from passed config if possible
+  const config = configIn ? configIn : await getReviewSettings()
   if (!config) throw new Error('No config found. Stopping.')
 
   logInfo('getAllMatchingProjects', `Starting for tags [${String(config.projectTypeTags)}], running in ${runInForeground ? 'foreground' : 'background'}`)
-  const startTime = moment().toDate() // use moment instead of  `new Date` to ensure we get a date in the local timezone
+  // logDebug('getAllMatchingProjects', `- foldersToInclude: [${String(config.foldersToInclude)}]`)
+  // logDebug('getAllMatchingProjects', `- foldersToIgnore: [${String(config.foldersToIgnore)}]`)
+
+  const startTime = moment().toDate() // use moment to ensure we get a date in the local timezone
 
   // Get list of folders, excluding @specials and our foldersToInclude or foldersToIgnore settings -- include takes priority over ignore.
-  const filteredFolderList = (config.foldersToInclude.length > 0)
+  const filteredFolderList = (config.foldersToInclude?.length ?? 0 > 0)
     ? getFoldersMatching(config.foldersToInclude, true).sort()
     : getFolderListMinusExclusions(config.foldersToIgnore, true, false).sort()
 
@@ -305,7 +319,7 @@ async function getAllMatchingProjects(configIn: any, runInForeground: boolean =
     if (!exists) acc.push(f)
     return acc
   }, [])
-  // logDebug('getAllMatchingProjects', `- filteredFolderListWithoutSubdirs: ${String(filteredFolderListWithoutSubdirs)}`)
+  logInfo('getAllMatchingProjects', `-> ${String(filteredFolderListWithoutSubdirs.length)} filteredFolderListWithoutSubdirs: ${String(filteredFolderListWithoutSubdirs)}`)
 
   // Filter the list of project notes from the DataStore.
   let filteredProjectNotes = filterProjectNotesByFolders(
@@ -347,7 +361,7 @@ async function getAllMatchingProjects(configIn: any, runInForeground: boolean =
           await CommandBar.onAsyncThread()
         }
         for (const n of projectNotesArr) {
-          const np = new Project(n, tag, true, config.nextActionTags, config.sequentialTag ?? SEQUENTIAL_TAG_DEFAULT)
+          const np = new Project(n, tag, true, config.nextActionTags, config.sequentialTag ? config.sequentialTag : SEQUENTIAL_TAG_DEFAULT)
           projectInstances.push(np)
         }
         if (!runInForeground) {
@@ -413,7 +427,7 @@ export async function writeAllProjectsList(projectInstances: Array<Project>): Pr
     if (res) {
       const reviewListDate = Date.now()
       DataStore.setPreference(generatedDatePrefName, reviewListDate)
-      logInfo('writeAllProjectsList', `- done at ${String(reviewListDate)}`)
+      logDebug('writeAllProjectsList', `- done at ${String(reviewListDate)}`)
       await updateDashboardIfOpen()
     } else {
       throw new Error(`Error writing JSON to '${allProjectsListFilename}'`)
@@ -423,6 +437,35 @@ export async function writeAllProjectsList(projectInstances: Array<Project>): Pr
   }
 }
 
+/**
+ * Copy the fixed demo default list JSON into the live allProjects list file.
+ * Does not touch any live project notes.
+ * @returns {Promise<boolean>} true if copy succeeded, false otherwise
+ */
+export async function copyDemoDefaultToAllProjectsList(): Promise<boolean> {
+  try {
+    if (!DataStore.fileExists(allProjectsDemoListDefaultFilename)) {
+      throw new Error(`Demo default file not found: ${allProjectsDemoListDefaultFilename}`)
+    }
+    const content = DataStore.loadData(allProjectsDemoListDefaultFilename, true)
+    if (content == null) {
+      throw new Error(`Couldn't read demo default file: ${allProjectsDemoListDefaultFilename}`)
+    }
+    const res = DataStore.saveData(String(content), allProjectsListFilename, true)
+    if (!res) {
+      throw new Error(`Couldn't write to ${allProjectsListFilename}`)
+    }
+    const now = Date.now()
+    DataStore.setPreference(generatedDatePrefName, now)
+    logInfo('copyDemoDefaultToAllProjectsList', `Copied demo list to ${allProjectsListFilename} at ${String(now)}`)
+    await updateDashboardIfOpen()
+    return true
+  } catch (error) {
+    logError('copyDemoDefaultToAllProjectsList', error.message)
+    return false
+  }
+}
+
 /**
  * Update the Project object in allProjects list with matching filename
  * @author @jgclark
@@ -452,7 +495,8 @@ export async function updateProjectInAllProjectsList(projectToUpdate: Project):
 
 /**
  * Get all Project object instances from JSON list of all available project notes. Doesn't come ordered.
- * First checks to see how old the list is, and re-generates more than 'maxAgeAllProjectsListInHours' hours old.
+ * If in demo mode, just load from the allProjectsList, which should be the demo list. Don't worry about its age.
+ * If not demo mode, then first check to see how old the list is, and re-generates more than 'maxAgeAllProjectsListInHours' hours old.
  * @author @jgclark
  * @returns {Promise<Array<Project>>} allProjects Object, the same as what is written to disk
  */
@@ -462,31 +506,38 @@ export async function getAllProjectsFromList(): Promise<Array<Project>> {
     const startTime = moment().toDate()
     let projectInstances: Array<Project>
 
-    // Check if file exists and is fresh enough
-    if (shouldRegenerateAllProjectsList()) {
-      if (DataStore.fileExists(allProjectsListFilename)) {
+    // Demo mode: never regenerate from live notes; ensure JSON exists (from demo default), then read it
+    const config = await getReviewSettings()
+    if (config.useDemoData ?? false) {
+      const content = DataStore.loadData(allProjectsListFilename, true) ?? `${ERROR_READING_PLACEHOLDER} ${allProjectsListFilename}>`
+      projectInstances = JSON.parse(content)
+    } else {
+      // Check if file exists and is fresh enough
+      if (shouldRegenerateAllProjectsList()) {
+        if (DataStore.fileExists(allProjectsListFilename)) {
+          const fileAgeMs = getFileAgeMs(generatedDatePrefName)
+          const fileAgeHours = (fileAgeMs / MS_PER_HOUR).toFixed(2)
+          logDebug('getAllProjectsFromList', `- Regenerating allProjects list as more than ${String(maxAgeAllProjectsListInHours)} hours old (currently ${fileAgeHours} hours)`)
+        } else {
+          logDebug('getAllProjectsFromList', `- Generating allProjects list as can't find it`)
+        }
+        projectInstances = await generateAllProjectsList()
+      } else {
+        // Read from the list
         const fileAgeMs = getFileAgeMs(generatedDatePrefName)
         const fileAgeHours = (fileAgeMs / MS_PER_HOUR).toFixed(2)
-        logDebug('getAllProjectsFromList', `- Regenerating allProjects list as more than ${String(maxAgeAllProjectsListInHours)} hours old (currently ${fileAgeHours} hours)`)
-      } else {
-        logDebug('getAllProjectsFromList', `- Generating allProjects list as can't find it`)
+        logDebug('getAllProjectsFromList', `- Reading from current allProjectsList (as only ${fileAgeHours} hours old)`)
+        const content = DataStore.loadData(allProjectsListFilename, true) ?? `${ERROR_READING_PLACEHOLDER} ${allProjectsListFilename}>`
+        // Make objects from this (except .note)
+        // Date fields (startDate, dueDate, etc.) are stored as ISO strings (YYYY-MM-DD) and left as strings
+        projectInstances = JSON.parse(content)
+        
+        // Recalculate review fields for all projects since nextReviewDays may be stale
+        // This is necessary because the JSON was written at a previous time, and nextReviewDays
+        // needs to be recalculated based on the current date
+        logDebug('getAllProjectsFromList', `- Recalculating review fields for ${projectInstances.length} projects loaded from JSON`)
+        projectInstances = projectInstances.map((project) => calcReviewFieldsForProject(project))
       }
-      projectInstances = await generateAllProjectsList()
-    } else {
-      // Read from the list
-      const fileAgeMs = getFileAgeMs(generatedDatePrefName)
-      const fileAgeHours = (fileAgeMs / MS_PER_HOUR).toFixed(2)
-      logDebug('getAllProjectsFromList', `- Reading from current allProjectsList (as only ${fileAgeHours} hours old)`)
-      const content = DataStore.loadData(allProjectsListFilename, true) ?? `${ERROR_READING_PLACEHOLDER} ${allProjectsListFilename}>`
-      // Make objects from this (except .note)
-      // Date fields (startDate, dueDate, etc.) are stored as ISO strings (YYYY-MM-DD) and left as strings
-      projectInstances = JSON.parse(content)
-      
-      // Recalculate review fields for all projects since nextReviewDays may be stale
-      // This is necessary because the JSON was written at a previous time, and nextReviewDays
-      // needs to be recalculated based on the current date
-      logDebug('getAllProjectsFromList', `- Recalculating review fields for ${projectInstances.length} projects loaded from JSON`)
-      projectInstances = projectInstances.map((project) => calcReviewFieldsForProject(project))
     }
     logTimer(`getAllProjectsFromList`, startTime, `- read ${projectInstances.length} Projects from allProjects list`)
 
@@ -498,6 +549,28 @@ export async function getAllProjectsFromList(): Promise<Array<Project>> {
   }
 }
 
+/**
+ * Get all project objects from the fixed demo JSON list. No generation or recalculation; data is used as-is.
+ * @author @jgclark
+ * @returns {Promise<Array<Project|any>>} array of project-like objects from allProjectsDemoList.json, or [] if file missing
+ */
+export async function getAllProjectsFromDemoList(): Promise<Array<Project | any>> {
+  try {
+    logDebug('getAllProjectsFromDemoList', `Starting ...`)
+    if (!DataStore.fileExists(allProjectsDemoListFilename)) {
+      logWarn('getAllProjectsFromDemoList', `Demo file not found: ${allProjectsDemoListFilename}`)
+      return []
+    }
+    const content = DataStore.loadData(allProjectsDemoListFilename, true) ?? `${ERROR_READING_PLACEHOLDER} ${allProjectsDemoListFilename}>`
+    const projectInstances = JSON.parse(content)
+    logDebug('getAllProjectsFromDemoList', `- read ${projectInstances.length} projects from demo list (no recalculation)`)
+    return Array.isArray(projectInstances) ? projectInstances : []
+  } catch (error) {
+    logError('getAllProjectsFromDemoList', error.message)
+    return []
+  }
+}
+
 /**
  * Get the Project object instance from JSON list that matches by filename.
  * @author @jgclark
@@ -622,6 +695,7 @@ export function sortProjectsList(
  * @param {string?} projectTag to filter by (optional)
  * @param {Array<string>?} sortingOrder array of field names to sort by; if given overrides the default sorting order from the Reviews plugin. (Optional)
  * @param {boolean?} dedupeList if true, deduplicate the list by removing projects with multiple 'projectTags'. (Optional, default is false)
+ * @param {boolean?} useDemoList if true, read from allProjectsDemoList.json instead of live list (optional, default is false)
  * @returns {Promise<[Array<Project>, number]>} [sorted projects, number projects unfiltered]
  */
 export async function filterAndSortProjectsList(
@@ -629,16 +703,23 @@ export async function filterAndSortProjectsList(
   projectTag: string = '',
   sortingOrder: Array<string> = [],
   dedupeList?: boolean = false,
+  useDemoList?: boolean = false,
 ): Promise<[Array<Project>, number]> {
-  const allProjectInstances = await getAllProjectsFromList()
-  logInfo('filterAndSortProjectsList', `Starting with tag '${projectTag}' for ${allProjectInstances.length} projects`)
+  let allProjectInstances: Array<Project>
+  if (useDemoList) {
+    allProjectInstances = await getAllProjectsFromDemoList()
+  } else {
+    allProjectInstances = await getAllProjectsFromList()
+  }
+  logInfo('filterAndSortProjectsList', `Starting with tag '${projectTag}' for ${allProjectInstances.length} projects${useDemoList ? ' (demo)' : ''}`)
   
   // Filter out projects that are not tagged with the projectTag
   const projectInstancesForTag = (projectTag !== '')
     ? allProjectInstances.filter((pi) => pi.projectTag === projectTag)
     : allProjectInstances
 
-  const filteredProjectList = await filterProjectsList(projectInstancesForTag, config, dedupeList)
+  const filteredProjectList = (useDemoList) ? projectInstancesForTag : await filterProjectsList(projectInstancesForTag, config, dedupeList)
+
   const sortedProjectList = sortProjectsList(filteredProjectList, config, sortingOrder) 
   logInfo('filterAndSortProjectsList', `- filtered ${filteredProjectList.length} projects, sorted ${sortedProjectList.length} projects`)
   return [sortedProjectList, projectInstancesForTag.length]
@@ -656,12 +737,17 @@ export async function filterAndSortProjectsList(
  * @param {ReviewConfig} config
  */
 export async function updateAllProjectsListAfterChange(
-  // reviewedTitle: string,
   reviewedFilename: string,
   simplyDelete: boolean,
   config: ReviewConfig,
 ): Promise<void> {
   try {
+    if (config.useDemoData ?? false) {
+      logInfo('updateAllProjectsListAfterChange', `Demo mode is on; not updating live notes, but will adjust JSON list if possible for '${reviewedFilename}'`)
+      // In demo mode we deliberately do not touch live notes or regenerate from them.
+      // We leave the JSON list unchanged here; any in-UI changes should go via updateProjectInAllProjectsList.
+      return
+    }
     if (reviewedFilename === '') {
       throw new Error('Empty filename passed')
     }
diff --git a/jgclark.Reviews/src/index.js b/jgclark.Reviews/src/index.js
index ff95d9ace..1e201e204 100644
--- a/jgclark.Reviews/src/index.js
+++ b/jgclark.Reviews/src/index.js
@@ -3,19 +3,16 @@
 //-----------------------------------------------------------------------------
 // Index for Reviews plugin
 // by Jonathan Clark
-// Last updated 2026-02-13 for v1.3.0.b9, @jgclark
+// Last updated 2026-02-23 for v1.4.0.b2, @jgclark
 //-----------------------------------------------------------------------------
 
 // allow changes in plugin.json to trigger recompilation
-// import { generateCSSFromTheme } from '@helpers/HTMLView'
 import pluginJson from '../plugin.json'
 import { getReviewSettings, type ReviewConfig } from './reviewHelpers'
-import { renderProjectLists } from './reviews'
-import { generateAllProjectsList } from './allProjectsListHelpers'
+import { displayProjectLists } from './reviews'
 import { pluginUpdated, updateSettingData } from '@helpers/NPConfiguration'
 import { JSP, logDebug, logError, logInfo } from '@helpers/dev'
 import { editSettings } from '@helpers/NPSettings'
-import { isHTMLWindowOpen } from '@helpers/NPWindows'
 
 export {
   finishReview,
@@ -26,6 +23,7 @@ export {
   redisplayProjectListHTML,
   renderProjectLists,
   renderProjectListsIfOpen,
+  toggleDemoModeForProjectLists,
   setNewReviewInterval,
   skipReview,
   startReviews,
@@ -35,8 +33,8 @@ export {
 } from './reviews'
 export {
   generateAllProjectsList,
-  getNextNoteToReview, //  TODO: remove in time. But why?
-  getNextProjectsToReview, //  TODO: remove in time. But why?
+  getNextNoteToReview,
+  getNextProjectsToReview,
   logAllProjectsList
 } from './allProjectsListHelpers'
 // export { NOP } from './reviewHelpers'
@@ -50,7 +48,10 @@ export {
 export {
   generateCSSFromTheme
 } from '@helpers/NPThemeToCSS'
-export { writeProjectsWeeklyProgressToCSV } from './projectsWeeklyProgress'
+export {
+  writeProjectsWeeklyProgressToCSV,
+  showProjectsWeeklyProgressHeatmaps
+} from './projectsWeeklyProgress'
 
 // Note: There are other possible exports, including:
 export { testFonts } from '../experiments/fontTests.js'
@@ -82,16 +83,13 @@ export async function onSettingsUpdated(): Promise<void> {
   logDebug(pluginJson, 'Have updated Review settings, so will recalc the review list and display...')
   const config: ReviewConfig = await getReviewSettings()
 
-  // await makeFullReviewList(config, true)
-  await generateAllProjectsList(config, true)
+  await displayProjectLists()
 
-  // If v3.11+, can now refresh Dashboard
-  if (NotePlan.environment.buildVersion >= 1181) {
-    if (isHTMLWindowOpen(pluginJson['plugin.id'])) {
-      logDebug(pluginJson, `will refresh Project List as it is open`)
-      await renderProjectLists(config)
-    }
-  }
+  // Following should be handled in the above function
+  // if (isHTMLWindowOpen(pluginJson['plugin.id'])) {
+  //   logDebug(pluginJson, `will refresh Project List as it is open`)
+  //   await renderProjectLists(config)
+  // }
 }
 
 export async function onUpdateOrInstall(forceUpdated: boolean = false): Promise<void> {
diff --git a/jgclark.Reviews/src/pluginToHTMLBridge.js b/jgclark.Reviews/src/pluginToHTMLBridge.js
index 138f0f323..b1bad2eff 100644
--- a/jgclark.Reviews/src/pluginToHTMLBridge.js
+++ b/jgclark.Reviews/src/pluginToHTMLBridge.js
@@ -1,7 +1,7 @@
 // @flow
 //-----------------------------------------------------------------------------
 // Bridging functions for Projects plugin (to/from HTML window)
-// Last updated 2026-02-16 for v1.3.0.b12, @jgclark
+// Last updated 2026-02-26 for v1.4.0.b4, @jgclark
 //-----------------------------------------------------------------------------
 
 import pluginJson from '../plugin.json'
@@ -64,9 +64,11 @@ export async function onMessageFromHTMLView(actionType: string, data: any): any
       case 'onChangeCheckbox':
         await bridgeChangeCheckbox(data)
         break
-      case 'refresh':
-        await displayProjectLists()
+      case 'refresh': {
+        const scrollPos = data && typeof data.scrollPos === 'number' ? data.scrollPos : 0
+        await displayProjectLists(null, scrollPos)
         break
+      }
       case 'runPluginCommand':
         await runPluginCommand(data)
         break
@@ -129,13 +131,14 @@ export async function bridgeChangeCheckbox(data: SettingDataObject) {
 
 /**
  * Save display filters from the Display filters dropdown (all three at once).
- * @param {{ displayOnlyDue: boolean, displayFinished: boolean, displayPaused: boolean, displayNextActions: boolean }} data
+ * @param {{ displayOnlyDue: boolean, displayFinished: boolean, displayPaused: boolean, displayNextActions: boolean, displayOrder?: string }} data
  */
 export async function bridgeSaveDisplayFilters(data: {
   displayOnlyDue: boolean,
   displayFinished: boolean,
   displayPaused: boolean,
   displayNextActions: boolean,
+  displayOrder?: string,
 }): Promise<void> {
   try {
     await saveDisplayFilters(data)
diff --git a/jgclark.Reviews/src/projectClass.js b/jgclark.Reviews/src/projectClass.js
index 536e88b79..248252812 100644
--- a/jgclark.Reviews/src/projectClass.js
+++ b/jgclark.Reviews/src/projectClass.js
@@ -2,7 +2,7 @@
 //-----------------------------------------------------------------------------
 // Project class definition for Review plugin
 // by Jonathan Clark
-// Last updated 2026-02-26 for v1.3.1, @jgclark
+// Last updated 2026-03-26 for v1.4.0.b13, @jgclark
 //-----------------------------------------------------------------------------
 
 // Import Helper functions
@@ -19,6 +19,7 @@ import { checkBoolean, checkNumber, checkString } from '@helpers/checkType'
 import {
   daysBetween,
   RE_DATE,
+  RE_DATE_INTERVAL,
   includesScheduledFurtherFutureDate,
   todaysDateISOString,
   toISODateString,
@@ -27,7 +28,7 @@ import { clo, JSP, logDebug, logError, logInfo, logTimer, logWarn } from '@helpe
 import { getFolderDisplayName, getFolderFromFilename } from '@helpers/folders'
 import { getOpenEditorFromFilename, saveEditorIfNecessary } from '@helpers/NPEditor'
 import { getContentFromBrackets, getStringFromList } from '@helpers/general'
-import { endOfFrontmatterLineIndex, getFrontmatterAttribute, updateFrontMatterVars } from '@helpers/NPFrontMatter'
+import { endOfFrontmatterLineIndex, getFrontmatterAttribute, getFrontmatterParagraphs, removeFrontMatterField, updateFrontMatterVars } from '@helpers/NPFrontMatter'
 import { removeAllDueDates } from '@helpers/NPParagraph'
 import { createSectionsAndParaAfterPreamble, endOfPreambleSection, findHeading, getFieldParagraphsFromNote, simplifyRawContent } from '@helpers/paragraph'
 import {
@@ -36,6 +37,11 @@ import {
 } from '@helpers/userInput'
 import { isClosedTask, isClosed, isOpen, isOpenTask } from '@helpers/utils'
 
+//-----------------------------------------------------------------------------
+// Constants
+
+const DEFAULT_REVIEW_INTERVAL = '1w'
+
 //-----------------------------------------------------------------------------
 // Types
 
@@ -46,6 +52,11 @@ export type Progress = {
   comment: string
 }
 
+type FrontmatterFieldRead = {
+  exists: boolean,
+  value: ?string,
+}
+
 //-----------------------------------------------------------------------------
 // Helpers
 
@@ -61,6 +72,58 @@ function getISODateStringFromMention(mentionStr: string): ?string {
   return match && match[1] ? match[1] : undefined
 }
 
+/**
+ * Parse a frontmatter value that may be plain content (e.g. '1w' / '2026-03-26')
+ * or a wrapped mention value (e.g. '@review(1w)' / '@due(2026-03-26)'),
+ * or a quoted value (e.g. '"1w"' / '"2026-03-26"'),
+ * or empty ('').
+ * Returns value trimmed and unquoted, and may be empty.
+ * @param {string} rawValue
+ * @returns {string}
+ * @private
+ */
+export function parseProjectFrontmatterValue(rawValue: string): string {
+  let trimmed = rawValue.trim()
+
+  // Remove double quotes that might surround the value
+  trimmed = trimmed.replace(/^"|"$/g, '')
+
+  // Remove any mention brackets and return the content
+  const mentionMatch = trimmed.match(/^@([A-Za-z0-9_-]+)\((.*)\)$/)
+  if (!mentionMatch) {
+    return trimmed
+  }
+
+  const mentionParam = mentionMatch[2] != null ? mentionMatch[2].trim() : ''
+  return mentionParam
+}
+
+/**
+ * Read a frontmatter key directly from raw frontmatter lines.
+ * This distinguishes between:
+ * - key missing
+ * - key present but empty/invalid
+ * @param {CoreNoteFields} note
+ * @param {string} fieldName
+ * @returns {FrontmatterFieldRead}
+ * @private
+ */
+export function readRawFrontmatterField(note: CoreNoteFields, fieldName: string): FrontmatterFieldRead {
+  const fmParas = getFrontmatterParagraphs(note, false)
+  if (!fmParas || fmParas.length === 0) {
+    return { exists: false, value: undefined }
+  }
+  const escapedFieldName = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+  const re = new RegExp(`^${escapedFieldName}:\\s*(.*)$`, 'i')
+  for (const para of fmParas) {
+    const match = para.content.match(re)
+    if (match) {
+      return { exists: true, value: match[1] != null ? match[1] : '' }
+    }
+  }
+  return { exists: false, value: undefined }
+}
+
 /**
  * Calculate duration string for a date, optionally relative to a start date.
  * If startDate is provided, returns "after X" format. Otherwise returns relative time (e.g., "2 days ago").
@@ -160,11 +223,32 @@ export class Project {
       }
 
       const metadataLineIndex = getOrMakeMetadataLineIndex(note)
-      // this.metadataPara = paras[metadataLineIndex]
       this.metadataParaLineIndex = metadataLineIndex
       let mentions: $ReadOnlyArray<string> = note.mentions ?? [] // Note: can be out of date, and I can't find a way of fixing this, even with updateCache()
       let hashtags: $ReadOnlyArray<string> = note.hashtags ?? [] // Note: can be out of date
       const metadataLine = paras[metadataLineIndex].content
+
+      // If we have a metadata line in the body but no combined frontmatter value yet, migrate it into frontmatter and remove the body line
+      try {
+        const singleKeyName = checkString(DataStore.preference('projectMetadataFrontmatterKey') || 'project')
+        const existingCombined = getFrontmatterAttribute(note, singleKeyName)
+        const existingCombinedStr = existingCombined != null && typeof existingCombined === 'string' ? existingCombined : ''
+        if (existingCombinedStr === '') {
+          const metadataParaToRemove = paras[metadataLineIndex]
+          const fmAttrs: { [string]: any } = {}
+          fmAttrs[singleKeyName] = metadataLine
+          const migratedOK = updateFrontMatterVars(note, fmAttrs)
+          if (migratedOK) {
+            note.removeParagraph(metadataParaToRemove)
+            DataStore.updateCache(note, true)
+            this.metadataParaLineIndex = getOrMakeMetadataLineIndex(note)
+            logDebug('ProjectConstructor', `- migrated body metadata line into frontmatter ${singleKeyName} and removed from body for '${this.title}'`)
+          }
+        }
+      } catch (e) {
+        logWarn('ProjectConstructor', `- migration to frontmatter metadata key failed for '${this.title}': ${e.message}`)
+      }
+
       if (mentions.length === 0) {
         logDebug('ProjectConstructor', `- Grr: .mentions empty: will use metadata line instead`)
         // Note: If necessary, fall back to getting mentions just from the metadataLine
@@ -190,7 +274,12 @@ export class Project {
         logWarn('ProjectConstructor', `- found no projectTag for '${this.title}' in folder ${this.folder}`)
       }
 
-      // read in various metadata fields (if present)
+      // read in review interval (if present) -- see special handling below as well
+      const tempIntervalStr = getParamMentionFromList(mentions, checkString(DataStore.preference('reviewIntervalMentionStr')))
+      if (tempIntervalStr !== '') {
+        this.reviewInterval = getContentFromBrackets(tempIntervalStr) ?? ''
+      }
+      // read in various metadata fields from body of note (if present)
       this.startDate = this.parseDateMention(mentions, 'startMentionStr')
       this.dueDate = this.parseDateMention(mentions, 'dueMentionStr')
       // read in reviewed date (if present)
@@ -200,10 +289,6 @@ export class Project {
       this.completedDate = this.parseDateMention(mentions, 'completedMentionStr')
       // read in cancelled date (if present)
       this.cancelledDate = this.parseDateMention(mentions, 'cancelledMentionStr')
-      // read in review interval (if present)
-      const tempIntervalStr = getParamMentionFromList(mentions, checkString(DataStore.preference('reviewIntervalMentionStr')))
-      // $FlowIgnore[incompatible-type]
-      this.reviewInterval = tempIntervalStr !== '' ? getContentFromBrackets(tempIntervalStr) : '1w'
       // read in nextReview date (if present)
       const nextReviewStr = getParamMentionFromList(mentions, checkString(DataStore.preference('nextReviewMentionStr')))
       if (nextReviewStr !== '') {
@@ -214,6 +299,107 @@ export class Project {
         }
       }
 
+      // Overlay metadata fields from separate frontmatter keys (if they exist)
+      try {
+        const invalidFrontmatterKeysToRemove: Set<string> = new Set()
+        const reISODate = new RegExp(`^${RE_DATE}$`)
+        const normalizedFrontmatterAttrs: { [string]: string } = {}
+
+        /**
+         * Helper to fetch and normalize field keys from DataStore preferences.
+         * Returns the cleaned string or a defaultKey if missing/empty.
+         */
+        const getFrontmatterFieldKey = (prefName: string, defaultKey: string): string =>
+          checkString(DataStore.preference(prefName) || '').replace(/^[@#]/, '') || defaultKey
+
+        const startKey = getFrontmatterFieldKey('startMentionStr', 'start')
+        const dueKey = getFrontmatterFieldKey('dueMentionStr', 'due')
+        const reviewedKey = getFrontmatterFieldKey('reviewedMentionStr', 'reviewed')
+        const completedKey = getFrontmatterFieldKey('completedMentionStr', 'completed')
+        const cancelledKey = getFrontmatterFieldKey('cancelledMentionStr', 'cancelled')
+        const reviewIntervalKey = getFrontmatterFieldKey('reviewIntervalMentionStr', 'review')
+        const nextReviewKey = getFrontmatterFieldKey('nextReviewMentionStr', 'nextReview')
+
+        /**
+         * Helper to read and assign a date field from frontmatter.
+         * @param {string} fieldKey
+         * @param {function} setter
+         * @returns {void}
+         */
+        const readAndAssignDateField = (fieldKey: string, setter: (value: string) => void): void => {
+          const field = readRawFrontmatterField(this.note, fieldKey)
+          if (!field.exists || !field.value) {
+            return
+          }
+          const originalValue = String(field.value)
+          const parsed = parseProjectFrontmatterValue(originalValue)
+          if (parsed !== '' && reISODate.test(parsed)) {
+            setter(parsed)
+            if (originalValue.trim() !== parsed) {
+              normalizedFrontmatterAttrs[fieldKey] = parsed
+            }
+          } else {
+            logWarn('ProjectConstructor', `Found invalid frontmatter '${fieldKey}' value '${String(field.value)}' in '${this.title}' (${this.filename}). Will remove the key.`)
+            invalidFrontmatterKeysToRemove.add(fieldKey)
+          }
+        }
+
+        // Get/set reviewInterval. Note: this is unlike the following metadata fields
+        const fmReviewInterval = readRawFrontmatterField(this.note, reviewIntervalKey)
+
+        if (fmReviewInterval.exists && fmReviewInterval.value) {
+          this.reviewInterval = parseProjectFrontmatterValue(fmReviewInterval.value)
+        }
+        // Now check for valid value in this.reviewInterval, and if not, then log warning and set to default
+        if (!this.reviewInterval || this.reviewInterval === '' || !this.reviewInterval.match(new RegExp(`^${RE_DATE_INTERVAL}$`))) {
+          logWarn('ProjectConstructor', `Found invalid frontmatter '${reviewIntervalKey}' value '${String(this.reviewInterval)}' in '${this.title}' (${this.filename}). Will set it to default '${DEFAULT_REVIEW_INTERVAL}'.`)
+          this.reviewInterval = DEFAULT_REVIEW_INTERVAL
+          const newAttr: { [string]: any } = {}
+          newAttr[reviewIntervalKey] = this.reviewInterval
+          updateFrontMatterVars(this.note, newAttr)
+        }
+
+        readAndAssignDateField(startKey, (value) => {
+          this.startDate = value
+        })
+        readAndAssignDateField(dueKey, (value) => {
+          this.dueDate = value
+        })
+        readAndAssignDateField(reviewedKey, (value) => {
+          this.reviewedDate = value
+        })
+        readAndAssignDateField(completedKey, (value) => {
+          this.completedDate = value
+        })
+        readAndAssignDateField(cancelledKey, (value) => {
+          this.cancelledDate = value
+        })
+        readAndAssignDateField(nextReviewKey, (value) => {
+          this.nextReviewDateStr = value
+        })
+
+        if (invalidFrontmatterKeysToRemove.size > 0) {
+          for (const invalidKey of invalidFrontmatterKeysToRemove) {
+            logInfo('ProjectConstructor', `About to remove invalid frontmatter key '${invalidKey}' from '${this.title}' (${this.filename})`)
+            const removed = removeFrontMatterField(this.note, invalidKey)
+            if (!removed) {
+              logWarn('ProjectConstructor', `Failed to remove invalid frontmatter key '${invalidKey}' from '${this.title}' (${this.filename})`)
+            }
+          }
+        }
+
+        if (Object.keys(normalizedFrontmatterAttrs).length > 0) {
+          updateFrontMatterVars(this.note, normalizedFrontmatterAttrs)
+          logDebug('ProjectConstructor', `Normalized frontmatter date fields [${Object.keys(normalizedFrontmatterAttrs).join(', ')}] in '${this.title}'`)
+        }
+
+        if (invalidFrontmatterKeysToRemove.size > 0 || Object.keys(normalizedFrontmatterAttrs).length > 0) {
+          DataStore.updateCache(this.note, true)
+        }
+      } catch (e) {
+        logWarn('ProjectConstructor', `- overlay from separate frontmatter keys failed for '${this.title}': ${e.message}`)
+      }
+
       // read in icon and iconColor from frontmatter (if present)
       const iconValue = getFrontmatterAttribute(note, 'icon')
       this.icon = iconValue != null && iconValue !== '' ? iconValue : undefined
@@ -263,9 +449,10 @@ export class Project {
         this.generateNextActionComments(nextActionTags, paras, sequentialTag, Array.from(hashtags ?? []), metadataLine)
       }
 
-      // Build allProjectTags: and all hashtags from metadata line and frontmatter 'project' (including #sequential if applicable)
+      // Build allProjectTags: all hashtags from metadata line and combined frontmatter metadata field (including #sequential if applicable)
       const metadataLineHashtags = (`${metadataLine} `).split(/\s+/).filter((w) => w.length > 0 && w[0] === '#')
-      const projectAttr = getFrontmatterAttribute(this.note, 'project')
+      const combinedKey = checkString(DataStore.preference('projectMetadataFrontmatterKey') || 'project')
+      const projectAttr = getFrontmatterAttribute(this.note, combinedKey)
       const projectAttrStr = projectAttr != null && typeof projectAttr === 'string' ? projectAttr : ''
       const frontmatterProjectHashtags = projectAttrStr ? projectAttrStr.match(/#\S+/g) ?? [] : []
       const hasSequentialTag =
@@ -301,6 +488,7 @@ export class Project {
         logDebug('ProjectConstructor', `Constructed ${this.projectTag} ${this.filename}:`)
         logDebug('ProjectConstructor', `  - folder = ${this.folder}`)
         logDebug('ProjectConstructor', `  - folder (for display) = ${getFolderDisplayName(this.folder)}`)
+        logDebug('ProjectConstructor', `  - reviewInterval = ${String(this.reviewInterval)}`)
         logDebug('ProjectConstructor', `  - metadataLine = ${metadataLine}`)
         if (this.isCompleted) logDebug('ProjectConstructor', `  - isCompleted ✔️`)
         if (this.isCancelled) logDebug('ProjectConstructor', `  - isCancelled ✔️`)
@@ -344,8 +532,21 @@ export class Project {
    * @private
    */
   parseDateMention(mentions: $ReadOnlyArray<string>, mentionKey: string): ?string {
-    const tempStr = getParamMentionFromList(mentions, checkString(DataStore.preference(mentionKey)))
-    return tempStr !== '' ? getISODateStringFromMention(tempStr) : undefined
+    const mentionName = checkString(DataStore.preference(mentionKey))
+    const tempStr = getParamMentionFromList(mentions, mentionName)
+    if (tempStr === '') {
+      return undefined
+    }
+
+    const bracketContent = getContentFromBrackets(tempStr)
+    if (bracketContent == null || bracketContent.trim() === '') {
+      logWarn(
+        'ProjectConstructor',
+        `Found malformed ${mentionName}() with empty brackets in '${this.title}' (${this.filename}). Ignoring this value.`,
+      )
+      return undefined
+    }
+    return getISODateStringFromMention(tempStr)
   }
 
   /**
@@ -412,12 +613,76 @@ export class Project {
   }
 
   /**
-   * Update metadata paragraph and save to Editor or note
+   * Update metadata paragraph and save to Editor or note.
+   * Writes to frontmatter; if metadata was in the body, removes the body line after writing to frontmatter.
    * @private
    */
   updateMetadataAndSave(): void {
     const newMetadataLine = this.generateMetadataOutputLine()
-    this.updateMetadataLine(newMetadataLine)
+    const metadataPara = this.note.paragraphs[this.metadataParaLineIndex]
+    const currentContent = metadataPara != null ? metadataPara.content : ''
+    const singleKeyName = checkString(DataStore.preference('projectMetadataFrontmatterKey') || 'project')
+    const frontmatterPrefixRe = new RegExp(`^${singleKeyName}:\\s*`, 'i')
+    const isFrontmatterStyleLine = currentContent.match(/^metadata:\s*/i) != null || frontmatterPrefixRe.test(currentContent)
+
+    this.updateFrontmatterMetadataFromFields(newMetadataLine)
+    if (!isFrontmatterStyleLine && metadataPara != null) {
+      // Metadata was in body; now in frontmatter, so remove the body line
+      this.note.removeParagraph(metadataPara)
+      DataStore.updateCache(this.note, true)
+      this.metadataParaLineIndex = getOrMakeMetadataLineIndex(this.note)
+      logDebug('updateMetadataAndSave', `Wrote metadata to frontmatter and removed body line for '${this.title}'`)
+    } else {
+      this.updateMetadataLine(newMetadataLine)
+    }
+  }
+
+  /**
+   * Ensure frontmatter combined metadata and (optionally) separate keys are kept in sync with Project fields.
+   * @param {string} newMetadataLine - Combined metadata string (without leading "metadata:" or "project:")
+   * @private
+   */
+  updateFrontmatterMetadataFromFields(newMetadataLine: string): void {
+    try {
+      const singleKeyName = checkString(DataStore.preference('projectMetadataFrontmatterKey') || 'project')
+      const attrs: { [string]: any } = {}
+      attrs[singleKeyName] = newMetadataLine
+
+      // Derive possible separate frontmatter key names from mention strings
+      const startKey = checkString(DataStore.preference('startMentionStr') || '').replace(/^[@#]/, '') || 'start'
+      const dueKey = checkString(DataStore.preference('dueMentionStr') || '').replace(/^[@#]/, '') || 'due'
+      const reviewedKey = checkString(DataStore.preference('reviewedMentionStr') || '').replace(/^[@#]/, '') || 'reviewed'
+      const completedKey = checkString(DataStore.preference('completedMentionStr') || '').replace(/^[@#]/, '') || 'completed'
+      const cancelledKey = checkString(DataStore.preference('cancelledMentionStr') || '').replace(/^[@#]/, '') || 'cancelled'
+      const reviewIntervalKey = checkString(DataStore.preference('reviewIntervalMentionStr') || '').replace(/^[@#]/, '') || 'review'
+      const nextReviewKey = checkString(DataStore.preference('nextReviewMentionStr') || '').replace(/^[@#]/, '') || 'nextReview'
+
+      // Only update separate keys that already exist in frontmatter
+      const hasStart = getFrontmatterAttribute(this.note, startKey) != null
+      if (hasStart && this.startDate != null) attrs[startKey] = this.startDate
+      const hasDue = getFrontmatterAttribute(this.note, dueKey) != null
+      if (hasDue && this.dueDate != null) attrs[dueKey] = this.dueDate
+      const hasReviewed = getFrontmatterAttribute(this.note, reviewedKey) != null
+      if (hasReviewed && this.reviewedDate != null) attrs[reviewedKey] = this.reviewedDate
+      const hasCompleted = getFrontmatterAttribute(this.note, completedKey) != null
+      if (hasCompleted && this.completedDate != null) attrs[completedKey] = this.completedDate
+      const hasCancelled = getFrontmatterAttribute(this.note, cancelledKey) != null
+      if (hasCancelled && this.cancelledDate != null) attrs[cancelledKey] = this.cancelledDate
+      const hasReviewInterval = getFrontmatterAttribute(this.note, reviewIntervalKey) != null
+      if (hasReviewInterval && this.reviewInterval != null) attrs[reviewIntervalKey] = this.reviewInterval
+      const hasNextReview = getFrontmatterAttribute(this.note, nextReviewKey) != null
+      if (hasNextReview && this.nextReviewDateStr != null) attrs[nextReviewKey] = this.nextReviewDateStr
+
+      // $FlowFixMe[incompatible-call]
+      const success = updateFrontMatterVars(this.note, attrs)
+      if (success) {
+        DataStore.updateCache(this.note, true)
+      } else {
+        logError('updateFrontmatterMetadataFromFields', `Failed to update frontmatter metadata for '${this.title}'`)
+      }
+    } catch (error) {
+      logError('updateFrontmatterMetadataFromFields', error.message)
+    }
   }
 
   /**
@@ -508,7 +773,7 @@ export class Project {
       // logDebug('calcNextReviewDate', `-> reviewedDate = ${String(this.reviewedDate)} / nextReviewDateStr = ${String(this.nextReviewDateStr)} / nextReviewDays = ${String(this.nextReviewDays)}`)
       return this.nextReviewDateStr
     } catch (error) {
-      logError('calcNextReviewDate', error.message)
+      logError('calcNextReviewDate', `${error.message} for project '${this.title}'`)
       return null
     }
   }
@@ -530,11 +795,12 @@ export class Project {
     // Check if sequential tag is present in frontmatter 'project' attribute or metadata line
     let hasSequentialTag = false
     if (sequentialTagValue !== '') {
-      // Check frontmatter 'project' attribute
-      const projectAttribute = getFrontmatterAttribute(this.note, 'project')
+      // Check combined frontmatter metadata attribute
+      const combinedKey = checkString(DataStore.preference('projectMetadataFrontmatterKey') || 'project')
+      const projectAttribute = getFrontmatterAttribute(this.note, combinedKey)
       if (projectAttribute && typeof projectAttribute === 'string' && projectAttribute.includes(sequentialTagValue)) {
         hasSequentialTag = true
-        logDebug('Project', `  - found sequential tag '${sequentialTagValue}' in frontmatter 'project' attribute`)
+        logDebug('Project', `  - found sequential tag '${sequentialTagValue}' in frontmatter '${combinedKey}' attribute`)
       }
       // Check metadata line hashtags
       if (!hasSequentialTag && hashtagsValue.length > 0) {
@@ -791,7 +1057,6 @@ export class Project {
   completeProject(): string {
     try {
       // update the metadata fields
-      // this.isActive = false
       this.isCompleted = true
       this.isCancelled = false
       this.isPaused = false
@@ -822,7 +1087,6 @@ export class Project {
   cancelProject(): string {
     try {
       // update the metadata fields
-      // this.isActive = false
       this.isCompleted = false
       this.isCancelled = true
       this.isPaused = false
@@ -871,6 +1135,7 @@ export class Project {
 
       // Update metadata using helper that handles both frontmatter and regular paragraphs
       this.updateMetadataLine(newMetadataLine)
+      this.updateFrontmatterMetadataFromFields(newMetadataLine)
       const possibleThisEditor = getOpenEditorFromFilename(this.note.filename)
       if (possibleThisEditor) {
         await possibleThisEditor.save()
@@ -1022,6 +1287,38 @@ function createImmutableProjectCopy(project: Project, updates: ProjectUpdates =
   }
 }
 
+/**
+ * Normalise a possibly non-ISO date string to strict ISO format (YYYY-MM-DD), or null if invalid.
+ * Handles legacy full ISO datetime strings (e.g. 'YYYY-MM-DDTHH:mm:ss.sssZ') by truncating to the date part.
+ * @param {?string} dateStrIn
+ * @param {string} context - description for logging
+ * @returns {?string} normalised ISO date string or null
+ * @private
+ */
+function normaliseISODateString(dateStrIn: ?string, context: string): ?string {
+  if (typeof dateStrIn !== 'string' || dateStrIn === '') {
+    return null
+  }
+
+  const reISODate = new RegExp(`^${RE_DATE}$`)
+  if (reISODate.test(dateStrIn)) {
+    return dateStrIn
+  }
+
+  // Handle full ISO datetime 'YYYY-MM-DDTHH:mm:ss.sssZ' by truncating to the date part
+  const isoDateTimeMatch = dateStrIn.match(/^(\d{4}-\d{2}-\d{2})T/)
+  if (isoDateTimeMatch && isoDateTimeMatch[1]) {
+    const truncated = isoDateTimeMatch[1]
+    if (reISODate.test(truncated)) {
+      logWarn('normaliseISODateString', `Truncating full ISO datetime '${dateStrIn}' to '${truncated}' for ${context}`)
+      return truncated
+    }
+  }
+
+  logWarn('normaliseISODateString', `Invalid date string '${dateStrIn}' for ${context}; treating as null`)
+  return null
+}
+
 /**
  * From a Project metadata object read in, calculate updated due/finished durations, and return an immutable updated Project object.
  * On error, returns the original Project object.
@@ -1075,14 +1372,15 @@ export function calcDurationsForProject(thisProjectIn: Project): Project {
 export function calcReviewFieldsForProject(thisProjectIn: Project): Project {
   try {
     // logDebug('calcReviewFieldsForProject', `Starting for '${thisProjectIn.title}' ...`)
-    const now = moment().toDate() // use moment instead of  `new Date` to ensure we get a date in the local timezone
+    const now = moment().toDate() // use moment instead of `new Date` to ensure we get a date in the local timezone
 
     // Calculate next review due date, if there isn't already a nextReviewDateStr, and there's a review interval.
     let nextReviewDateStr: ?string = thisProjectIn.nextReviewDateStr
     let nextReviewDays: number = thisProjectIn.nextReviewDays
 
     // First check to see if project start is in future: if so set nextReviewDateStr to project start
-    const startDateIn = thisProjectIn.startDate
+    const rawStartDateIn = thisProjectIn.startDate
+    const startDateIn = normaliseISODateString(rawStartDateIn, 'startDate')
     if (startDateIn != null) {
       const momTSD = moment(startDateIn)
       if (momTSD.isAfter(now)) {
@@ -1093,24 +1391,40 @@ export function calcReviewFieldsForProject(thisProjectIn: Project): Project {
     }
 
     // Now check to see if we have a specific nextReviewDateStr
-    if (thisProjectIn.nextReviewDateStr != null) {
-      nextReviewDays = daysBetween(now, thisProjectIn.nextReviewDateStr)
-      logDebug('calcReviewFieldsForProject', `- already had a nextReviewDateStr ${thisProjectIn.nextReviewDateStr ?? '?'} -> ${String(nextReviewDays)} interval`)
-    }
-    else if (thisProjectIn.reviewInterval != null) {
-      if (thisProjectIn.reviewedDate != null) {
-        const calculatedNextReviewDateStr = calcNextReviewDate(thisProjectIn.reviewedDate, thisProjectIn.reviewInterval)
-        if (calculatedNextReviewDateStr != null) {
-          nextReviewDateStr = calculatedNextReviewDateStr
-          // this now uses moment and truncated (not rounded) date diffs in number of days
-          nextReviewDays = daysBetween(now, nextReviewDateStr)
-          // logDebug('calcReviewFieldsForProject', `${String(thisProjectIn.reviewedDate)} + ${thisProjectIn.reviewInterval ?? ''} -> nextReviewDateStr: ${nextReviewDateStr ?? ''} = ${String(nextReviewDays) ?? '-'}`)
+    const rawNextReviewDateStrIn = thisProjectIn.nextReviewDateStr
+    const normalisedNextReviewDateStr = normaliseISODateString(rawNextReviewDateStrIn, 'nextReviewDateStr')
+
+    if (normalisedNextReviewDateStr != null) {
+      nextReviewDateStr = normalisedNextReviewDateStr
+      nextReviewDays = daysBetween(now, normalisedNextReviewDateStr)
+      // logDebug('calcReviewFieldsForProject', `- already had a nextReviewDateStr ${normalisedNextReviewDateStr ?? '?'} -> ${String(nextReviewDays)} interval`)
+    } else if (thisProjectIn.reviewInterval != null) {
+      const reviewedDateIn = thisProjectIn.reviewedDate
+      if (typeof reviewedDateIn === 'string' && reviewedDateIn !== '') {
+        const calculatedNextReviewDateStr = calcNextReviewDate(reviewedDateIn, thisProjectIn.reviewInterval)
+        const hasValidCalculated = calculatedNextReviewDateStr != null && calculatedNextReviewDateStr !== ''
+        if (hasValidCalculated) {
+          const safeCalculatedNextReviewDateStr = normaliseISODateString(calculatedNextReviewDateStr, 'calculatedNextReviewDateStr')
+          if (safeCalculatedNextReviewDateStr != null) {
+            nextReviewDateStr = safeCalculatedNextReviewDateStr
+            // this now uses moment and truncated (not rounded) date diffs in number of days
+            nextReviewDays = daysBetween(now, safeCalculatedNextReviewDateStr)
+            // logDebug('calcReviewFieldsForProject', `${String(thisProjectIn.reviewedDate)} + ${thisProjectIn.reviewInterval ?? ''} -> nextReviewDateStr: ${nextReviewDateStr ?? ''} = ${String(nextReviewDays) ?? '-'}`)
+          } else {
+            // Fall back to today rather than throwing (e.g. if calcNextReviewDate returned an unexpected format)
+            nextReviewDateStr = moment().format('YYYY-MM-DD')
+            nextReviewDays = 0
+            logWarn('calcReviewFieldsForProject', `Could not normalise calculated nextReviewDate '${String(calculatedNextReviewDateStr)}' for project '${thisProjectIn.title}'; using today`)
+          }
         } else {
-          throw new Error(`calculated nextReviewDate is null; reviewedDate = ${String(thisProjectIn.reviewedDate)}`)
+          // No valid calculated date (null or empty): treat next review as today
+          nextReviewDateStr = moment().format('YYYY-MM-DD')
+          nextReviewDays = 0
+          logDebug('calcReviewFieldsForProject', `calcNextReviewDate returned no date for reviewedDate=${String(reviewedDateIn)}; using today`)
         }
       } else {
         // no next review date, so set at today
-        nextReviewDateStr = toISODateString(now)
+        nextReviewDateStr = moment().format('YYYY-MM-DD')
         nextReviewDays = 0
       }
     }
@@ -1121,7 +1435,7 @@ export function calcReviewFieldsForProject(thisProjectIn: Project): Project {
       nextReviewDays,
     })
   } catch (error) {
-    logError('calcReviewFieldsForProject', error.message)
+    logError('calcReviewFieldsForProject', `${error.message} in project '${thisProjectIn.title}'`)
     return thisProjectIn
   }
 }
diff --git a/jgclark.Reviews/src/projects.js b/jgclark.Reviews/src/projects.js
index f3327eabc..fab499f84 100644
--- a/jgclark.Reviews/src/projects.js
+++ b/jgclark.Reviews/src/projects.js
@@ -3,13 +3,13 @@
 //-----------------------------------------------------------------------------
 // Commands for working with Project and Area notes, seen in NotePlan notes.
 // by @jgclark
-// Last updated 2026-02-13 for v1.3.0.b8, @jgclark
+// Last updated 2026-03-16 for v1.4.0.b8, @jgclark
 //-----------------------------------------------------------------------------
 
 import moment from 'moment'
-import { generateProjectOutputLine } from './projectsHTMLGenerator'
+import { buildProjectLineForStyle } from './projectsHTMLGenerator'
 import { Project } from './projectClass'
-import { finishReviewForNote, renderProjectLists } from './reviews'
+import { finishReviewForNote, renderProjectListsIfOpen } from './reviews'
 import { getReviewSettings, type ReviewConfig } from './reviewHelpers'
 import { updateAllProjectsListAfterChange } from './allProjectsListHelpers'
 import { clo, JSP, logDebug, logError, logInfo, logWarn } from '@helpers/dev'
@@ -63,8 +63,8 @@ async function reloadAndUpdateLists(note: TNote, config: ReviewConfig, shouldArc
   // Update the allProjects list
   await updateAllProjectsListAfterChange(note.filename ?? ERROR_FILENAME_PLACEHOLDER, shouldArchive, config)
 
-  // Re-render the outputs (but don't focus)
-  await renderProjectLists(config, false)
+  // Re-render the outputs if window open (but don't focus)
+  await renderProjectListsIfOpen(config)
 }
 
 /**
@@ -76,7 +76,7 @@ async function reloadAndUpdateLists(note: TNote, config: ReviewConfig, shouldArc
  */
 function addToYearlyNote(thisProject: Project, config: ReviewConfig): void {
   // Pass config with showFolderName so folder appears before title (config may be frozen from loadJSON)
-  const lineToAdd = generateProjectOutputLine(thisProject,
+  const lineToAdd = buildProjectLineForStyle(thisProject,
     { ...config, showFolderName: true },
     'list') // list = for summary note, without [x] etc.
   const yearlyNote = DataStore.calendarNoteByDateString(thisYearStr)
diff --git a/jgclark.Reviews/src/projectsHTMLGenerator.js b/jgclark.Reviews/src/projectsHTMLGenerator.js
index 02f5ddba8..abd70e350 100644
--- a/jgclark.Reviews/src/projectsHTMLGenerator.js
+++ b/jgclark.Reviews/src/projectsHTMLGenerator.js
@@ -3,16 +3,17 @@
 // HTML Generation Functions for Reviews Plugin
 // Consolidated HTML generation logic from multiple files
 // by Jonathan Clark
-// Last updated 2026-02-16 for v1.3.0.b12 by @jgclark
+// Last updated 2026-03-26 for v1.4.0.b13 by @jgclark
 //-----------------------------------------------------------------------------
 
+import moment from 'moment/min/moment-with-locales'
 import { Project } from './projectClass'
-import { addFAIcon, getIntervalDueStatus, getIntervalReviewStatus, type ReviewConfig } from './reviewHelpers'
+import { addFAIcon, type ReviewConfig } from './reviewHelpers'
 import { checkBoolean, checkString } from '@helpers/checkType'
-import { logDebug, logError, logInfo, logWarn } from '@helpers/dev'
+import { logWarn } from '@helpers/dev'
 import { getFolderDisplayName, getFolderDisplayNameForHTML } from '@helpers/folders'
 import { createOpenOrDeleteNoteCallbackUrl } from '@helpers/general'
-import { makePluginCommandButton, makeSVGPercentRing, redToGreenInterpolation } from '@helpers/HTMLView'
+import { makePluginCommandButton, redToGreenInterpolation } from '@helpers/HTMLView'
 import { localeRelativeDateFromNumber, nowLocaleShortDateTime } from '@helpers/NPdateTime'
 import { getLineMainContentPos } from '@helpers/search'
 import { encodeRFC3986URIComponent, prepAndTruncateMarkdownForDisplay } from '@helpers/stringTransforms'
@@ -22,20 +23,67 @@ import { encodeRFC3986URIComponent, prepAndTruncateMarkdownForDisplay } from '@h
 //-----------------------------------------------------------------------------
 
 /**
- * Returns line showing more detailed summary of the project, for output in Rich (HTML) or Markdown formats or simple list format.
- * Now uses fontawesome icons for some indicators.
- * Note: this is V2, now *not* part of the Project class, so can take config etc.
+ * Folder label for a per-project row, matching folder header display (hideTopLevelFolder, root).
  * @param {Project} thisProject
  * @param {ReviewConfig} config
- * @param {string} style: 'Rich' (-> HTML), or 'Markdown'
+ * @returns {string}
+ */
+function projectFolderDisplayLabel(thisProject: Project, config: ReviewConfig): string {
+  const folderDisplayName = getFolderDisplayNameForHTML(thisProject.folder)
+  let folderPart = folderDisplayName
+  if (config.hideTopLevelFolder) {
+    if (folderDisplayName.includes(']')) {
+      const match = folderDisplayName.match(/^(\[.*?\])\s*(.+)$/)
+      if (match) {
+        const pathPart = match[2]
+        const pathParts = pathPart.split('/').filter((p) => p !== '')
+        folderPart = `${match[1]} ${pathParts.length > 0 ? pathParts[pathParts.length - 1] : pathPart}`
+      } else {
+        folderPart = folderDisplayName.split('/').slice(-1)[0] || folderDisplayName
+      }
+    } else {
+      const pathParts = folderDisplayName.split('/').filter((p) => p !== '')
+      folderPart = pathParts.length > 0 ? pathParts[pathParts.length - 1] : folderDisplayName
+    }
+  }
+  if (thisProject.folder === '/') folderPart = '(root folder)'
+  return folderPart
+}
+
+/**
+ * One HTML row: outer {@code <div class="projectFolderRow">} with folder path, optional review interval, and review/due status {@code <span>}s.
+ * Shown under the title when not grouping by folder.
+ * @param {Project} thisProject
+ * @param {ReviewConfig} config
+ * @returns {string}
+ */
+function buildProjectFolderMetadataRowDiv(thisProject: Project, config: ReviewConfig): string {
+  const folderPart = projectFolderDisplayLabel(thisProject, config)
+
+  // Show review interval (for active projects only)
+  const isActiveProject = !thisProject.isCompleted && !thisProject.isCancelled && !thisProject.isPaused
+  const reviewIntervalStr = isActiveProject ? `・ <i class="fa-light fa-repeat pad-right"></i>${thisProject.reviewInterval}` : ''
+
+  const statusLozenges = buildReviewAndDueStatusSpans(thisProject).join('\n')
+
+  const rowString = `\n\t\t\t<div class="projectFolderRow project-metadata-row projectFolderText"><span class="projectFolderIcon"><i class="fa-regular fa-folder"></i></span><span class="pad-left pad-right-larger projectFolderText">${folderPart}${reviewIntervalStr} ${statusLozenges}</span></div>`
+  return rowString
+}
+
+/**
+ * Format one project for export: HTML list row ({@code style === 'Rich'}), Markdown line, or simple list line.
+ * @param {Project} thisProject
+ * @param {ReviewConfig} config
+ * @param {string} style {@code 'Rich'} (HTML grid row), {@code 'Markdown'}, or {@code 'list'}
+ * @param {Array<string>?} wantedTagsForRow - when provided (single-section view), added as data-wanted-tags on the row for tag toggles
  * @returns {string} HTML or Markdown string for the project output line (or empty string if error)
  */
-export function generateProjectOutputLine(
+export function buildProjectLineForStyle(
   thisProject: Project,
   config: ReviewConfig,
   style: string,
+  wantedTagsForRow?: Array<string>,
 ): string {
-  // logInfo('generateProjectOutputLine', `- ${thisProject.title}: nRD ${thisProject.nextReviewDays} / due ${thisProject.dueDays}`)
   const ignoreChecklistsInProgress = checkBoolean(DataStore.preference('ignoreChecklistsInProgress')) || false
   let output = ''
   let statsProgress = ''
@@ -53,194 +101,243 @@ export function generateProjectOutputLine(
   }
 
   if (style === 'Rich') {
-    output = generateRichHTMLRow(thisProject, config)
+    output = buildProjectListRowDiv(thisProject, config, wantedTagsForRow)
   } else if (style === 'Markdown' || style === 'list') {
-    output = generateMarkdownLine(thisProject, config, style, statsProgress, thisPercent)
+    output = formatMarkdownProjectLine(thisProject, config, style, statsProgress, thisPercent)
   } else {
-    logWarn('htmlGenerators::generateProjectOutputLine', `Unknown style '${style}'; nothing returned.`)
+    logWarn('projectsHTMLGenerator::buildProjectLineForStyle', `Unknown style '${style}'; nothing returned.`)
     output = ''
   }
   return output
 }
 
 /**
- * Generate Rich HTML row for project
+ * HTML list row: outer {@code <div class="project-grid-row projectRow">} (title block, optional folder row, progress row, next-action rows).
  * @param {Project} thisProject
  * @param {ReviewConfig} config
+ * @param {Array<string>?} wantedTagsForRow - when provided, output as data-wanted-tags for tag-toggle filtering
  * @returns {string}
  * @private
  */
-function generateRichHTMLRow(thisProject: Project, config: ReviewConfig): string {
+function buildProjectListRowDiv(thisProject: Project, config: ReviewConfig, wantedTagsForRow?: Array<string>): string {
+  // Build the various HTML parts of the row, and store in 'parts'
   const parts: Array<string> = []
-  parts.push(`\t<div class="project-grid-row projectRow" data-encoded-filename="${encodeRFC3986URIComponent(thisProject.filename)}">\n\t\t`)
-  parts.push(generateCircleIndicator(thisProject))
-
-  // Column 2a: Project name + link / item count badge / edit dialog trigger button
-  const editButton = `          <span class="pad-left dialogTrigger" onclick="showProjectControlDialog({encodedFilename: '${encodeRFC3986URIComponent(thisProject.filename)}', reviewInterval:'${thisProject.reviewInterval}', encodedTitle:'${encodeRFC3986URIComponent(thisProject.title)}', encodedLastProgressComment:'${encodeRFC3986URIComponent(thisProject.lastProgressComment ?? '')}'})"><i class="fa-light fa-edit"></i></span>\n`
-  const openItemCount = generateItemCountsBadge(thisProject)
-  parts.push(`\n\t\t\t<div class="project-grid-cell project-grid-cell--content"><span class="projectTitle">${decoratedProjectTitle(thisProject, 'Rich', config)}${editButton}${openItemCount}</span>`)
-
-  if (!thisProject.isCompleted && !thisProject.isCancelled) {
-    const nextActionsContent: Array<string> = thisProject.nextActionsRawContent
-      ? thisProject.nextActionsRawContent.map((na) => na.slice(getLineMainContentPos(na)))
-      : []
-
-    // Write column 2b/2c under title: progress line row (if any) then stats then next actions
-    parts.push(generateProgressSection(thisProject, config, false))
-    parts.push(generateNextActionsSection(config, nextActionsContent))
+  const wantedTagsAttr = (wantedTagsForRow != null && wantedTagsForRow.length > 0)
+    ? ` data-wanted-tags="${wantedTagsForRow.join(' ').replace(/"/g, '&quot;')}"`
+    : ''
+  const extraStyle = `style="border-left: 5px solid ${getProjectIndicatorColor(thisProject)};"`
+  
+  // Start the row with the outer <div>, and inject the colour style for its left border
+  parts.push(`\t<div class="project-grid-row projectRow" data-encoded-filename="${encodeRFC3986URIComponent(thisProject.filename)}"${wantedTagsAttr}${extraStyle}>\n\t\t`)
+
+  // Edit button
+  const editButtonSpan = `\t\t\t\t\t<span class="pad-left dialogTrigger" onclick="showProjectControlDialog({encodedFilename: '${encodeRFC3986URIComponent(thisProject.filename)}', reviewInterval:'${thisProject.reviewInterval}', encodedTitle:'${encodeRFC3986URIComponent(thisProject.title)}', encodedLastProgressComment:'${encodeRFC3986URIComponent(thisProject.lastProgressComment ?? '')}'})"><i class="fa-light fa-edit"></i></span>\n`
+
+  const projectTags = buildProjectTagLozengeSpans(thisProject).join('\n')
+
+  // Project title block
+  if (!config.displayGroupedByFolder) {
+    parts.push(`\n\t\t\t\t<span class="projectMainDetailsRow">${formatProjectTitleForStyle(thisProject, 'Rich', config)}
+      ${editButtonSpan}
+      <span class="projectTagsInline">${projectTags}</span>
+      </span>`)
+  } else {
+    const statusLozenges = buildReviewAndDueStatusSpans(thisProject).join('\n')
+    parts.push(`\n\t\t\t\t<span class="projectMainDetailsRow">${formatProjectTitleForStyle(thisProject, 'Rich', config)}
+      ${editButtonSpan}
+      <span class="projectTagsInline">${projectTags}${statusLozenges}</span>
+      </span>`)
+  }
+  // Write possible row 2 under project title: folder path (if any)
+  if (!config.displayGroupedByFolder) {
+    parts.push(buildProjectFolderMetadataRowDiv(thisProject, config))
   }
-  parts.push(`</div>`)
 
-  // Column 3: metadata (dates + project tags/hashtags)
-  parts.push(generateDateSection(thisProject, config))
+  // Write possible rows 3 + 4 under project title: progress line row (if any) then stats then next actions (if any)
+  const nextActionsContent: Array<string> = thisProject.nextActionsRawContent
+    ? thisProject.nextActionsRawContent.map((na) => na.slice(getLineMainContentPos(na)))
+    : []
+  parts.push(buildProjectProgressRowDiv(thisProject))
+  parts.push(buildNextActionRowDivs(config, nextActionsContent))
+
+  // End the row with the outer </div>
   parts.push('\n\t</div>')
 
   return parts.join('')
 }
 
 /**
- * Generate circle indicator HTML for project status
+ * Get the color for the project indicator
  * @param {Project} thisProject
  * @returns {string}
  * @private
  */
-function generateCircleIndicator(thisProject: Project): string {
+function getProjectIndicatorColor(thisProject: Project): string {
   if (thisProject.isCompleted) {
-    return `<div class="project-grid-cell project-grid-cell--indicator first-col-indicator checked">${addFAIcon('fa-solid fa-circle-check circle-icon')}</div>`
+    return 'var(--project-completed-color)'
   } else if (thisProject.isCancelled) {
-    return `<div class="project-grid-cell project-grid-cell--indicator first-col-indicator cancelled">${addFAIcon('fa-solid fa-circle-xmark circle-icon')}</div>`
+    return 'var(--project-cancelled-color)'
   } else if (thisProject.isPaused) {
-    return `<div class="project-grid-cell project-grid-cell--indicator first-col-indicator">${addFAIcon("fa-solid fa-circle-pause circle-icon", "var(--project-pause-color)")}</div>`
+    return 'var(--project-paused-color)'
   } else if (thisProject.percentComplete == null || isNaN(thisProject.percentComplete)) {
-    return `<div class="project-grid-cell project-grid-cell--indicator first-col-indicator">${addFAIcon('fa-solid fa-circle circle-icon', 'var(--project-no-percent-color)')}</div>`
+    return 'var(--project-no-percent-color)'
   } else if (thisProject.percentComplete === 0) {
-    return `<div class="project-grid-cell project-grid-cell--indicator first-col-indicator">${addSVGPercentRing(thisProject, 100, '#FF000088', '0')}</div>`
+    return 'var(--project-zero-progress-color)'
   } else {
-    return `<div class="project-grid-cell project-grid-cell--indicator first-col-indicator">${addSVGPercentRing(thisProject, thisProject.percentComplete, 'multicol', String(thisProject.percentComplete))}</div>`
+    return redToGreenInterpolation(thisProject.percentComplete)
   }
 }
 
 /**
- * Generate item count badge HTML
+ * Label for the progress line: localized open-item count, {@code ''} if inactive project, or {@code 'no'} when none.
  * @param {Project} thisProject
  * @returns {string}
  * @private
  */
-function generateItemCountsBadge(thisProject: Project): string {
-  const parts: Array<string> = []
-  
+function formatOpenItemCountForProgressLine(thisProject: Project): string {
   // Only show counts for active projects
-  if (thisProject.isCompleted || thisProject.isCancelled) {
+  if (thisProject.isCompleted || thisProject.isCancelled || thisProject.isPaused) {
     return ''
   }
   
   // Task count badge (circle)
   const badgeNumber = (thisProject.numOpenItems - thisProject.numFutureItems > 0) ? thisProject.numOpenItems - thisProject.numFutureItems : 0
   if (badgeNumber > 0) {
-    parts.push(`<span class="openItemCount">${badgeNumber}</span>`)
+    return badgeNumber.toLocaleString()
   }
-  
-  return parts.join('')
+  return 'no'
 }
 
 /**
- * Generate progress section HTML (stats: percent done / item count; comment shown in progress line row when present)
+ * Project tag chips: each entry is one {@code <span class="metadata-lozenge">…</span>} HTML string.
+ * @param {Project} thisProject
+ * @returns {Array<string>}
+ * @private
+ */
+function buildProjectTagLozengeSpans(thisProject: Project): Array<string> {
+  if (thisProject.allProjectTags == null || thisProject.allProjectTags.length === 0) return []
+  const tagsToUse = thisProject.allProjectTags.filter((hashtag) => hashtag !== '#sequential')
+  const parts = tagsToUse.map((hashtag) => `<span class="metadata-lozenge lozenge-general">${hashtag}</span>`)
+  return parts
+}
+
+/**
+ * Review and due chips: each entry is one {@code <span>} HTML string (no outer wrapper).
+ * @param {Project} thisProject
+ * @returns {Array<string>}
+ * @private
+ */
+function buildReviewAndDueStatusSpans(thisProject: Project): Array<string> {
+  const lozenges: Array<string> = []
+  // return empty array if project is completed, cancelled or paused
+  if (thisProject.isCompleted || thisProject.isCancelled || thisProject.isPaused) {
+    return []
+  }
+
+  // Make Review status lozenge (from mapReviewDaysToStatus)
+  if (thisProject.nextReviewDays != null && !isNaN(thisProject.nextReviewDays)) {
+    const reviewStatus = mapReviewDaysToStatus(thisProject.nextReviewDays)
+    if (reviewStatus.text !== '') {
+      lozenges.push(
+        `<span class="pad-left ${reviewStatus.colorClass}">${addFAIcon(reviewStatus.icon ?? '')} ${reviewStatus.text}</span>`,
+      )
+    }
+  }
+
+  // Make Due status lozenge (from mapDueDaysToStatus), follows review in same container
+  if (thisProject.dueDays != null && !isNaN(thisProject.dueDays)) {
+    const dueStatus = mapDueDaysToStatus(thisProject.dueDays)
+    if (dueStatus.text !== '') {
+      lozenges.push(
+        `<span class="pad-left ${dueStatus.colorClass}">${addFAIcon(dueStatus.icon ?? '')} ${dueStatus.text}</span>`,
+      )
+    }
+  }
+
+  return lozenges
+}
+
+/**
+ * One {@code <div class="projectProgressRow">}: completion/cancel/pause line, percent and open-item copy, optional progress comment.
  * @param {Project} thisProject
- * @param {ReviewConfig} config
- * @param {boolean} useDiv - Whether to use div instead of span
  * @returns {string}
  * @private
  */
-function generateProgressSection(thisProject: Project, config: ReviewConfig, useDiv: boolean = false): string {
-  if (!config.displayProgress) return ''
-  const tag = useDiv ? 'div' : 'span'
-  const indent = useDiv ? '\t\t\t\t' : '\n\t\t\t\t'
+function buildProjectProgressRowDiv(thisProject: Project): string {
+  // V2 with added info at start of line
+  // if (!config.displayProgress) return ''
+  // Start with stat progress % and number of open tasks
+  const timeAgoStr = (thisProject.isCompleted)
+    ? moment(thisProject.completedDate).fromNow()
+    : (thisProject.isCancelled)
+      ? moment(thisProject.cancelledDate).fromNow()
+      : (thisProject.isPaused)
+        ? 'paused'
+        : ''
+  const extraClass = (thisProject.isCompleted)
+    ? 'checked'
+    : (thisProject.isCancelled)
+      ? 'cancelled'
+      : (thisProject.isPaused)
+        ? 'paused'
+        : ''
+  const statsStr = (thisProject.isCompleted)
+    ? `<i class="fa-solid fa-circle-check pad-right"></i> ${timeAgoStr}`
+    : (thisProject.isCancelled)
+      ? `<i class="fa-solid fa-circle-xmark pad-right"></i> ${timeAgoStr}`
+      : (thisProject.isPaused)
+        ? `<i class="fa-solid fa-circle-pause pad-right"></i> ${timeAgoStr}`
+        : (isNaN(thisProject.percentComplete))
+          ? ''
+          : `${thisProject.percentComplete}% done ・ `
+  let statsString = `<span class="progressText ${extraClass}">${statsStr}</span>`
+  
+  if (!thisProject.isCompleted && !thisProject.isCancelled && !thisProject.isPaused) {
+    const itemCountsStr = formatOpenItemCountForProgressLine(thisProject)
+    const itemCountsDescription = (itemCountsStr === "1") ? `open item` : `open items`
+    statsString += `<span class="pad-left">${itemCountsStr} ${itemCountsDescription}</span>`
+  }
 
-  // logDebug('generateProgressSection', `for ${thisProject.title}: lastProgressComment: ${thisProject.lastProgressComment}`)
   // If there is a progress comment, show it in the progress line row, otherwise show only stats
+  // logDebug('buildProjectProgressRowDiv', `for ${thisProject.title}: lastProgressComment: ${thisProject.lastProgressComment}`)
   if (thisProject.lastProgressComment !== '') {
-    return `${indent}<${tag} class="projectProgress"><i class="fa-regular fa-circle-info pad-right"></i>${thisProject.lastProgressComment}</${tag}>`
-  } else {
-  //   return `${indent}<${tag} class="progress"><span class="progressText">${statsProgress}</span></${tag}>`
-    return ''
+    statsString += `<span 
+    class="progressIcon pad-left-larger"><i class="fa-regular fa-circle-info"></i></span><span class="pad-left">${thisProject.lastProgressComment}</span>`
+  // } else {
+  //   //   return `${indent}<${tag} class="progress"><span class="progressText">${statsProgress}</span></${tag}>`
+  //   return ''
   }
+  const outputString = `\n\t\t\t\t<div class="projectProgressRow project-metadata-row">${statsString}</div>`
+  return outputString
 }
 
 /**
- * Generate next actions text lines as HTML <divs>.
- * Prepares and truncates long next actions to 80 characters, with ellipsis if truncated. Also simplifies Markdown links to just the [title].
+ * Zero or more {@code <div class="nextActionRow">} rows (plain text body), joined into one string. Truncates to 80 chars per line.
  * @param {ReviewConfig} config
  * @param {Array<string>} nextActionsContent
  * @returns {string}
  * @private
  */
-function generateNextActionsSection(config: ReviewConfig, nextActionsContent: Array<string>): string {
+function buildNextActionRowDivs(config: ReviewConfig, nextActionsContent: Array<string>): string {
   if (!config.displayNextActions || nextActionsContent.length === 0) return ''
 
   const parts: Array<string> = []
   for (const NAContent of nextActionsContent) {
     // const truncatedNAContent = trimString(NAContent, 80)
     const truncatedNAContent = prepAndTruncateMarkdownForDisplay(NAContent, 80)
-    parts.push(`\n\t\t\t<div class="nextAction"><span class="nextActionIcon"><i class="todo fa-regular fa-circle"></i></span><span class="nextActionText pad-left">${truncatedNAContent}</span></div>`)
+    parts.push(`\n\t\t\t<div class="nextActionRow project-metadata-row project-metadata-row"><span class="nextActionIcon"><i class="todo fa-regular fa-circle"></i></span><span class="pad-left-larger nextActionText">${truncatedNAContent}</span></div>`)
   }
   return parts.join('')
 }
 
 /**
- * Generate column 3 (metadata column) HTML for Rich format.
- * Shows up to two coloured lozenges (review status then due status from getIntervalReviewStatus / getIntervalDueStatus),
- * and when present a line of project hashtags from from metadata line and/or frontmatter `project` value.
+ * Title fragment for one project: Rich HTML link + icon, Markdown wikilinks, or list-style wikilinks (not necessarily a single root element).
  * @param {Project} thisProject
- * @param {ReviewConfig} config
- * @returns {string}
- * @private
- */
-function generateDateSection(thisProject: Project, config: ReviewConfig): string {
-  if (!config.displayDates) return ''
-
-  if (thisProject.isPaused) return '<div class="project-grid-cell project-grid-cell--metadata"></div>'
-
-  if (thisProject.isCompleted) {
-    const completionRef = thisProject.completedDuration || "completed"
-    return `<div class="project-grid-cell project-grid-cell--metadata checked">Completed ${completionRef}</div>`
-  } else if (thisProject.isCancelled) {
-    const cancellationRef = thisProject.cancelledDuration || "cancelled"
-    return `<div class="project-grid-cell project-grid-cell--metadata cancelled">Cancelled ${cancellationRef}</div>`
-  }
-
-  const lozenges: Array<string> = []
-
-  // Add all lozenges, each in their own span within one div
-  // Start with project tags
-  if (thisProject.allProjectTags != null && thisProject.allProjectTags.length > 0) {
-    for (const hashtag of thisProject.allProjectTags) {
-      lozenges.push(`<span class="metadata-lozenge metadata-lozenge--tag">${hashtag}</span>`)
-    }
-  }
-
-  // Review status lozenge (from getIntervalReviewStatus)
-  if (thisProject.nextReviewDays != null && !isNaN(thisProject.nextReviewDays)) {
-    const reviewStatus = getIntervalReviewStatus(thisProject.nextReviewDays)
-    lozenges.push(`<span class="metadata-lozenge metadata-lozenge--${reviewStatus.color}">${reviewStatus.text}</span>`)
-  }
-  // Due status lozenge (from getIntervalDueStatus), follows review in same column
-  if (thisProject.dueDays != null && !isNaN(thisProject.dueDays)) {
-    const dueStatus = getIntervalDueStatus(thisProject.dueDays)
-    lozenges.push(`<span class="metadata-lozenge metadata-lozenge--${dueStatus.color}">${dueStatus.text}</span>`)
-  }
-
-  return `<div class="project-grid-cell project-grid-cell--metadata project-metadata-cell">${lozenges.join('\n')}</div>`
-}
-
-/**
- * Returns title of note as folder name + link, also showing complete or cancelled where relevant.
- * Supports 'Markdown' or 'HTML' styling or simpler 'list' styling
- * @param {Project} thisProject 'Markdown' or 'HTML' or 'list'
- * @param {string} style 'Markdown' or 'HTML' or 'list'
+ * @param {string} style 'Rich' | 'Markdown' | 'list'
  * @param {any} config
- * @return {string} - title as wikilink
+ * @return {string}
  */
-function decoratedProjectTitle(thisProject: Project, style: string, config: any): string {
+function formatProjectTitleForStyle(thisProject: Project, style: string, config: any): string {
   const titlePart = thisProject.title ?? '(error, not available)'
   switch (style) {
     case 'Rich': {
@@ -260,8 +357,6 @@ function decoratedProjectTitle(thisProject: Project, style: string, config: any)
       // const tailwindColor = thisProject.iconColor != null && thisProject.iconColor !== '' ? thisProject.iconColor : ''
       const iconColorStyle = '' // tailwindColor !== '' ? ` style="color: ${tailwindToHsl(tailwindColor)};"` : ''
       const iconHTML = `<i class="fa-regular fa-${iconClass}"${iconColorStyle}></i>`
-      // logDebug('decoratedProjectTitle', `${thisProject.filename}: icon: ${thisProject.icon ?? '-'}, color: ${thisProject.iconColor ?? '-'}`)
-      // logDebug('decoratedProjectTitle', `${thisProject.filename}:iconClass: ${iconClass}, tailwindColor: ${tailwindColor}, iconColorStyle: ${iconColorStyle}`)
       
       return `<a class="noteTitle" href="${noteOpenActionURL}"><span class="noteTitleIcon">${iconHTML}</span><span class="noteTitleText ${extraClasses}">${folderNamePart}${titlePart}</span></a>`
     }
@@ -295,44 +390,25 @@ function decoratedProjectTitle(thisProject: Project, style: string, config: any)
     }
 
     default:
-      logWarn('htmlGenerators::decoratedProjectTitle', `Unknown style '${style}'; nothing returned.`)
+      logWarn('projectsHTMLGenerator::formatProjectTitleForStyle', `Unknown style '${style}'; nothing returned.`)
       return ''
   }
 }
 
 /**
- * Add SVG ready for percent ring with the number in the middle.
- * Note: this is kept in this file as it is specific to Review functionality. But it relies on the more generic 'makeSVGPercentRing' helper function.
- * Note: It needs to be followed by call to JS function setPercentRing() to set the ring's state.
- * Note: This is a non-Class version of the function.
- * @param {Project} thisProject
- * @param {number} percent 0-100
- * @param {string?} color for ring and text (as colour name or #RGB), or 'multicol' to mean shading between red and green
- * @param {string?} textToShow inside ring, which can be different from just the percent, which is used by default
- * @returns {string} SVG code to insert in HTML
- */
-function addSVGPercentRing(thisProject: Project, percent: number, colorIn: string = 'multicol', text: string = ''): string {
-  const textToShow = (text !== '') ? text : String(percent)
-  const colorToUse = (colorIn === 'multicol')
-    ? redToGreenInterpolation(percent)
-    : colorIn
-  return makeSVGPercentRing(percent, colorToUse, textToShow, thisProject.ID)
-}
-
-/**
- * Generate Markdown/list format line for project
+ * One Markdown or list-format line for a project (plain text / markdown, not HTML row markup).
  * @param {Project} thisProject
  * @param {any} config
- * @param {string} style
+ * @param {string} style {@code 'Markdown'} | {@code 'list'}
  * @param {string} statsProgress
  * @param {string} thisPercent
  * @returns {string}
  * @private
  */
-function generateMarkdownLine(thisProject: Project, config: any, style: string, statsProgress: string, thisPercent: string): string {
+function formatMarkdownProjectLine(thisProject: Project, config: any, style: string, statsProgress: string, thisPercent: string): string {
   const parts: Array<string> = []
   parts.push('- ')
-  parts.push(decoratedProjectTitle(thisProject, style, config))
+  parts.push(formatProjectTitleForStyle(thisProject, style, config))
 
   if (config.displayDates && !thisProject.isPaused) {
     if (thisProject.isCompleted) {
@@ -377,17 +453,51 @@ function generateMarkdownLine(thisProject: Project, config: any, style: string,
   return parts.join('')
 }
 
+type IntervalStatus = {
+  colorClass: string,
+  icon: string,
+  text: string,
+}
+
+/**
+ * Map a review interval (days until/since due) to a display color and label.
+ * @param {number} interval - days until due (negative = overdue, positive = due in future)
+ * @returns {{ color: string, text: string }}
+ */
+function mapDueDaysToStatus(interval: number): IntervalStatus {
+  // if (interval < -90) return { color: 'red', icon: 'fa-solid fa-flag-checkered', text: 'very overdue' }
+  if (interval < -14) return { colorClass: 'overdue', icon: 'fa-light fa-flag-checkered', text: 'overdue' }
+  if (interval < 0) return { colorClass: 'due', icon: 'fa-light fa-flag-checkered', text: 'due now' }
+  if (interval > 30) return { colorClass: 'soon', icon: 'fa-light fa-flag-checkered', text: 'due soon' }
+  return { text: '', colorClass: '', icon: '' }
+}
+
+/**
+ * Map days-until-next-review to icon/text/css class for a status {@code <span>}.
+ * @param {number} interval - days until next review (negative = overdue, positive = due in future)
+ * @returns {IntervalStatus}
+ */
+function mapReviewDaysToStatus(interval: number): IntervalStatus {
+  // if (interval < -90) return { color: 'red', icon: 'fa-solid fa-user-clock', text: 'very overdue' }
+  if (interval < -14) return { colorClass: 'overdue', icon: 'fa-light fa-user-clock', text: 'overdue' }
+  if (interval < 0) return { colorClass: 'due', icon: 'fa-light fa-user-clock', text: 'due now' }
+  if (interval < 30) return { colorClass: 'soon', icon: 'fa-light fa-user-clock', text: 'due soon' }
+  return { text: '', colorClass: '', icon: '' }
+}
+
 //-----------------------------------------------------------------------------
 // HTML Structure Generation
 //-----------------------------------------------------------------------------
 
 /**
- * Generate top bar HTML with controls and toggles
+ * Sticky top bar markup ({@code <div>} tree): refresh, filters dropdown, review command buttons.
  * @param {any} config
  * @returns {string}
  */
-export function generateTopBarHTML(config: any): string {
+export function buildProjectListTopBarHtml(config: any): string {
+  const topbarClasses = config.usePerspectives ? 'topbar' : 'topbar no-perspective'
   const parts: Array<string> = []
+  const displayOrder = (typeof config.displayOrder === 'string' && config.displayOrder !== '') ? config.displayOrder : 'review'
   
   // Add buttons for various commands
   const refreshPCButton = makePluginCommandButton(
@@ -414,6 +524,7 @@ export function generateTopBarHTML(config: any): string {
     `Update the ${checkString(DataStore.preference('reviewedMentionStr'))}() date for the Project you're currently editing`,
     true
   )
+  // Note: this button is not currently used, but might be again, so leaving.
   const finishAndNextReviewPCButton = makePluginCommandButton(
     `<i class="fa-regular fa-calendar-check"></i>\u00A0Finish\u00A0+\u00A0<i class="fa-solid fa-calendar-arrow-down"></i>\u00A0Next`,
     'jgclark.Reviews',
@@ -431,35 +542,70 @@ export function generateTopBarHTML(config: any): string {
     true
   )
 
-  // Start with a sticky top bar
-  parts.push(`<div class="topbar">`)
-  
+  // Start with a sticky top bar (grid with 4 elements spaced out, or 3 if not using perspectives)
+  parts.push(`<div class="${topbarClasses}">`)
   if (config.usePerspectives) {
     const perspectiveSection = `<div id="persp" class="topbar-item">Persp: <span class="perspective-name">${config.perspectiveName}</span></div>`
     parts.push(perspectiveSection)
   }
 
-  const refreshSection = `<div id="refresh" class="topbar-item">${refreshPCButton}\n<span class="topbar-text pad-left">Updated: <span id="timer">${nowLocaleShortDateTime()}</span>\n</span></div>`
+  const refreshSection = `<div id="refresh">${refreshPCButton}\n<span class="topbar-item">Updated: <span id="timer">${nowLocaleShortDateTime()}</span>\n</span></div>`
   parts.push(refreshSection)
 
-  // Display filters: button (same style as Refresh) after Refresh + time, opens dropdown; click outside saves, Escape cancels
+  parts.push(`<div class="topbar-center-cluster">`)
+  // Display filters: centred button opens dropdown; click outside saves, Escape cancels
   const displayOnlyDue = config.displayOnlyDue ?? false
   const displayFinished = config.displayFinished ?? false
   const displayPaused = config.displayPaused ?? true
   const displayNextActions = config.displayNextActions ?? false
-  parts.push(`<div id="toggles" class="topbar-item display-filters-wrapper">`)
-  parts.push(`  <button type="button" class="PCButton" id="displayFiltersButton" aria-haspopup="true" aria-expanded="false"><i class="fa-solid fa-filter pad-right"></i>Filters…</button>`)
-  parts.push(`  <div class="display-filters-dropdown" id="displayFiltersDropdown" role="menu" aria-label="Display filters">`)
+  parts.push(`<span id="toggles" class="display-filters-wrapper">`)
+  parts.push(`  <button type="button" class="PCButton" id="displayFiltersButton" aria-haspopup="true" aria-expanded="false"><i class="fa-solid fa-filter pad-right"></i>Filter & Order…</button>`)
+  parts.push(`  <div class="display-filters-dropdown" id="displayFiltersDropdown" role="menu" aria-label="Filter and order">`)
   parts.push(`    <div class="display-filters-dropdown-content">`)
+  // Tag toggles: one per wanted tag; when off, hide projects that only have that tag (client-side). Count = active (not paused/cancelled/completed).
+  const projectTypeTags = config.projectTypeTags != null && typeof config.projectTypeTags === 'string' ? [config.projectTypeTags] : (config.projectTypeTags ?? [])
+  const tagActiveCounts = config.tagActiveCounts ?? []
+  if (projectTypeTags.length > 0) {
+    parts.push(`      <div id="tagToggles" class="display-filters-tag-toggles">`)
+    for (let i = 0; i < projectTypeTags.length; i++) {
+      const tag = projectTypeTags[i]
+      const count = tagActiveCounts[i] != null ? tagActiveCounts[i] : 0
+      const safeId = `tagToggle-${tag.replace(/[^a-zA-Z0-9-_]/g, '_')}`
+      parts.push(`        <label class="display-filters-option display-filters-option--tag-row">`)
+      parts.push(`          <span class="display-filters-option-text">${tag}</span> <span class="display-filters-option-count">(${count})</span>`)
+      parts.push(`          <input type="checkbox" class="apple-switch" data-tag-toggle="${tag.replace(/"/g, '&quot;')}" id="${safeId}" checked>`)
+      parts.push(`        </label>`)
+    }
+    parts.push(`      </div>`)
+    parts.push(`      <hr class="display-filters-divider">`)
+  }
   parts.push(`      <label class="display-filters-option">Show only projects ready for review?<input class="apple-switch pad-left" type="checkbox" ${displayOnlyDue ? 'checked' : ''} name="displayOnlyDue" data-display-filter="true"></label>`)
   parts.push(`      <label class="display-filters-option">Show finished projects?<input class="apple-switch pad-left" type="checkbox" ${displayFinished ? 'checked' : ''} name="displayFinished" data-display-filter="true"></label>`)
   parts.push(`      <label class="display-filters-option">Show paused projects?<input class="apple-switch pad-left" type="checkbox" ${displayPaused ? 'checked' : ''} name="displayPaused" data-display-filter="true"></label>`)
   parts.push(`      <label class="display-filters-option">Show next actions?<input class="apple-switch pad-left" type="checkbox" ${displayNextActions ? 'checked' : ''} name="displayNextActions" data-display-filter="true"></label>`)
+  parts.push(`      <hr class="display-filters-divider">`)
+  parts.push(`      <div class="display-filters-order-row">`)
+  parts.push(`        <label for="displayOrderSelect" class="display-filters-order-label">Order by</label>`)
+  parts.push(`        <select id="displayOrderSelect" class="topbar-select display-filters-order-select" name="displayOrder" aria-label="Sort projects by">`)
+  parts.push(`          <option value="review" ${displayOrder === 'review' ? 'selected' : ''}>Review date</option>`)
+  parts.push(`          <option value="due" ${displayOrder === 'due' ? 'selected' : ''}>Due date</option>`)
+  parts.push(`          <option value="title" ${displayOrder === 'title' ? 'selected' : ''}>Title</option>`)
+  parts.push(`        </select>`)
+  parts.push(`      </div>`)
   parts.push(`    </div>`)
   parts.push(`  </div>`)
+  parts.push(`</span>`)
+
   parts.push(`</div>`)
 
-  const controlButtons = `<div id="reviews" class="topbar-item">Reviews: ${startReviewPCButton}\n${reviewedPCButton}\n${finishAndNextReviewPCButton}\n${nextReviewPCButton}\n</div>`
+  const controlButtons = `
+<div class="topbar-right-cluster">
+  <div id="reviews" class="topbar-item">Reviews: ${startReviewPCButton}
+  ${reviewedPCButton}
+  <!--${finishAndNextReviewPCButton}-->
+  ${nextReviewPCButton}
+  </div>
+</div>`
   parts.push(controlButtons)
 
   // Finish the sticky top bar
@@ -469,84 +615,23 @@ export function generateTopBarHTML(config: any): string {
 }
 
 /**
- * Generate folder header HTML for Rich format.
+ * Folder group heading row ({@code <div class="folder-header-row">}).
  * @param {string} folderPart - Display name for folder
- * @param {any} config
  * @returns {string}
  */
-export function generateFolderHeaderHTML(folderPart: string, config: any): string {
+export function buildFolderGroupHeaderHtml(folderPart: string): string {
   const parts: Array<string> = []
-  parts.push(` <div class="project-grid-row folder-header-row">`)
+  parts.push(` <div class="folder-header-row">`)
   parts.push(`  <div class="project-grid-cell project-grid-cell--span-2 folder-header h3">${folderPart}</div>`)
-  if (config.displayDates) {
-    parts.push(`  <div class="project-grid-cell folder-header"></div>`) // deliberately no header text
-  }
   parts.push(` </div>`)
   return parts.join('')
 }
 
 /**
- * Generate table structure HTML with colgroup
- * @param {any} config
- * @param {number} noteCount
- * @returns {string}
- */
-export function generateTableStructureHTML(_config: any, _noteCount: number): string {
-  // Grid column layout is defined in CSS via .project-list-grid--with-dates / .project-list-grid--no-dates
-  return ''
-}
-
-/**
- * Generate HTML for project tag section header
- * @param {string} thisTag
- * @param {number} noteCount
- * @param {number} due
- * @param {any} config
- * @param {boolean} isMultipleTags
- * @returns {string}
- */
-export function generateHTMLForProjectTagSectionHeader(
-  thisTag: string,
-  noteCount: number,
-  due: number,
-  config: any,
-  isMultipleTags: boolean
-): string {
-  const parts: Array<string> = []
-  // TODO: figure out what to do about paused/caompleted being filtered out, when displaying the count here.
-  let numberItemsStr = (config.displayOnlyDue)
-    ? `${due} of ${noteCount} notes ready for review`
-    : `${noteCount} notes`
-  if (config.numberDaysForFutureToIgnore > 0) {
-    numberItemsStr += ` (with future tasks ignored)`
-  }
-  const headingContent = `<span class="h2">${thisTag}</span><span class="folder-header-text">${numberItemsStr}</span>`
-  
-  if (isMultipleTags) {
-    parts.push(`  <details open>`) // start it open
-    parts.push(`   <summary class="folder-header">${headingContent}</summary>`)
-  } else {
-    parts.push(`  <div class="folder-header">${headingContent}</div>`)
-  }
-  parts.push('\n<div class="details-content">')
-  
-  // Add folder name, but only if we're only looking at 1 folder, and we're not grouping by folder
-  if (!config.displayGroupedByFolder && config.foldersToInclude.length === 1) {
-    const folderDisplayName = getFolderDisplayNameForHTML(config.foldersToInclude[0])
-    parts.push(`<h4>${folderDisplayName} folder</h4>`)
-  }
-  
-  const gridClass = config.displayDates ? 'project-list-grid project-list-grid--with-dates' : 'project-list-grid project-list-grid--no-dates'
-  parts.push(`\n<div class="${gridClass}">`)
-
-  return parts.join('\n')
-}
-
-/**
- * Generate project control dialog HTML
+ * {@code <dialog id="projectControlDialog">} markup for per-project actions.
  * @returns {string}
  */
-export function generateProjectControlDialogHTML(): string {
+export function buildProjectControlDialogHtml(): string {
   return `
   <!----------- Dialog to show on Project items ----------->
   <dialog id="projectControlDialog" class="projectControlDialog" aria-labelledby="Actions Dialog"
diff --git a/jgclark.Reviews/src/projectsHTMLTemplates.js b/jgclark.Reviews/src/projectsHTMLTemplates.js
new file mode 100644
index 000000000..cd8029b2d
--- /dev/null
+++ b/jgclark.Reviews/src/projectsHTMLTemplates.js
@@ -0,0 +1,365 @@
+// @flow
+//-----------------------------------------------------------------------------
+// HTML and JS template strings for Reviews plugin HTML view
+// Extracted from reviews.js to keep command logic separate from templates.
+// Last updated 2026-02-26 for v1.4.0.b4, @jgclark
+//-----------------------------------------------------------------------------
+
+export const stylesheetinksInHeader: string = `
+<!-- Load in Project List-specific CSS -->
+<link href="./projectList.css" rel="stylesheet">
+<link href="./projectListDialog.css" rel="stylesheet">
+`
+
+export const faLinksInHeader: string = `
+<!-- Load in fontawesome assets (licensed for NotePlan) -->
+<link href="../np.Shared/fontawesome.css" rel="stylesheet">
+<link href="../np.Shared/regular.min.flat4NP.css" rel="stylesheet">
+<link href="../np.Shared/solid.min.flat4NP.css" rel="stylesheet">
+<link href="../np.Shared/light.min.flat4NP.css" rel="stylesheet">
+`
+
+export const checkboxHandlerJSFunc: string = `
+<script type="text/javascript">
+async function handleCheckboxClick(cb) {
+  try {
+  console.log("Checkbox for " + cb.name + " clicked, new value = " + cb.checked);
+  const callbackURL = "noteplan://x-callback-url/runPlugin?pluginID=jgclark.Reviews&command=toggle"+cb.name;
+  console.log("Calling URL " + callbackURL + " ...");
+  // v1: use fetch() - doesn't work in plugin
+  // const res = await fetch(callbackURL);
+  // console.log("Result: " + res.status);
+  // v2: use window.open() - doesn't work in plugin
+  // window.open(callbackURL);
+  // v3: use window.location ... - doesn't work in plugin
+  // window.location.href = callbackURL;
+  // v4:
+  const options = {
+    method: 'GET',
+  }
+  fetch(callbackURL, options)
+  .then(response => {
+    console.log("Result: " + response.status);
+  })
+  .catch(error => {
+    console.log("Error Result: " + response.status);
+  });
+
+  // onChangeCheckbox(cb.name, cb.checked); // this uses handler func in commsSwitchboard.js
+  }
+  catch (err) {
+    console.error(err.message);
+  }
+}
+</script>
+`
+
+/**
+ * Functions to get/set scroll position of the project list content.
+ * Helped by https://stackoverflow.com/questions/9377951/how-to-remember-scroll-position-and-scroll-back
+ * But need to find a different approach to store the position, as cookies not available.
+ */
+export const scrollPreLoadJSFuncs: string = `
+<script type="text/javascript">
+function getCurrentScrollHeight() {
+  let scrollPos;
+  if (typeof window.pageYOffset !== 'undefined') {
+    scrollPos = window.pageYOffset;
+  }
+  else if (typeof document.compatMode !== 'undefined' && document.compatMode !== 'BackCompat') {
+    scrollPos = document.documentElement.scrollTop;
+  }
+  else if (typeof document.body !== 'undefined') {
+    scrollPos = document.body.scrollTop;
+  }
+  let label = document.getElementById("scrollDisplay");
+  label.innerHTML = String(scrollPos);
+  console.log("getCurrentScrollHeight = " + String(scrollPos));
+}
+
+// Note: saving scroll position to cookie does not work in Safari, but not in NP.
+function setScrollPos(h) {
+  <!-- console.log('setScrollPos = ' + String(h)); -->
+  document.documentElement.scrollTop = h;
+  document.body.scrollTop = h;
+}
+</script>
+`
+
+export const autoRefreshScript: string = `
+<script type="text/javascript">
+(function() {
+  function getScrollPos() {
+    if (typeof window.pageYOffset !== 'undefined') {
+      return window.pageYOffset;
+    } else if (document.documentElement && typeof document.documentElement.scrollTop !== 'undefined') {
+      return document.documentElement.scrollTop;
+    } else if (document.body && typeof document.body.scrollTop !== 'undefined') {
+      return document.body.scrollTop;
+    }
+    return 0;
+  }
+
+  function scheduleAutoRefresh() {
+    var meta = document.querySelector('meta[name="autoUpdateAfterIdleTime"]');
+    if (!meta) return;
+    var minutes = parseInt(meta.getAttribute('content') || '0', 10);
+    if (!minutes || minutes <= 0) return;
+    var intervalMs = minutes * 60 * 1000;
+
+    if (window.__reviewsAutoRefreshTimer) {
+      clearInterval(window.__reviewsAutoRefreshTimer);
+    }
+
+    window.__reviewsAutoRefreshTimer = setInterval(function() {
+      try {
+        var scrollPos = getScrollPos();
+        <!-- console.log('Auto-refreshing Project List at scrollPos', scrollPos); -->
+        sendMessageToPlugin('refresh', { scrollPos: scrollPos });
+      } catch (e) {
+        console.log('Auto-refresh error', e && e.message);
+      }
+    }, intervalMs);
+  }
+
+  if (document.readyState === 'loading') {
+    document.addEventListener('DOMContentLoaded', scheduleAutoRefresh);
+  } else {
+    scheduleAutoRefresh();
+  }
+})();
+</script>
+`
+
+export const commsBridgeScripts: string = `
+<!-- commsBridge scripts -->
+<script type="text/javascript" src="../np.Shared/pluginToHTMLErrorBridge.js"></script>
+<script>
+/* you must set this before you import the CommsBridge file */
+const receivingPluginID = "jgclark.Reviews"; // the plugin ID of the plugin which will receive the comms from HTML
+// That plugin should have a function NAMED onMessageFromHTMLView (in the plugin.json and exported in the plugin's index.js)
+// this onMessageFromHTMLView will receive any arguments you send using the sendToPlugin() command in the HTML window
+
+/* The onMessageFromPlugin function is called when data is received from your plugin and needs to be processed.
+ * This function should not do the work itself, it should just send the data payload to a function for processing.
+ * The onMessageFromPlugin function below and your processing functions can be in your html document or could be imported in an external file.
+ * The only requirement is that onMessageFromPlugin (and receivingPluginID) must be defined or imported before the 
+   pluginToHTMLCommsBridge in your html document or could be imported in an external file. */
+</script>
+<script type="text/javascript" src="./HTMLWinCommsSwitchboard.js"></script>
+<script type="text/javascript" src="../np.Shared/pluginToHTMLCommsBridge.js"></script>
+`
+
+/**
+ * Script to add some keyboard shortcuts to control the dashboard. (Meta=Cmd here.)
+ */
+export const shortcutsScript: string = `
+<!-- shortcuts script -->
+<script type="text/javascript" src="./shortcut.js"></script>
+<script>
+// send 'refresh' command
+shortcut.add("meta+r", function() {
+  console.log("Shortcut '⌘r' triggered: will call refresh");
+  var scrollPos = (typeof window.pageYOffset !== 'undefined')
+    ? window.pageYOffset
+    : (document.documentElement && typeof document.documentElement.scrollTop !== 'undefined')
+      ? document.documentElement.scrollTop
+      : (document.body && typeof document.body.scrollTop !== 'undefined')
+        ? document.body.scrollTop
+        : 0;
+  sendMessageToPlugin('refresh', { scrollPos: scrollPos });
+});
+// send 'toggleDisplayOnlyDue' command
+shortcut.add("meta+d", function() {
+  console.log("Shortcut '⌘d' triggered: will call toggleDisplayOnlyDue");
+  sendMessageToPlugin('runPluginCommand', {pluginID: 'jgclark.Reviews', commandName:'toggleDisplayOnlyDue', commandArgs: []});
+});
+// send 'toggleDisplayFinished' command
+shortcut.add("meta+f", function() {
+  console.log("Shortcut '⌘f' triggered: will call toggleDisplayFinished");
+  sendMessageToPlugin('runPluginCommand', {pluginID: 'jgclark.Reviews', commandName: 'toggleDisplayFinished', commandArgs: []});
+});
+</script>
+`
+
+export const setPercentRingJSFunc: string = `
+<script>
+/**
+ * Sets the value of a SVG percent ring.
+ * @param {number} percent The percent value to set.
+ */
+function setPercentRing(percent, ID) {
+  let svg = document.getElementById(ID);
+  let circle = svg.querySelector('circle');
+  const radius = circle.r.baseVal.value;
+  const circumference = radius * 2 * Math.PI;
+  circle.style.strokeDasharray = String(circumference) + ' ' + String(circumference);
+  circle.style.strokeDashoffset = String(circumference);
+
+  const offset = circumference - percent / 100 * circumference;
+  circle.style.strokeDashoffset = offset;  // Set to negative for anti-clockwise.
+
+  // let text = svg.querySelector('text');
+  // text.textContent = String(percent); // + '%';
+}
+</script>
+`
+
+export const addToggleEvents: string = `
+<script>
+  /**
+   * Register click handlers for each checkbox/toggle in the window with details of the items.
+   * Skip checkboxes inside the Display filters dropdown (those use Save instead).
+   */
+  allInputs = document.getElementsByTagName("INPUT");
+  let added = 0;
+  for (const input of allInputs) {
+    if (input.type !== 'checkbox') continue;
+    if (input.getAttribute('data-display-filter') === 'true') continue;
+    if (input.getAttribute('data-tag-toggle')) continue; // tag toggles are client-side only
+    const thisSettingName = input.name;
+    console.log("- adding event for checkbox '"+thisSettingName+"' currently set to state "+input.checked);
+    input.addEventListener('change', function (event) {
+      event.preventDefault();
+      sendMessageToPlugin('onChangeCheckbox', { settingName: thisSettingName, state: event.target.checked });
+    }, false);
+    added++;
+  }
+  <!-- console.log('- '+ String(added) + ' input ELs added'); -->
+</script>
+`
+
+export const displayFiltersDropdownScript: string = `
+<script>
+  (function() {
+    var btn = document.getElementById('displayFiltersButton');
+    var dropdown = document.getElementById('displayFiltersDropdown');
+    if (!btn || !dropdown) return;
+
+    var savedState = null;
+
+    function getCheckboxState() {
+      var onlyDue = dropdown.querySelector('input[name="displayOnlyDue"]');
+      var finished = dropdown.querySelector('input[name="displayFinished"]');
+      var paused = dropdown.querySelector('input[name="displayPaused"]');
+      var nextActions = dropdown.querySelector('input[name="displayNextActions"]');
+      var displayOrder = dropdown.querySelector('#displayOrderSelect');
+      return onlyDue && finished && paused && nextActions
+        ? {
+            displayOnlyDue: onlyDue.checked,
+            displayFinished: finished.checked,
+            displayPaused: paused.checked,
+            displayNextActions: nextActions.checked,
+            displayOrder: displayOrder ? displayOrder.value : 'review',
+          }
+        : null;
+    }
+
+    function closeDropdown(apply) {
+      dropdown.classList.remove('is-open');
+      btn.setAttribute('aria-expanded', 'false');
+      if (apply) {
+        var state = getCheckboxState();
+        if (state) {
+          // Only save + refresh if something actually changed while the dropdown was open
+          var hasChanges =
+            !savedState ||
+            state.displayOnlyDue !== savedState.displayOnlyDue ||
+            state.displayFinished !== savedState.displayFinished ||
+            state.displayPaused !== savedState.displayPaused ||
+            state.displayNextActions !== savedState.displayNextActions ||
+            state.displayOrder !== savedState.displayOrder;
+          if (hasChanges) {
+            sendMessageToPlugin('saveDisplayFilters', state);
+          }
+        }
+      } else if (savedState) {
+        var onlyDue = dropdown.querySelector('input[name="displayOnlyDue"]');
+        var finished = dropdown.querySelector('input[name="displayFinished"]');
+        var paused = dropdown.querySelector('input[name="displayPaused"]');
+        var nextActions = dropdown.querySelector('input[name="displayNextActions"]');
+        if (onlyDue && finished && paused && nextActions) {
+          onlyDue.checked = savedState.displayOnlyDue;
+          finished.checked = savedState.displayFinished;
+          paused.checked = savedState.displayPaused;
+          nextActions.checked = savedState.displayNextActions;
+        }
+        var orderSel = dropdown.querySelector('#displayOrderSelect');
+        if (orderSel && savedState.displayOrder != null) {
+          orderSel.value = savedState.displayOrder;
+        }
+      }
+    }
+
+    btn.addEventListener('click', function(e) {
+      e.stopPropagation();
+      var isOpen = dropdown.classList.toggle('is-open');
+      btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
+      if (isOpen) savedState = getCheckboxState();
+    });
+
+    document.addEventListener('click', function(e) {
+      if (dropdown.classList.contains('is-open') && !dropdown.contains(e.target) && e.target !== btn) {
+        closeDropdown(true);
+      }
+    });
+
+    document.addEventListener('keydown', function(e) {
+      if (!dropdown.classList.contains('is-open')) return;
+      if (e.key === 'Escape') {
+        closeDropdown(false);
+      } else if (e.key === 'Enter') {
+        closeDropdown(true);
+      }
+    });
+
+    // Sort order is an explicit immediate action: save + refresh on change.
+    var displayOrderSelect = dropdown.querySelector('#displayOrderSelect');
+    if (displayOrderSelect) {
+      displayOrderSelect.addEventListener('change', function() {
+        var state = getCheckboxState();
+        if (state) {
+          sendMessageToPlugin('saveDisplayFilters', state);
+          savedState = state;
+        }
+      });
+    }
+  })();
+</script>
+`
+
+export const tagTogglesVisibilityScript: string = `
+<script>
+  (function() {
+    function applyTagToggleVisibility() {
+      var toggles = document.querySelectorAll('input[data-tag-toggle]');
+      var offTags = [];
+      for (var i = 0; i < toggles.length; i++) {
+        if (!toggles[i].checked) offTags.push(toggles[i].getAttribute('data-tag-toggle'));
+      }
+      var rows = document.querySelectorAll('.projectRow[data-wanted-tags]');
+      for (var r = 0; r < rows.length; r++) {
+        var row = rows[r];
+        var raw = row.getAttribute('data-wanted-tags') || '';
+        var rowTags = raw ? raw.trim().split(/\\s+/) : [];
+        var hide = false;
+        for (var t = 0; t < offTags.length; t++) {
+          if (rowTags.length === 1 && rowTags[0] === offTags[t]) {
+            hide = true;
+            break;
+          }
+        }
+        row.style.display = hide ? 'none' : '';
+      }
+    }
+    document.addEventListener('DOMContentLoaded', function() {
+      applyTagToggleVisibility();
+      var container = document.getElementById('tagToggles');
+      if (container) {
+        container.addEventListener('change', applyTagToggleVisibility);
+      }
+    });
+    if (document.readyState !== 'loading') applyTagToggleVisibility();
+  })();
+</script>
+`
diff --git a/jgclark.Reviews/src/projectsWeeklyProgress.js b/jgclark.Reviews/src/projectsWeeklyProgress.js
index 4948f1ca9..7c87bb792 100644
--- a/jgclark.Reviews/src/projectsWeeklyProgress.js
+++ b/jgclark.Reviews/src/projectsWeeklyProgress.js
@@ -7,7 +7,7 @@
 // Columns: successive week labels (e.g. 2026-W06)
 // Rows: folder names in alphabetical order
 //
-// Last updated 2026-02-06 for v1.3.0.b5 by @jgclark (spec) + @cursor (implementation)
+// Last updated 2026-03-12 for v1.4.0.b6 by @jgclark (spec) + @cursor (implementation)
 //-----------------------------------------------------------------------------
 
 import pluginJson from '../plugin.json'
@@ -22,6 +22,8 @@ import { getNPWeekData, pad } from '@helpers/NPdateTime'
 import { clo, JSP, logDebug, logError, logInfo, logTimer, timer } from '@helpers/dev'
 import { getRegularNotesFromFilteredFolders, getFolderFromFilename } from '@helpers/folders'
 import { isDone } from '@helpers/utils'
+import { showHTMLV2 } from '@helpers/HTMLView'
+import { showMessage } from '@helpers/userInput'
 
 //-----------------------------------------------------------------------------
 // Constants
@@ -31,6 +33,7 @@ const DEFAULT_NUM_WEEKS: number = 26
 const PROJECT_FOLDER_MATCHERS: Array<string> = ['area', 'project']
 const PROGRESS_PER_FOLDER_FILENAME: string = 'progress-per-folder.csv'
 const TASK_COMPLETION_PER_FOLDER_FILENAME: string = 'task-completion-per-folder.csv'
+const PLUGIN_ID: string = 'jgclark.Reviews'
 
 //-----------------------------------------------------------------------------
 // Types
@@ -154,26 +157,25 @@ async function generateProjectsWeeklyProgressLines(): Promise<[Array<string>, Ar
     }
     const weekLabels: Array<string> = weeks.map((w) => w.label)
 
-    // 2. Get all regular notes from filtered folders (respecting existing Summaries exclusions)
+    // 2. Get all regular notes from filtered folders (respecting existing Projects exclusions)
     const allNotes = getRegularNotesFromFilteredFolders(foldersToExclude, true)
-    logDebug(pluginJson, `projectsWeeklyProgressCSV: considering ${String(allNotes.length)} regular notes`)
+    logDebug('generateProjectsWeeklyProgressLines', `considering ${String(allNotes.length)} regular notes`)
 
-    // 3. Filter notes to those whose folder name contains 'Area' or 'Project'
+    // 3. Filter notes to those whose folder name contains 'Area' or 'Project', and doesn't start or end with 'index' or 'MOC' (case-insensitive)
     const folderSet: Set<string> = new Set()
     const notesInTargetFolders = allNotes.filter((n) => {
       const folderPath = getFolderFromFilename(n.filename)
-      // const baseFolder = folderPath === '/' ? '/' : folderPath.split('/').pop() ?? folderPath
-      if (isAreaOrProjectFolder(folderPath)) {
+      if (isAreaOrProjectFolder(folderPath) && !n.title?.match(/^index $/i) && !n.title?.match(/ index$/i) && !n.title?.match(/^moc $/i) && !n.title?.match(/ moc$/i)) {
         folderSet.add(folderPath)
         return true
       }
       return false
     })
     const folders: Array<string> = Array.from(folderSet).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
-    logInfo(pluginJson, `projectsWeeklyProgressCSV: found ${String(folders.length)} Area/Project folders and ${String(notesInTargetFolders.length)} notes in them`)
+    logInfo('generateProjectsWeeklyProgressLines', `found ${String(folders.length)} Area/Project folders and ${String(notesInTargetFolders.length)} notes in them`)
 
     if (folders.length === 0) {
-      logInfo(pluginJson, `projectsWeeklyProgressCSV: no Area/Project folders found – nothing to write`)
+      logInfo('generateProjectsWeeklyProgressLines', `no Area/Project folders found – nothing to write`)
       return [[], []]
     }
 
@@ -184,8 +186,6 @@ async function generateProjectsWeeklyProgressLines(): Promise<[Array<string>, Ar
     // 5. Scan notes and paragraphs
     for (const note of notesInTargetFolders) {
       const folderPath = getFolderFromFilename(note.filename)
-      const baseFolder = folderPath === '/' ? '/' : folderPath.split('/').pop() ?? folderPath
-
       for (const p of note.paragraphs) {
         if (!isDone(p)) continue
         const doneISO = getDoneISODateFromContent(p.content)
@@ -194,7 +194,7 @@ async function generateProjectsWeeklyProgressLines(): Promise<[Array<string>, Ar
         const weekLabel = getWeekLabelForISODate(doneISO, weeks)
         if (!weekLabel) continue
 
-        const key = makeFolderWeekKey(baseFolder, weekLabel)
+        const key = makeFolderWeekKey(folderPath, weekLabel)
 
         // tasks-per-week
         const currentTasks = tasksPerWeekMap.get(key) ?? 0
@@ -209,15 +209,17 @@ async function generateProjectsWeeklyProgressLines(): Promise<[Array<string>, Ar
 
     // 6. Build CSV tables
     const notesRows: Array<string> = [
-      ['Folder / Notes progressed per week', ...weekLabels].join(','),
+      ['Folder / Notes progressed per week', ...weekLabels, 'total'].join(','),
     ]
     const tasksRows: Array<string> = [
-      ['Folder / Tasks completed per week', ...weekLabels].join(','),
+      ['Folder / Tasks completed per week', ...weekLabels, 'total'].join(','),
     ]
 
     for (const folderName of folders) {
       const noteCounts: Array<string> = []
+      let noteCountTotal = 0
       const taskCounts: Array<string> = []
+      let taskCountTotal = 0
 
       for (const weekLabel of weekLabels) {
         const key = makeFolderWeekKey(folderName, weekLabel)
@@ -225,17 +227,44 @@ async function generateProjectsWeeklyProgressLines(): Promise<[Array<string>, Ar
         const noteCount = noteSet ? noteSet.size : 0
         const taskCount = tasksPerWeekMap.get(key) ?? 0
         noteCounts.push(String(noteCount))
+        noteCountTotal += noteCount
         taskCounts.push(String(taskCount))
+        taskCountTotal += taskCount
       }
 
       // Note: surround folder name with quotes in case folder name contains commas
-      notesRows.push([`"${folderName}"`].concat(noteCounts).join(','))
-      tasksRows.push([`"${folderName}"`].concat(taskCounts).join(','))
+      notesRows.push([`"${folderName}"`].concat(noteCounts).concat(String(noteCountTotal)).join(','))
+      tasksRows.push([`"${folderName}"`].concat(taskCounts).concat(String(taskCountTotal)).join(','))
+    }
+
+    // Add totals row (sum of each column across all folders)
+    if (folders.length > 0) {
+      const notesColumnTotals: Array<number> = new Array<number>(weekLabels.length + 1).fill(0)
+      const tasksColumnTotals: Array<number> = new Array<number>(weekLabels.length + 1).fill(0)
+
+      for (const folderName of folders) {
+        const rowPartsNotes = notesRows.find((r) => r.startsWith(`"${folderName}"`))
+        const rowPartsTasks = tasksRows.find((r) => r.startsWith(`"${folderName}"`))
+        if (!rowPartsNotes || !rowPartsTasks) {
+          continue
+        }
+        const colsNotes = rowPartsNotes.split(',').slice(1).map((v) => Number(v) || 0)
+        const colsTasks = rowPartsTasks.split(',').slice(1).map((v) => Number(v) || 0)
+        colsNotes.forEach((val, idx) => {
+          notesColumnTotals[idx] += val
+        })
+        colsTasks.forEach((val, idx) => {
+          tasksColumnTotals[idx] += val
+        })
+      }
+
+      notesRows.push(['"TOTAL"', ...notesColumnTotals.map((n) => String(n))].join(','))
+      tasksRows.push(['"TOTAL"', ...tasksColumnTotals.map((n) => String(n))].join(','))
     }
-    logInfo(pluginJson, `projectsWeeklyProgressCSV: generated ${String(notesRows.length)} notes rows and ${String(tasksRows.length)} tasks rows in ${timer(startTime)}`)
+    logInfo('projectsWeeklyProgressCSV', `Generated ${String(notesRows.length)} notes rows and ${String(tasksRows.length)} tasks rows in ${timer(startTime)}`)
     return [notesRows, tasksRows]
   } catch (error) {
-    logError(pluginJson, `projectsWeeklyProgressCSV: ${error.message}`)
+    logError('projectsWeeklyProgressCSV', error.message)
     throw error
   }
 }
@@ -254,7 +283,7 @@ async function generateProjectsWeeklyProgressLines(): Promise<[Array<string>, Ar
  */
 export async function writeProjectsWeeklyProgressToCSV(): Promise<void> {
   try {
-    logDebug(pluginJson, `projectsWeeklyProgressCSV: starting`)
+    logDebug(pluginJson, `writeProjectsWeeklyProgressToCSV: starting`)
 
     const [notesRows, tasksRows] = await generateProjectsWeeklyProgressLines()
 
@@ -266,9 +295,235 @@ export async function writeProjectsWeeklyProgressToCSV(): Promise<void> {
     const tasksCsvString = tasksRows.join('\n')
     await DataStore.saveData(tasksCsvString, TASK_COMPLETION_PER_FOLDER_FILENAME, true)
 
-    logInfo(pluginJson, `projectsWeeklyProgressCSV: written weekly progress CSV to '${PROGRESS_PER_FOLDER_FILENAME}' and '${TASK_COMPLETION_PER_FOLDER_FILENAME}'`)
+    logInfo('writeProjectsWeeklyProgressToCSV', `Written weekly progress CSV to '${PROGRESS_PER_FOLDER_FILENAME}' and '${TASK_COMPLETION_PER_FOLDER_FILENAME}'`)
+  } catch (error) {
+    logError('writeProjectsWeeklyProgressToCSV', error.message)
+    throw error
+  }
+}
+
+//-----------------------------------------------------------------------------
+// Heatmap visualisation
+
+/**
+ * Convert the CSV-style rows returned by generateProjectsWeeklyProgressLines()
+ * into the data structure expected by AnyChart's heatMap chart.
+ * The header row is expected to be:
+ *   label,week1,week2,...,weekN,total
+ * Subsequent rows are:
+ *   "folder name",v1,v2,...,vN,total
+ * The TOTAL row is ignored.
+ * @param {Array<string>} rows
+ * @returns {Array<{x: string, y: string, heat: number}>}
+ */
+function buildHeatmapDataFromCSVRows(rows: Array<string>): Array<{ x: string, y: string, heat: number }> {
+  if (rows.length < 2) {
+    return []
+  }
+
+  const headerParts = rows[0].split(',')
+  if (headerParts.length < 3) {
+    return []
+  }
+
+  const weekLabels = headerParts.slice(1, -1)
+  const data = []
+
+  for (let i = 1; i < rows.length; i++) {
+    const line = rows[i]
+    if (!line || line.trim() === '') {
+      continue
+    }
+    const parts = line.split(',')
+    if (parts.length < weekLabels.length + 2) {
+      continue
+    }
+
+    const rawFolder = parts[0]
+    const folderName = rawFolder.startsWith('"') && rawFolder.endsWith('"')
+      ? rawFolder.slice(1, -1)
+      : rawFolder
+
+    if (folderName.toUpperCase() === 'TOTAL') {
+      continue
+    }
+
+    for (let w = 0; w < weekLabels.length; w++) {
+      const valStr = parts[1 + w]
+      const heat = Number(valStr) || 0
+      data.push({
+        x: weekLabels[w],
+        y: folderName,
+        heat,
+      })
+    }
+  }
+
+  return data
+}
+
+/**
+ * Render a heatmap for the given per-folder / per-week CSV rows in an HTML window.
+ * Uses AnyChart's heatMap chart in the same way as the Summaries plugin's heatmap generator.
+ * @param {Array<string>} rows
+ * @param {string} windowTitle
+ * @param {string} chartTitle
+ * @param {string} filenameToSave
+ * @param {string} windowID
+ * @returns {Promise<void>}
+ */
+async function showProjectsWeeklyProgressHeatmap(
+  rows: Array<string>,
+  windowTitle: string,
+  chartTitle: string,
+  filenameToSave: string,
+  windowID: string,
+): Promise<void> {
+  try {
+    const data = buildHeatmapDataFromCSVRows(rows)
+    if (data.length === 0) {
+      logInfo('showProjectsWeeklyProgressHeatmap', 'No heatmap data to display')
+      return
+    }
+
+    const dataAsString = JSON.stringify(data)
+
+    const heatmapCSS = `html, body, #container {
+  width: 100%;
+  height: 100%;
+  margin: 0px;
+  padding: 0px;
+  color: var(--fg-main-color);
+  background-color: var(--bg-main-color);
+}
+`
+
+    const preScript = `<!-- Load AnyChart scripts -->
+<script src="https://cdn.anychart.com/releases/8.7.1/js/anychart-core.min.js"></script>
+<script src="https://cdn.anychart.com/releases/8.7.1/js/anychart-heatmap.min.js"></script>
+`
+
+    const body = `
+<div id="container"></div>
+<script>
+  anychart.onDocumentReady(function () {
+    var chart = anychart.heatMap(${dataAsString});
+
+    chart.title("${chartTitle}");
+
+    var customColorScale = anychart.scales.linearColor();
+    customColorScale.colors(["#F4FFF4", "#09B009"]);
+    chart.colorScale(customColorScale);
+
+    chart.container("container");
+    chart.labels().enabled(false);
+    chart.xAxis().orientation('bottom');
+
+    // Format x-axis labels to
+    // - normally drop the leading "YYYY-" and show "WNN"
+    // - but on the first week of a new year (W00/W01), drop the "-WNN" part and show just "YYYY"
+    chart.xAxis().labels().format(function () {
+      var v = this.value;
+      if (!v || typeof v !== 'string') {
+        return v;
+      }
+      var parts = v.split('-W');
+      if (parts.length !== 2) {
+        return v;
+      }
+      var year = parts[0];
+      var week = parts[1];
+      if (week === '00' || week === '01') {
+        return year;
+      }
+      return 'W' + week;
+    });
+    <!-- Rotate x-axis labels to go (nearly) vertically upwards and center them vertically -->
+    chart.xAxis().labels().rotation(290); <!-- .hAlign('right').vAlign('center');-->
+
+    var tooltip = chart.tooltip();
+    tooltip.titleFormat('');
+    tooltip.padding().left(20);
+    tooltip.separator(false);
+    tooltip.format(function () {
+      if (this.heat != null && this.heat !== '' && !isNaN(this.heat)) {
+        return this.heat + ' items\\nFolder: ' + this.getData("y") + '\\nWeek: ' + this.getData("x");
+      } else {
+        return 'No data';
+      }
+    });
+
+    chart.xScroller().enabled(true);
+    chart.legend(true);
+    chart.draw();
+  });
+</script>
+`
+
+    const winOpts = {
+      windowTitle,
+      width: 800,
+      height: 500,
+      generalCSSIn: '',
+      specificCSS: heatmapCSS,
+      preBodyScript: preScript,
+      postBodyScript: '',
+      customId: windowID,
+      savedFilename: filenameToSave,
+      makeModal: false,
+      reuseUsersWindowRect: true,
+      shouldFocus: true,
+    }
+
+    await showHTMLV2(body, winOpts)
+    logInfo('showProjectsWeeklyProgressHeatmap', `Shown window titled '${windowTitle}'`)
+  } catch (error) {
+    logError('showProjectsWeeklyProgressHeatmap', error.message)
+  }
+}
+
+/**
+ * Generate weekly Area/Project folder progress stats and display them
+ * as two heatmaps:
+ * - Notes progressed per week
+ * - Tasks completed per week
+ * This reuses the HTML heatmap pattern from the Summaries plugin.
+ * @returns {Promise<void>}
+ */
+export async function showProjectsWeeklyProgressHeatmaps(): Promise<void> {
+  try {
+    logDebug(pluginJson, `showProjectsWeeklyProgressHeatmaps: starting`)
+
+    const [notesRows, tasksRows] = await generateProjectsWeeklyProgressLines()
+
+    if (notesRows.length === 0 && tasksRows.length === 0) {
+      logInfo('showProjectsWeeklyProgressHeatmaps', 'No weekly progress data available to visualise')
+      await showMessage('No weekly progress data available to visualise', 'OK', 'Weekly Progress Heatmaps')
+      return
+    }
+
+    // FIXME: Why does this not work if the following chart is also shown?
+    if (notesRows.length > 0) {
+      await showProjectsWeeklyProgressHeatmap(
+        notesRows,
+        'Projects Weekly Progress – Notes',
+        'Area/Project Notes progressed per week',
+        'projects-notes-weekly-progress-heatmap.html',
+        `${PLUGIN_ID}.projects-notes-weekly-progress-heatmap`,
+      )
+    }
+
+    if (tasksRows.length > 0) {
+      await showProjectsWeeklyProgressHeatmap(
+        tasksRows,
+        'Projects Weekly Progress – Tasks',
+        'Area/Project Tasks completed per week',
+        'projects-tasks-weekly-progress-heatmap.html',
+        `${PLUGIN_ID}.projects-tasks-weekly-progress-heatmap`,
+      )
+    }
   } catch (error) {
-    logError(pluginJson, `projectsWeeklyProgressCSV: ${error.message}`)
+    logError('showProjectsWeeklyProgressHeatmaps', error.message)
     throw error
   }
 }
diff --git a/jgclark.Reviews/src/reviewHelpers.js b/jgclark.Reviews/src/reviewHelpers.js
index d33d0bdd0..86c2a1348 100644
--- a/jgclark.Reviews/src/reviewHelpers.js
+++ b/jgclark.Reviews/src/reviewHelpers.js
@@ -2,7 +2,7 @@
 //-----------------------------------------------------------------------------
 // Helper functions for Review plugin
 // by Jonathan Clark
-// Last updated 2026-02-26 for v1.3.1, @jgclark
+// Last updated 2026-03-26 for v1.4.0.b13, @jgclark
 //-----------------------------------------------------------------------------
 
 //-----------------------------------------------------------------------------
@@ -10,6 +10,7 @@
 import { getActivePerspectiveDef, getAllowedFoldersInCurrentPerspective, getPerspectiveSettings } from '../../jgclark.Dashboard/src/perspectiveHelpers'
 import type { TPerspectiveDef } from '../../jgclark.Dashboard/src/types'
 import { type Progress } from './projectClass'
+import { checkString } from '@helpers/checkType'
 import { stringListOrArrayToArray } from '@helpers/dataManipulation'
 import {
   calcOffsetDate,
@@ -22,7 +23,7 @@ import {
 } from '@helpers/dateTime'
 import { clo, JSP, logDebug, logError, logInfo, logWarn } from '@helpers/dev'
 import { displayTitle } from '@helpers/general'
-import { getFrontmatterAttribute, noteHasFrontMatter, updateFrontMatterVars } from '@helpers/NPFrontMatter'
+import { endOfFrontmatterLineIndex, ensureFrontmatter, getFrontmatterAttribute, noteHasFrontMatter, updateFrontMatterVars } from '@helpers/NPFrontMatter'
 import { getFieldParagraphsFromNote } from '@helpers/paragraph'
 import { showMessage } from '@helpers/userInput'
 
@@ -58,19 +59,21 @@ export type ReviewConfig = {
   ignoreChecklistsInProgress: boolean,
   reviewedMentionStr: string,
   reviewIntervalMentionStr: string,
+  sequentialTag: string,
+  showFolderName: boolean,
   startMentionStr: string,
   nextReviewMentionStr: string,
   width: number,
   height: number,
   archiveUsingFolderStructure: boolean,
   archiveFolder: string,
-  removeDueDatesOnPause: boolean,
   nextActionTags: Array<string>,
   preferredWindowType: string,
-  sequentialTag: string,
+  autoUpdateAfterIdleTime?: number,
   progressHeading?: string,
   progressHeadingLevel: number,
   writeMostRecentProgressToFrontmatter?: boolean,
+  projectMetadataFrontmatterKey?: string,
   _logLevel: string,
   _logTimer: boolean,
 }
@@ -109,6 +112,16 @@ export async function getReviewSettings(externalCall: boolean = false): Promise<
     DataStore.setPreference('numberDaysForFutureToIgnore', config.numberDaysForFutureToIgnore)
     DataStore.setPreference('ignoreChecklistsInProgress', config.ignoreChecklistsInProgress)
 
+    // Frontmatter metadata preferences
+    // Allow any frontmatter key name, defaulting to 'project'
+    const rawSingleMetadataKeyName: string =
+      config.projectMetadataFrontmatterKey && typeof config.projectMetadataFrontmatterKey === 'string'
+        ? config.projectMetadataFrontmatterKey.trim()
+        : ''
+    const singleMetadataKeyName: string = rawSingleMetadataKeyName !== '' ? rawSingleMetadataKeyName : 'project'
+    config.projectMetadataFrontmatterKey = singleMetadataKeyName
+    DataStore.setPreference('projectMetadataFrontmatterKey', singleMetadataKeyName)
+
     // Set default for includedTeamspaces if not using Perspectives
     // Note: This value is only used when Perspectives are enabled, so the default doesn't affect filtering when Perspectives are off
     if (!config.usePerspectives) {
@@ -120,15 +133,14 @@ export async function getReviewSettings(externalCall: boolean = false): Promise<
       const perspectiveSettings: Array<TPerspectiveDef> = await getPerspectiveSettings(false)
       // Get the current Perspective
       const currentPerspective: any = getActivePerspectiveDef(perspectiveSettings)
-      // clo(currentPerspective, `currentPerspective`)
       config.perspectiveName = currentPerspective.name
-      logInfo('getReviewSettings', `Will use Perspective '${config.perspectiveName}', and will override any foldersToInclude, foldersToIgnore, and includedTeamspaces settings`)
+      logInfo('getReviewSettings', `Will use Perspective '${config.perspectiveName}', and its folder & teamspace settings`)
       config.foldersToInclude = stringListOrArrayToArray(currentPerspective.dashboardSettings?.includedFolders ?? '', ',')
-      config.foldersToIgnore = stringListOrArrayToArray(currentPerspective.dashboardSettings?.excludedFolders ?? '', ',')
-      config.includedTeamspaces = currentPerspective.dashboardSettings?.includedTeamspaces ?? ['private']
       // logDebug('getReviewSettings', `- foldersToInclude: [${String(config.foldersToInclude)}]`)
+      config.foldersToIgnore = stringListOrArrayToArray(currentPerspective.dashboardSettings?.excludedFolders ?? '', ',')
       // logDebug('getReviewSettings', `- foldersToIgnore: [${String(config.foldersToIgnore)}]`)
-      logDebug('getReviewSettings', `- includedTeamspaces: [${String(config.includedTeamspaces)}]`)
+      config.includedTeamspaces = currentPerspective.dashboardSettings?.includedTeamspaces ?? ['private']
+      // logDebug('getReviewSettings', `- includedTeamspaces: [${String(config.includedTeamspaces)}]`)
 
       const validFolders = getAllowedFoldersInCurrentPerspective(perspectiveSettings)
       logDebug('getReviewSettings', `-> validFolders for '${config.perspectiveName}': [${String(validFolders)}]`)
@@ -139,6 +151,11 @@ export async function getReviewSettings(externalCall: boolean = false): Promise<
       config.displayPaused = true
     }
 
+    // Ensure autoUpdateAfterIdleTime has a sensible default if missing from settings
+    if (config.autoUpdateAfterIdleTime == null) {
+      config.autoUpdateAfterIdleTime = 0
+    }
+
     // Ensure reviewsTheme has a default if missing (e.g. before 'Theme to use for Project Lists' setting existed)
     if (config.reviewsTheme == null || config.reviewsTheme === undefined) {
       config.reviewsTheme = ''
@@ -205,9 +222,10 @@ export function getNextActionLineIndex(note: CoreNoteFields, naTag: string): num
  */
 export function isProjectNoteIsMarkedSequential(note: TNote, sequentialTag: string): boolean {
   if (!sequentialTag) return false
-  const projectAttribute = getFrontmatterAttribute(note, 'project') ?? ''
+  const combinedKey = checkString(DataStore.preference('projectMetadataFrontmatterKey') || 'metadata')
+  const projectAttribute = getFrontmatterAttribute(note, combinedKey) ?? ''
   if (projectAttribute.includes(sequentialTag)) {
-    logDebug('isProjectNoteIsMarkedSequential', `found sequential tag '${sequentialTag}' in frontmatter 'project' attribute`)
+    logDebug('isProjectNoteIsMarkedSequential', `found sequential tag '${sequentialTag}' in frontmatter '${combinedKey}' attribute`)
     return true
   }
   const metadataLineIndex = getOrMakeMetadataLineIndex(note)
@@ -335,16 +353,18 @@ export function getOrMakeMetadataLineIndex(note: CoreNoteFields, metadataLinePla
 
     // If no metadataPara found, then insert one either after title, or in the frontmatter if present.
     if (Number.isNaN(lineNumber)) {
+      const singleMetadataKeyName = checkString(DataStore.preference('projectMetadataFrontmatterKey') || 'metadata')
       if (noteHasFrontMatter(note)) {
         logWarn('getOrMakeMetadataLineIndex', `I couldn't find an existing metadata line, so have added a placeholder at the top of the note. Please review it.`)
-        // $FlowIgnore[incompatible-call]
-        const res = updateFrontMatterVars(note, {
-          metadata: metadataLinePlaceholder,
-        })
+        const fmAttrs: { [string]: any } = {}
+        fmAttrs[singleMetadataKeyName] = metadataLinePlaceholder
+        // $FlowFixMe[incompatible-call]
+        const res = updateFrontMatterVars(note, fmAttrs)
         const updatedLines = note.paragraphs?.map((s) => s.content) ?? []
         // Find which line that project field is on
         for (let i = 1; i < updatedLines.length; i++) {
-          if (updatedLines[i].match(/^metadata:/i)) {
+          const re = new RegExp(`^${singleMetadataKeyName}:`, 'i')
+          if (updatedLines[i].match(re)) {
             lineNumber = i
             break
           }
@@ -363,57 +383,296 @@ export function getOrMakeMetadataLineIndex(note: CoreNoteFields, metadataLinePla
   }
 }
 
-//-------------------------------------------------------------------------------
+//------------------------------
+// Migration message when body metadata has been moved to frontmatter
+
+export const PROJECT_METADATA_MIGRATED_MESSAGE = '_Project metadata has been migrated to frontmatter._'
+
 /**
- * Update project metadata @mentions (e.g. @reviewed(date)) in the metadata line of the note in the Editor.
- * It takes each mention in the array (e.g. '@reviewed(2023-06-23)') and all other versions of it will be removed first, before that string is appended.
+ * Find the first body line that looks like project metadata, and return its index and content.
+ * Metadata-style lines are defined as lines that:
+ * - start with 'project:', 'metadata:', 'review:', or 'reviewed:'
+ * - or contain an '@review(...)' / '@reviewed(...)' mention
+ * - or start with a hashtag.
+ * @param {Array<TParagraph>} paras - all paragraphs in the note/editor
+ * @param {number} startIndex - index to start scanning from (usually after frontmatter)
+ * @returns {?{ index: number, content: string }} first matching line info, or null if none found
+ */
+function findFirstMetadataBodyLine(paras: Array<TParagraph>, startIndex: number): ?{ index: number, content: string } {
+  for (let i = startIndex; i < paras.length; i++) {
+    const p = paras[i]
+    const content = p.content ?? ''
+    const isMetadataStyleLine =
+      content.match(/^(project|metadata|review|reviewed):/i) != null ||
+      content.match(/(@review|@reviewed)\(.+\)/) != null ||
+      content.match(/^#\S/) != null
+
+    if (isMetadataStyleLine) {
+      return { index: i, content }
+    }
+  }
+  return null
+}
+
+/**
+ * If project metadata is now stored in frontmatter, then:
+ * - replace any existing project metadata line in the body with a short migration message, or
+ * - remove that migration message if it already exists.
+ * NOTE: This helper does not save/update the Editor; callers must handle persistence.
  * @author @jgclark
  * @param {TEditor} thisEditor - the Editor window to update
- * @param {Array<string>} mentions to update:
- * @returns { ?TNote } current note
  */
-export function updateMetadataInEditor(thisEditor: TEditor, updatedMetadataArr: Array<string>): void {
+export function migrateProjectMetadataLineInEditor(thisEditor: TEditor): void {
   try {
-    logDebug('updateMetadataInEditor', `Starting for '${displayTitle(Editor)}' with metadata ${String(updatedMetadataArr)}`)
-    
-    // Only proceed if we're in a valid Project note (with at least 2 lines)
-    if (thisEditor.note == null || thisEditor.note.type === 'Calendar' || thisEditor.note.paragraphs.length < 2) {
-      logWarn('updateMetadataInEditor', `- We're not in a valid Project note (and with at least 2 lines). Stopping.`)
+    // Bail if this isn't a valid project note (Notes type, at least 2 paragraphs).
+    if (thisEditor.note == null || thisEditor.note.type === 'Calendar' || thisEditor.paragraphs.length < 2) {
+      logWarn('migrateProjectMetadataLineInEditor', `- We're not in a valid Project note (and with at least 2 lines). Stopping.`)
       return
     }
-    const thisNote = thisEditor // note: not thisEditor.note
+    const noteForFM = thisEditor.note
+    logDebug('migrateProjectMetadataLineInEditor', `Starting for '${displayTitle(noteForFM)}'`)
+
+    // Check that project metadata is actually stored in frontmatter (configurable key or 'metadata').
+    const singleMetadataKeyName = checkString(DataStore.preference('projectMetadataFrontmatterKey') || 'metadata')
+    const metadataAttr = getFrontmatterAttribute(noteForFM, singleMetadataKeyName)
+    const metadataStrSavedFromBodyOfNote = typeof metadataAttr === 'string' ? metadataAttr.trim() : ''
+
+    // Scan the body only (after the closing ---). Find either the migration message or the first metadata-style line.
+    const paras = thisEditor.paragraphs
+    // const initialLineCount: number = paras.length
+    const endFMIndex = endOfFrontmatterLineIndex(noteForFM) ?? -1
+
+    // First pass: handle migration message line (if present)
+    for (let i = endFMIndex + 1; i < paras.length; i++) {
+      const p = paras[i]
+      const content = p.content ?? ''
+
+      // If we already left the migration message on a previous run, clear that line and we're done.
+      if (content === PROJECT_METADATA_MIGRATED_MESSAGE) {
+        logDebug('migrateProjectMetadataLineInEditor', `- Found existing migration message at line ${String(i)}; removing.`)
+        // v1: not working, and I can't see why
+        // thisEditor.removeParagraph(p)
+        // v2: also not working
+        // thisEditor.removeParagraphAtIndex(p.lineIndex)
+        // if (Editor.paragraphs.length === initialLineCount) {
+        //   logWarn('migrateProjectMetadataLineInEditor', `- Line count didn't change from ${String(initialLineCount)} after removing migration message. This shouldn't happen.`)
+        // }
+        // v3: just clear the message instead TEST:
+        p.content = ''
+        thisEditor.updateParagraph(p)
+        return
+      }
+    }
 
-    const metadataLineIndex: number = getOrMakeMetadataLineIndex(thisEditor)
-    // Re-read paragraphs, as they might have changed
-    const metadataPara = thisEditor.paragraphs[metadataLineIndex]
-    if (!metadataPara) {
-      throw new Error(`Couldn't get or make metadataPara for ${displayTitle(Editor)}`)
+    // Second pass: find the first metadata-style line in the body (if any).
+    const metadataInfo = findFirstMetadataBodyLine(paras, endFMIndex + 1)
+
+    // If we found an old metadata line in the body, first merge its contents into frontmatter (to avoid dropping mentions),
+    // then replace it with the migration message.
+    if (metadataInfo != null) {
+      // Decide which frontmatter key we are using (always use the configured combined-metadata key here)
+      const existingFMValue = metadataStrSavedFromBodyOfNote
+
+      // Strip any leading "project:" / "metadata:" / "review:" / "reviewed:" prefix from the body line
+      const bodyValue = metadataInfo.content.replace(/^(project|metadata|review|reviewed)\s*:\s*/i, '').trim()
+
+      if (bodyValue !== '') {
+        const mergedValue = (existingFMValue !== '' ? `${existingFMValue} ${bodyValue}` : bodyValue).replace(/\s{2,}/g, ' ').trim()
+        const fmAttrs: { [string]: any } = {}
+        fmAttrs[singleMetadataKeyName] = mergedValue
+        // $FlowFixMe[incompatible-call]
+        const mergedOK = updateFrontMatterVars(noteForFM, fmAttrs)
+        if (!mergedOK) {
+          logError(
+            'migrateProjectMetadataLineInEditor',
+            `Failed to merge body metadata line into frontmatter key '${singleMetadataKeyName}' for '${displayTitle(noteForFM)}'`,
+          )
+        } else {
+          logDebug(
+            'migrateProjectMetadataLineInEditor',
+            `- Merged body metadata into frontmatter key '${singleMetadataKeyName}' for '${displayTitle(noteForFM)}'`,
+          )
+        }
+      }
+
+      const metadataPara = paras[metadataInfo.index]
+      logDebug('migrateProjectMetadataLineInEditor', `- Replacing body metadata line at ${String(metadataInfo.index)} with migration message.`)
+      metadataPara.content = PROJECT_METADATA_MIGRATED_MESSAGE
+      thisEditor.updateParagraph(metadataPara)
+    }
+  } catch (error) {
+    logError('migrateProjectMetadataLineInEditor', error.message)
+  }
+}
+
+/**
+ * If project metadata is now stored in frontmatter, then:
+ * - replace any existing project metadata line in the body with a short migration message, or
+ * - remove that migration message if it already exists.
+ * NOTE: This helper does not save/update the note or cache; callers must handle persistence.
+ * @author @jgclark
+ * @param {CoreNoteFields} noteToUse - the note to update
+ */
+export function migrateProjectMetadataLineInNote(noteToUse: CoreNoteFields): void {
+  try {
+    // Bail if this isn't a valid project note (Notes type, at least 2 paragraphs).
+    if (noteToUse == null || noteToUse.type === 'Calendar' || noteToUse.paragraphs.length < 2) {
+      logWarn('migrateProjectMetadataLineInNote', `- We've not been passed a valid Project note (and with at least 2 lines). Stopping.`)
+      return
     }
+    logDebug('migrateProjectMetadataLineInNote', `Starting for '${displayTitle(noteToUse)}'`)
+
+    // Ensure we have a frontmatter section to write to. TEST: Is this needed?
+    if (!noteHasFrontMatter(noteToUse)) {
+      ensureFrontmatter(noteToUse)
+    }
+
+    // Check that project metadata is actually stored in frontmatter (configurable key or 'metadata').
+    const singleMetadataKeyName = checkString(DataStore.preference('projectMetadataFrontmatterKey') || 'metadata')
+    const metadataAttr = getFrontmatterAttribute((noteToUse: any), singleMetadataKeyName)
+    const metadataStrSavedFromBodyOfNote = typeof metadataAttr === 'string' ? metadataAttr.trim() : ''
+
+    // Scan the body only (after the closing ---). Find either the migration message or the first metadata-style line.
+    const paras = noteToUse.paragraphs
+    const endFMIndex = endOfFrontmatterLineIndex(noteToUse) ?? -1
+
+    // First pass: handle migration message line (if present)
+    for (let i = endFMIndex + 1; i < paras.length; i++) {
+      const p = paras[i]
+      const content = p.content ?? ''
+
+      // If we already left the migration message on a previous run, clear that line and we're done.
+      if (content === PROJECT_METADATA_MIGRATED_MESSAGE) {
+        logDebug('migrateProjectMetadataLineInNote', `- Found existing migration message at line ${String(i)}; clearing its content.`)
+        p.content = ''
+        noteToUse.updateParagraph(p)
+        return
+      }
+    }
+
+    // Second pass: find the first metadata-style line in the body (if any).
+    const metadataInfo = findFirstMetadataBodyLine(paras, endFMIndex + 1)
+
+    // If we found an old metadata line in the body, first merge its contents into frontmatter (to avoid dropping mentions),
+    // then replace it with the migration message.
+    if (metadataInfo != null) {
+      // Decide which frontmatter key we are using
+      const primaryKey = singleMetadataKeyName ?? 'metadata'
+      const existingFMValue = metadataStrSavedFromBodyOfNote
+
+      // Strip any leading "project:" / "metadata:" / "review:" / "reviewed:" prefix from the body line
+      const bodyValue = metadataInfo.content.replace(/^(project|metadata|review|reviewed)\s*:\s*/i, '').trim()
+
+      if (bodyValue !== '') {
+        logDebug('migrateProjectMetadataLineInNote', `- Merging body metadata into frontmatter key '${primaryKey}' with bodyValue '${bodyValue}'`)
+        const mergedValue = (existingFMValue !== '' ? `${existingFMValue} ${bodyValue}` : bodyValue).replace(/\s{2,}/g, ' ').trim()
+        const fmAttrs: { [string]: any } = {}
+        fmAttrs[primaryKey] = mergedValue
+        // $FlowFixMe[incompatible-call]
+        const mergedOK = updateFrontMatterVars((noteToUse: any), fmAttrs)
+        if (!mergedOK) {
+          logError('migrateProjectMetadataLineInNote',`Failed to merge body metadata line into frontmatter key '${primaryKey}' for '${displayTitle(noteToUse)}'`,)
+        } else {
+          logDebug('migrateProjectMetadataLineInNote',`- Merged body metadata into frontmatter key '${primaryKey}' for '${displayTitle(noteToUse)}'`,)
+        }
+      }
+
+      const metadataPara = paras[metadataInfo.index]
+      logDebug('migrateProjectMetadataLineInNote', `- Replacing body metadata line at ${String(metadataInfo.index)} with migration message.`)
+      metadataPara.content = PROJECT_METADATA_MIGRATED_MESSAGE
+      noteToUse.updateParagraph(metadataPara)
+    }
+  } catch (error) {
+    logError('migrateProjectMetadataLineInNote', error.message)
+  }
+}
+
+//-------------------------------------------------------------------------------
+// Other helpers (metadata mutation + delete)
+
+/**
+ * Core helper to update project metadata @mentions in a metadata line.
+ * Shared by updateMetadataInEditor and updateMetadataInNote.
+ * @param {CoreNoteFields | TEditor} noteLike - the note/editor to update
+ * @param {number} metadataLineIndex - index of the metadata line to use
+ * @param {Array<string>} updatedMetadataArr - full @mention strings to apply (e.g. '@reviewed(2023-06-23)')
+ * @param {string} logContext - name to use in log messages
+ */
+function updateMetadataCore(
+  noteLike: CoreNoteFields | TEditor,
+  metadataLineIndex: number,
+  updatedMetadataArr: Array<string>,
+  logContext: string,
+): void {
+  const metadataPara = noteLike.paragraphs[metadataLineIndex]
+  if (!metadataPara) {
+    throw new Error(`Couldn't get metadata line ${metadataLineIndex} from ${displayTitle(noteLike)}`)
+  }
+
+  const origLine: string = metadataPara.content
+  let updatedLine = origLine
 
-    const origLine: string = metadataPara.content
-    let updatedLine = origLine
+  const endFMIndex = endOfFrontmatterLineIndex(noteLike) ?? -1
+  const singleMetadataKeyName = checkString(DataStore.preference('projectMetadataFrontmatterKey') || 'metadata')
+  const frontmatterPrefixRe = new RegExp(`^${singleMetadataKeyName}:\\s*`, 'i')
+  const isFrontmatterLine = metadataLineIndex <= endFMIndex
 
-    logDebug(
-      'updateMetadataInEditor',
-      `starting for '${displayTitle(thisNote)}' for new metadata ${String(updatedMetadataArr)} with metadataLineIndex ${metadataLineIndex} ('${origLine}')`,
-    )
+  logDebug(
+    logContext,
+    `starting for '${displayTitle(noteLike)}' for new metadata ${String(updatedMetadataArr)} with metadataLineIndex ${metadataLineIndex} ('${origLine}')`,
+  )
 
+  if (isFrontmatterLine) {
+    let valueOnly = origLine.replace(frontmatterPrefixRe, '')
+    for (const item of updatedMetadataArr) {
+      const mentionName = item.split('(', 1)[0]
+      const RE_THIS_MENTION_ALL = new RegExp(`${mentionName}\\([\\w\\-\\.]+\\)`, 'gi')
+      valueOnly = valueOnly.replace(RE_THIS_MENTION_ALL, '')
+      valueOnly += ` ${item}`
+    }
+    const finalValue = valueOnly.replace(/\s{2,}/g, ' ').trim()
+    const fmAttrs: { [string]: any } = {}
+    fmAttrs[singleMetadataKeyName] = finalValue
+    // $FlowFixMe[incompatible-call]
+    const success = updateFrontMatterVars(noteLike, fmAttrs)
+    if (!success) {
+      logError(logContext, `Failed to update frontmatter ${singleMetadataKeyName} for '${displayTitle(noteLike)}'`)
+    } else {
+      logDebug(logContext, `- After update frontmatter ${singleMetadataKeyName}='${finalValue}'`)
+    }
+  } else {
     for (const item of updatedMetadataArr) {
       const mentionName = item.split('(', 1)[0]
-      // logDebug('updateMetadataInEditor', `Processing ${item} for ${mentionName}`)
-      // Start by removing all instances of this @mention
       const RE_THIS_MENTION_ALL = new RegExp(`${mentionName}\\([\\w\\-\\.]+\\)`, 'gi')
       updatedLine = updatedLine.replace(RE_THIS_MENTION_ALL, '')
-      // Then append this @mention
       updatedLine += ` ${item}`
-      // logDebug('updateMetadataInEditor', `-> ${updatedLine}`)
     }
-
-    // send update to Editor (removing multiple and trailing spaces)
     metadataPara.content = updatedLine.replace(/\s{2,}/g, ' ').trimRight()
-    thisEditor.updateParagraph(metadataPara)
-    // await saveEditorToCache() // might be stopping code execution here for unknown reasons
-    logDebug('updateMetadataInEditor', `- After update ${metadataPara.content}`)
+    noteLike.updateParagraph(metadataPara)
+    logDebug(logContext, `- After update ${metadataPara.content}`)
+  }
+}
+
+/**
+ * Update project metadata @mentions (e.g. @reviewed(date)) in the metadata line of the note in the Editor.
+ * It takes each mention in the array (e.g. '@reviewed(2023-06-23)') and all other versions of it will be removed first, before that string is appended.
+ * @author @jgclark
+ * @param {TEditor} thisEditor - the Editor window to update
+ * @param {Array<string>} mentions to update:
+ * @returns { ?TNote } current note
+ */
+export function updateMetadataInEditor(thisEditor: TEditor, updatedMetadataArr: Array<string>): void {
+  try {
+    logDebug('updateMetadataInEditor', `Starting for '${displayTitle(Editor)}' with metadata ${String(updatedMetadataArr)}`)
+
+    // Only proceed if we're in a valid Project note (with at least 2 lines)
+    if (thisEditor.note == null || thisEditor.note.type === 'Calendar' || thisEditor.note.paragraphs.length < 2) {
+      logWarn('updateMetadataInEditor', `- We're not in a valid Project note (and with at least 2 lines). Stopping.`)
+      return
+    }
+
+    const metadataLineIndex: number = getOrMakeMetadataLineIndex(thisEditor)
+    updateMetadataCore(thisEditor, metadataLineIndex, updatedMetadataArr, 'updateMetadataInEditor')
   } catch (error) {
     logError('updateMetadataInEditor', error.message)
   }
@@ -436,117 +695,86 @@ export function updateMetadataInNote(note: CoreNoteFields, updatedMetadataArr: A
     }
 
     const metadataLineIndex: number = getOrMakeMetadataLineIndex(note)
-    // Re-read paragraphs, as they might have changed
-    const metadataPara = note.paragraphs[metadataLineIndex]
-    if (!metadataPara) {
-      throw new Error(`Couldn't get or make metadataPara for ${displayTitle(note)}`)
-    }
-
-    const origLine: string = metadataPara.content
-    let updatedLine = origLine
-
-    logDebug(
-      'updateMetadataInNote',
-      `starting for '${displayTitle(note)}' for new metadata ${String(updatedMetadataArr)} with metadataLineIndex ${metadataLineIndex} ('${origLine}')`,
-    )
-
-    for (const item of updatedMetadataArr) {
-      const mentionName = item.split('(', 1)[0]
-      logDebug('updateMetadataInNote', `Processing ${item} for ${mentionName}`)
-      // Start by removing all instances of this @mention
-      const RE_THIS_MENTION_ALL = new RegExp(`${mentionName}\\([\\w\\-\\.]+\\)`, 'gi')
-      updatedLine = updatedLine.replace(RE_THIS_MENTION_ALL, '')
-      // Then append this @mention
-      updatedLine += ` ${item}`
-      logDebug('updateMetadataInNote', `-> ${updatedLine}`)
-    }
-
-    // update the note (removing multiple and trailing spaces)
-    metadataPara.content = updatedLine.replace(/\s{2,}/g, ' ').trimRight()
-    note.updateParagraph(metadataPara)
-    logDebug('updateMetadataInNote', `- After update ${metadataPara.content}`)
-
-    return
+    updateMetadataCore(note, metadataLineIndex, updatedMetadataArr, 'updateMetadataInNote')
   } catch (error) {
     logError('updateMetadataInNote', `${error.message}`)
-    return
   }
 }
 
-//-------------------------------------------------------------------------------
-// Other helpers
-
-export type IntervalDueStatus = {
-  color: string,
-  text: string
-}
 
 /**
- * Map a review interval (days until/since due) to a display color and label.
- * @param {number} interval - days until due (negative = overdue, positive = due in future)
- * @returns {{ color: string, text: string }}
+ * Internal helper to delete specific metadata mentions from a metadata line in a note-like object.
+ * Shared by deleteMetadataMentionInEditor and deleteMetadataMentionInNote.
+ * @param {CoreNoteFields | TEditor} noteLike - the note or editor to update
+ * @param {number} metadataLineIndex - index of the metadata line to use
+ * @param {Array<string>} mentionsToDeleteArr - mentions to delete (just the @mention name, not any bracketed date)
+ * @param {string} logContext - name to use in log messages
  */
-export function getIntervalDueStatus(interval: number): IntervalDueStatus {
-  if (interval < -90) return { color: 'red', text: 'project very overdue' }
-  if (interval < -14) return { color: 'red', text: 'project overdue' }
-  if (interval < 0) return { color: 'orange', text: 'project slightly overdue' }
-  if (interval > 30) return { color: 'blue', text: 'project due >month' }
-  return { color: 'green', text: 'due soon' }
-}
+function deleteMetadataMentionCore(
+  noteLike: CoreNoteFields | TEditor,
+  metadataLineIndex: number,
+  mentionsToDeleteArr: Array<string>,
+  logContext: string,
+): void {
+  const metadataPara = noteLike.paragraphs[metadataLineIndex]
+  if (!metadataPara) {
+    throw new Error(`Couldn't get metadata line ${metadataLineIndex} from ${displayTitle(noteLike)}`)
+  }
+  const origLine: string = metadataPara.content
+  let newLine = origLine
 
-/**
- * Map a review interval (days until/since next review) to a display color and label.
- * @param {number} interval - days until next review (negative = overdue, positive = due in future)
- * @returns {{ color: string, text: string }}
- */
-export function getIntervalReviewStatus(interval: number): IntervalDueStatus {
-  if (interval < -14) return { color: 'red', text: 'review overdue' }
-  if (interval < 0) return { color: 'orange', text: 'review slightly overdue' }
-  if (interval > 30) return { color: 'blue', text: 'review in >month' }
-  return { color: 'green', text: 'review soon' }
+  const endOfFrontmatterIndex = endOfFrontmatterLineIndex(noteLike) ?? -1
+  const singleMetadataKeyName = checkString(DataStore.preference('projectMetadataFrontmatterKey') || 'metadata')
+  const frontmatterPrefixRe = new RegExp(`^${singleMetadataKeyName}:\\s*`, 'i')
+  const isFrontmatterLine = metadataLineIndex <= endOfFrontmatterIndex
+
+  logDebug(logContext, `starting for '${displayTitle(noteLike)}' with metadataLineIndex ${metadataLineIndex} to remove [${String(mentionsToDeleteArr)}]`)
+
+  if (isFrontmatterLine) {
+    let valueOnly = origLine.replace(frontmatterPrefixRe, '')
+    for (const mentionName of mentionsToDeleteArr) {
+      const RE_THIS_MENTION_ALL = new RegExp(`${mentionName}(\\([\\d\\-\\.]+\\))?`, 'gi')
+      valueOnly = valueOnly.replace(RE_THIS_MENTION_ALL, '')
+      logDebug(logContext, `-> ${valueOnly}`)
+    }
+    const finalValue = valueOnly.replace(/\s{2,}/g, ' ').trim()
+    const fmAttrs: { [string]: any } = {}
+    fmAttrs[singleMetadataKeyName] = finalValue
+    // $FlowFixMe[incompatible-call]
+    const success = updateFrontMatterVars(noteLike, fmAttrs)
+    if (!success) {
+      logError(logContext, `Failed to update frontmatter ${singleMetadataKeyName} for '${displayTitle(noteLike)}'`)
+    } else {
+      logDebug(logContext, `- Finished frontmatter ${singleMetadataKeyName}='${finalValue}'`)
+    }
+  } else {
+    for (const mentionName of mentionsToDeleteArr) {
+      const RE_THIS_MENTION_ALL = new RegExp(`${mentionName}(\\([\\d\\-\\.]+\\))?`, 'gi')
+      newLine = newLine.replace(RE_THIS_MENTION_ALL, '')
+      logDebug(logContext, `-> ${newLine}`)
+    }
+    metadataPara.content = newLine.replace(/\s{2,}/g, ' ').trimRight()
+    noteLike.updateParagraph(metadataPara)
+    logDebug(logContext, `- Finished`)
+  }
 }
 
 /**
- * Update project metadata @mentions (e.g. @reviewed(date)) in the note in the Editor
+ * Delete specific metadata @mentions (e.g. @reviewed(date)) from the metadata line of the note in the Editor
  * @author @jgclark
  * @param {TEditor} thisEditor - the Editor window to update
+ * @param {number} metadataLineIndex - index of the metadata line to use
  * @param {Array<string>} mentions to update (just the @mention name, not and bracketed date)
  * @returns { ?TNote } current note
  */
-export function deleteMetadataMentionInEditor(thisEditor: TEditor, mentionsToDeleteArr: Array<string>): void {
+export function deleteMetadataMentionInEditor(thisEditor: TEditor, metadataLineIndex: number, mentionsToDeleteArr: Array<string>): void {
   try {
     // only proceed if we're in a valid Project note (with at least 2 lines)
     if (thisEditor.note == null || thisEditor.note.type === 'Calendar' || thisEditor.note.paragraphs.length < 2) {
       logWarn('deleteMetadataMentionInEditor', `- We're not in a valid Project note (and with at least 2 lines). Stopping.`)
       return
     }
-    const thisNote = thisEditor // note: not thisEditor.note
-
-    const metadataLineIndex: number = getOrMakeMetadataLineIndex(thisEditor)
-    // Re-read paragraphs, as they might have changed
-    const metadataPara = thisEditor.paragraphs[metadataLineIndex]
-    if (!metadataPara) {
-      throw new Error(`Couldn't get or make metadataPara for ${displayTitle(thisEditor)}`)
-    }
-
-    const origLine: string = metadataPara.content
-    let newLine = origLine
-
-    logDebug('deleteMetadataMentionInEditor', `starting for '${displayTitle(thisEditor)}' with metadataLineIndex ${metadataLineIndex} to remove [${String(mentionsToDeleteArr)}]`)
-
-    for (const mentionName of mentionsToDeleteArr) {
-      // logDebug('deleteMetadataMentionInEditor', `Processing ${item} for ${mentionName}`)
-      // Start by removing all instances of this @mention
-      const RE_THIS_MENTION_ALL = new RegExp(`${mentionName}(\\([\\d\\-\\.]+\\))?`, 'gi')
-      newLine = newLine.replace(RE_THIS_MENTION_ALL, '')
-      logDebug('deleteMetadataMentionInEditor', `-> ${newLine}`)
-    }
-
-    // send update to Editor (removing multiple and trailing spaces)
-    metadataPara.content = newLine.replace(/\s{2,}/g, ' ').trimRight()
-    thisEditor.updateParagraph(metadataPara)
-    // await saveEditorToCache() // seems to stop here but without error
-    logDebug('deleteMetadataMentionInEditor', `- Finished`)
+    deleteMetadataMentionCore(thisEditor, metadataLineIndex, mentionsToDeleteArr, 'deleteMetadataMentionInEditor')
   } catch (error) {
     logError('deleteMetadataMentionInEditor', `${error.message}`)
   }
@@ -556,39 +784,17 @@ export function deleteMetadataMentionInEditor(thisEditor: TEditor, mentionsToDel
  * Update project metadata @mentions (e.g. @reviewed(date)) in the note in the Editor
  * @author @jgclark
  * @param {TNote} noteToUse
+ * @param {number} metadataLineIndex - index of the metadata line to use
  * @param {Array<string>} mentions to update (just the @mention name, not and bracketed date)
  */
-export function deleteMetadataMentionInNote(noteToUse: CoreNoteFields, mentionsToDeleteArr: Array<string>): void {
+export function deleteMetadataMentionInNote(noteToUse: CoreNoteFields, metadataLineIndex: number, mentionsToDeleteArr: Array<string>): void {
   try {
     // only proceed if we're in a valid Project note (with at least 2 lines)
     if (noteToUse == null || noteToUse.type === 'Calendar' || noteToUse.paragraphs.length < 2) {
       logWarn('deleteMetadataMentionInNote', `- We've not been passed a valid Project note (and with at least 2 lines). Stopping.`)
       return
     }
-
-    const metadataLineIndex: number = getOrMakeMetadataLineIndex(noteToUse)
-    const metadataPara = noteToUse.paragraphs[metadataLineIndex]
-    if (!metadataPara) {
-      throw new Error(`Couldn't get or make metadataPara for ${displayTitle(noteToUse)}`)
-    }
-
-    const origLine: string = metadataPara.content
-    let newLine = origLine
-
-    logDebug('deleteMetadataMentionInNote', `starting for '${displayTitle(noteToUse)}' with metadataLineIndex ${metadataLineIndex} to remove [${String(mentionsToDeleteArr)}]`)
-
-    for (const mentionName of mentionsToDeleteArr) {
-      // logDebug('deleteMetadataMentionInNote', `Processing ${item} for ${mentionName}`)
-      // Start by removing all instances of this @mention
-      const RE_THIS_MENTION_ALL = new RegExp(`${mentionName}(\\([\\d\\-\\.]+\\))?`, 'gi')
-      newLine = newLine.replace(RE_THIS_MENTION_ALL, '')
-      logDebug('deleteMetadataMentionInNote', `-> ${newLine}`)
-    }
-
-    // send update to noteToUse (removing multiple and trailing spaces)
-    metadataPara.content = newLine.replace(/\s{2,}/g, ' ').trimRight()
-    noteToUse.updateParagraph(metadataPara)
-    logDebug('deleteMetadataMentionInNote', `- Finished`)
+    deleteMetadataMentionCore(noteToUse, metadataLineIndex, mentionsToDeleteArr, 'deleteMetadataMentionInNote')
   } catch (error) {
     logError('deleteMetadataMentionInNote', `${error.message}`)
   }
@@ -623,8 +829,8 @@ export async function updateDashboardIfOpen(): Promise<void> {
  */
 export function addFAIcon(faClasses: string, colorStr: string = ''): string {
   if (colorStr !== '') {
-    return `<span class="${faClasses}" style="color: ${colorStr}"></span>`
+    return `<i class="${faClasses}" style="color: ${colorStr}"></i>`
   } else {
-    return `<span class="${faClasses}"></span>`
+    return `<i class="${faClasses}"></i>`
   }
 }
diff --git a/jgclark.Reviews/src/reviews.js b/jgclark.Reviews/src/reviews.js
index 3e7060786..cbc15c2e6 100644
--- a/jgclark.Reviews/src/reviews.js
+++ b/jgclark.Reviews/src/reviews.js
@@ -11,7 +11,7 @@
 // It draws its data from an intermediate 'full review list' CSV file, which is (re)computed as necessary.
 //
 // by @jgclark
-// Last updated 2026-02-26 for v1.3.1, @jgclark
+// Last updated 2026-03-26 for v1.4.0.b13, @jgclark
 //-----------------------------------------------------------------------------
 
 import moment from 'moment/min/moment-with-locales'
@@ -20,14 +20,18 @@ import { checkForWantedResources, logAvailableSharedResources, logProvidedShared
 import {
   deleteMetadataMentionInEditor,
   deleteMetadataMentionInNote,
+  getOrMakeMetadataLineIndex,
   getNextActionLineIndex,
   getReviewSettings,
   isProjectNoteIsMarkedSequential,
+  migrateProjectMetadataLineInEditor,
+  migrateProjectMetadataLineInNote,
   type ReviewConfig,
   updateMetadataInEditor,
   updateMetadataInNote,
 } from './reviewHelpers'
 import {
+  copyDemoDefaultToAllProjectsList,
   filterAndSortProjectsList,
   getNextNoteToReview,
   getSpecificProjectFromList,
@@ -36,13 +40,24 @@ import {
 } from './allProjectsListHelpers.js'
 import { calcReviewFieldsForProject, Project } from './projectClass'
 import {
-  generateProjectOutputLine,
-  generateTopBarHTML,
-  generateHTMLForProjectTagSectionHeader,
-  generateTableStructureHTML,
-  generateProjectControlDialogHTML,
-  generateFolderHeaderHTML,
+  buildProjectLineForStyle,
+  buildProjectListTopBarHtml,
+  buildProjectControlDialogHtml,
+  buildFolderGroupHeaderHtml,
 } from './projectsHTMLGenerator.js'
+import {
+  stylesheetinksInHeader,
+  faLinksInHeader,
+  checkboxHandlerJSFunc,
+  scrollPreLoadJSFuncs,
+  commsBridgeScripts,
+  shortcutsScript,
+  autoRefreshScript,
+  setPercentRingJSFunc,
+  addToggleEvents,
+  displayFiltersDropdownScript,
+  tagTogglesVisibilityScript,
+} from './projectsHTMLTemplates.js'
 import { checkString } from '@helpers/checkType'
 import { getTodaysDateHyphenated, RE_DATE, RE_DATE_INTERVAL, todaysDateISOString } from '@helpers/dateTime'
 import { clo, JSP, logDebug, logError, logInfo, logTimer, logWarn, overrideSettingsWithEncodedTypedArgs } from '@helpers/dev'
@@ -50,6 +65,7 @@ import { getFolderDisplayName, getFolderDisplayNameForHTML } from '@helpers/fold
 import { createRunPluginCallbackUrl, displayTitle } from '@helpers/general'
 import { showHTMLV2, sendToHTMLWindow } from '@helpers/HTMLView'
 import { numberOfOpenItemsInNote } from '@helpers/note'
+import { saveSettings } from '@helpers/NPConfiguration'
 import { calcOffsetDateStr, nowLocaleShortDateTime } from '@helpers/NPdateTime'
 import { getOrOpenEditorFromFilename, getOpenEditorFromFilename, isNoteOpenInEditor, saveEditorIfNecessary } from '@helpers/NPEditor'
 import { getOrMakeRegularNoteInFolder } from '@helpers/NPnote'
@@ -62,9 +78,11 @@ import { getInputTrimmed, showMessage, showMessageYesNo } from '@helpers/userInp
 // Constants
 
 const pluginID = 'jgclark.Reviews'
-const windowTitle = `Project Review List`
-const filenameHTMLCopy = '../../jgclark.Reviews/review_list.html'
+const windowTitle = `Projects List`
+const windowTitleDemo = 'Projects List (Demo)'
+const filenameHTMLCopy = 'projects_list.html'
 const customRichWinId = `${pluginID}.rich-review-list`
+const customRichWinIdDemo = `${pluginID}.rich-review-list-demo`
 const customMarkdownWinId = `markdown-review-list`
 
 //-----------------------------------------------------------------------------
@@ -103,252 +121,8 @@ async function clearProjectReviewingInHTML(): Promise<void> {
   }
 }
 
-//-------------------------------------------------------------------------------
-// JS scripts
-
-const stylesheetinksInHeader = `
-<!-- Load in Project List-specific CSS -->
-<link href="projectList.css" rel="stylesheet">
-<link href="projectListDialog.css" rel="stylesheet">
-`
-const faLinksInHeader = `
-<!-- Load in fontawesome assets (licensed for NotePlan) -->
-<link href="../np.Shared/fontawesome.css" rel="stylesheet">
-<link href="../np.Shared/regular.min.flat4NP.css" rel="stylesheet">
-<link href="../np.Shared/solid.min.flat4NP.css" rel="stylesheet">
-<link href="../np.Shared/light.min.flat4NP.css" rel="stylesheet">
-`
-
-export const checkboxHandlerJSFunc: string = `
-<script type="text/javascript">
-async function handleCheckboxClick(cb) {
-  try {
-  console.log("Checkbox for " + cb.name + " clicked, new value = " + cb.checked);
-  const callbackURL = "noteplan://x-callback-url/runPlugin?pluginID=jgclark.Reviews&command=toggle"+cb.name;
-  console.log("Calling URL " + callbackURL + " ...");
-  // v1: use fetch() - doesn't work in plugin
-  // const res = await fetch(callbackURL);
-  // console.log("Result: " + res.status);
-  // v2: use window.open() - doesn't work in plugin
-  // window.open(callbackURL);
-  // v3: use window.location ... - doesn't work in plugin
-  // window.location.href = callbackURL;
-  // v4:
-  const options = {
-    method: 'GET',
-  }
-  fetch(callbackURL, options)
-  .then(response => {
-    console.log("Result: " + response.status);
-  })
-  .catch(error => {
-    console.log("Error Result: " + response.status);
-  });
-
-  // onChangeCheckbox(cb.name, cb.checked); // this uses handler func in commsSwitchboard.js
-  }
-  catch (err) {
-    console.error(err.message);
-  }
-}
-</script>
-`
-
-/**
- * Functions to get/set scroll position of the project list content.
- * Helped by https://stackoverflow.com/questions/9377951/how-to-remember-scroll-position-and-scroll-back
- * But need to find a different approach to store the position, as cookies not available.
- */
-export const scrollPreLoadJSFuncs: string = `
-<script type="text/javascript">
-function getCurrentScrollHeight() {
-  let scrollPos;
-  if (typeof window.pageYOffset !== 'undefined') {
-    scrollPos = window.pageYOffset;
-  }
-  else if (typeof document.compatMode !== 'undefined' && document.compatMode !== 'BackCompat') {
-    scrollPos = document.documentElement.scrollTop;
-  }
-  else if (typeof document.body !== 'undefined') {
-    scrollPos = document.body.scrollTop;
-  }
-  let label = document.getElementById("scrollDisplay");
-  label.innerHTML = String(scrollPos);
-  console.log("getCurrentScrollHeight = " + String(scrollPos));
-}
-
-// Note: saving scroll position to cookie does not work in Safari, but not in NP.
-function setScrollPos(h) {
-  document.documentElement.scrollTop = h;
-  document.body.scrollTop = h;
-  console.log('setScrollPos = ' + String(h));
-}
-</script>
-`
-
-const commsBridgeScripts = `
-<!-- commsBridge scripts -->
-<script type="text/javascript" src="../np.Shared/pluginToHTMLErrorBridge.js"></script>
-<script>
-/* you must set this before you import the CommsBridge file */
-const receivingPluginID = "jgclark.Reviews"; // the plugin ID of the plugin which will receive the comms from HTML
-// That plugin should have a function NAMED onMessageFromHTMLView (in the plugin.json and exported in the plugin's index.js)
-// this onMessageFromHTMLView will receive any arguments you send using the sendToPlugin() command in the HTML window
-
-/* The onMessageFromPlugin function is called when data is received from your plugin and needs to be processed.
- * This function should not do the work itself, it should just send the data payload to a function for processing.
- * The onMessageFromPlugin function below and your processing functions can be in your html document or could be imported in an external file.
- * The only requirement is that onMessageFromPlugin (and receivingPluginID) must be defined or imported before the 
-   pluginToHTMLCommsBridge in your html document or could be imported in an external file. */
-</script>
-<script type="text/javascript" src="./HTMLWinCommsSwitchboard.js"></script>
-<script type="text/javascript" src="../np.Shared/pluginToHTMLCommsBridge.js"></script>
-`
-/**
- * Script to add some keyboard shortcuts to control the dashboard. (Meta=Cmd here.)
- */
-const shortcutsScript = `
-<!-- shortcuts script -->
-<script type="text/javascript" src="./shortcut.js"></script>
-<script>
-// send 'refresh' command
-shortcut.add("meta+r", function() {
-  console.log("Shortcut '⌘r' triggered: will call refresh");
-  sendMessageToPlugin('refresh', {});
-});
-// send 'toggleDisplayOnlyDue' command
-shortcut.add("meta+d", function() {
-  console.log("Shortcut '⌘d' triggered: will call toggleDisplayOnlyDue");
-  sendMessageToPlugin('runPluginCommand', {pluginID: 'jgclark.Reviews', commandName:'toggleDisplayOnlyDue', commandArgs: []});
-});
-// send 'toggleDisplayFinished' command
-shortcut.add("meta+f", function() {
-  console.log("Shortcut '⌘f' triggered: will call toggleDisplayFinished");
-  sendMessageToPlugin('runPluginCommand', {pluginID: 'jgclark.Reviews', commandName: 'toggleDisplayFinished', commandArgs: []});
-});
-</script>
-`
-
-export const setPercentRingJSFunc: string = `
-<script>
-/**
- * Sets the value of a SVG percent ring.
- * @param {number} percent The percent value to set.
- */
-function setPercentRing(percent, ID) {
-  let svg = document.getElementById(ID);
-  let circle = svg.querySelector('circle');
-  const radius = circle.r.baseVal.value;
-  const circumference = radius * 2 * Math.PI;
-  circle.style.strokeDasharray = String(circumference) + ' ' + String(circumference);
-  circle.style.strokeDashoffset = String(circumference);
-
-  const offset = circumference - percent / 100 * circumference;
-  circle.style.strokeDashoffset = offset;  // Set to negative for anti-clockwise.
-
-  // let text = svg.querySelector('text');
-  // text.textContent = String(percent); // + '%';
-}
-</script>
-`
-
-const addToggleEvents: string = `
-<script>
-  /**
-   * Register click handlers for each checkbox/toggle in the window with details of the items.
-   * Skip checkboxes inside the Display filters dropdown (those use Save instead).
-   */
-  allInputs = document.getElementsByTagName("INPUT");
-  let added = 0;
-  for (const input of allInputs) {
-    if (input.type !== 'checkbox') continue;
-    if (input.getAttribute('data-display-filter') === 'true') continue;
-    const thisSettingName = input.name;
-    console.log("- adding event for checkbox '"+thisSettingName+"' currently set to state "+input.checked);
-    input.addEventListener('change', function (event) {
-      event.preventDefault();
-      sendMessageToPlugin('onChangeCheckbox', { settingName: thisSettingName, state: event.target.checked });
-    }, false);
-    added++;
-  }
-  console.log('- '+ String(added) + ' input ELs added');
-</script>
-`
-
-const displayFiltersDropdownScript: string = `
-<script>
-  (function() {
-    var btn = document.getElementById('displayFiltersButton');
-    var dropdown = document.getElementById('displayFiltersDropdown');
-    if (!btn || !dropdown) return;
-
-    var savedState = null;
-
-    function getCheckboxState() {
-      var onlyDue = dropdown.querySelector('input[name="displayOnlyDue"]');
-      var finished = dropdown.querySelector('input[name="displayFinished"]');
-      var paused = dropdown.querySelector('input[name="displayPaused"]');
-      var nextActions = dropdown.querySelector('input[name="displayNextActions"]');
-      return onlyDue && finished && paused && nextActions
-        ? { displayOnlyDue: onlyDue.checked, displayFinished: finished.checked, displayPaused: paused.checked, displayNextActions: nextActions.checked }
-        : null;
-    }
-
-    function closeDropdown(apply) {
-      dropdown.classList.remove('is-open');
-      btn.setAttribute('aria-expanded', 'false');
-      if (apply) {
-        var state = getCheckboxState();
-        if (state) {
-          // Only save + refresh if something actually changed while the dropdown was open
-          var hasChanges =
-            !savedState ||
-            state.displayOnlyDue !== savedState.displayOnlyDue ||
-            state.displayFinished !== savedState.displayFinished ||
-            state.displayPaused !== savedState.displayPaused ||
-            state.displayNextActions !== savedState.displayNextActions;
-          if (hasChanges) {
-            sendMessageToPlugin('saveDisplayFilters', state);
-          }
-        }
-      } else if (savedState) {
-        var onlyDue = dropdown.querySelector('input[name="displayOnlyDue"]');
-        var finished = dropdown.querySelector('input[name="displayFinished"]');
-        var paused = dropdown.querySelector('input[name="displayPaused"]');
-        var nextActions = dropdown.querySelector('input[name="displayNextActions"]');
-        if (onlyDue && finished && paused && nextActions) {
-          onlyDue.checked = savedState.displayOnlyDue;
-          finished.checked = savedState.displayFinished;
-          paused.checked = savedState.displayPaused;
-          nextActions.checked = savedState.displayNextActions;
-        }
-      }
-    }
 
-    btn.addEventListener('click', function(e) {
-      e.stopPropagation();
-      var isOpen = dropdown.classList.toggle('is-open');
-      btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
-      if (isOpen) savedState = getCheckboxState();
-    });
 
-    document.addEventListener('click', function(e) {
-      if (dropdown.classList.contains('is-open') && !dropdown.contains(e.target) && e.target !== btn) {
-        closeDropdown(true);
-      }
-    });
-
-    document.addEventListener('keydown', function(e) {
-      if (!dropdown.classList.contains('is-open')) return;
-      if (e.key === 'Escape') {
-        closeDropdown(false);
-      } else if (e.key === 'Enter') {
-        closeDropdown(true);
-      }
-    });
-  })();
-</script>
-`
 
 //-----------------------------------------------------------------------------
 // Main functions
@@ -373,9 +147,10 @@ export async function displayProjectLists(argsIn?: string | null = null, scrollP
       // clo(config, 'Review settings with no args:')
     }
 
-    // Re-calculate the allProjects list (in foreground)
-    await generateAllProjectsList(config, true)
-
+    if (!(config.useDemoData ?? false)) {
+      // Re-calculate the allProjects list (in foreground)
+      await generateAllProjectsList(config, true)
+    }
     // Call the relevant rendering function with the updated config
     await renderProjectLists(config, true, scrollPos)
   } catch (error) {
@@ -384,7 +159,45 @@ export async function displayProjectLists(argsIn?: string | null = null, scrollP
 }
 
 /**
- * Internal version of above that doesn't open window if not already open.
+ * Demo variant of project lists.
+ * Reads from fixed demo JSON (copied into allProjectsList.json) without regenerating from live notes.
+ * @param {string? | null} argsIn as JSON (optional)
+ * @param {number?} scrollPos in pixels (optional, for HTML only)
+ */
+export async function toggleDemoModeForProjectLists(): Promise<void> {
+  try {
+    const config = await getReviewSettings()
+    if (!config) throw new Error('No config found. Stopping.')
+    const isCurrentlyDemoMode = config.useDemoData ?? false
+    logInfo('toggleDemoModeForProjectLists', `Demo mode is currently ${isCurrentlyDemoMode ? 'ON' : 'off'}.`)
+    const willBeDemoMode = !isCurrentlyDemoMode
+    // Save a plain object so the value persists (loaded config may be frozen or a proxy)
+    const toSave = { ...config, useDemoData: willBeDemoMode }
+    const saved = await saveSettings(pluginJson['plugin.id'], toSave, false)
+    if (!saved) throw new Error('Failed to save demo mode setting.')
+
+    if (willBeDemoMode) {
+      // Copy the fixed demo list into allProjectsList.json (first time after switching to demo)
+      const copied = await copyDemoDefaultToAllProjectsList()
+      if (!copied) {
+        throw new Error('Failed to copy demo list. Please check that allProjectsDemoListDefault.json exists in data/jgclark.Reviews, and try again.')
+      }
+      logInfo('toggleDemoModeForProjectLists', 'Demo mode is now ON; project list copied from demo default.')
+    } else {
+      // First time after switching away from demo: re-generate list from live notes
+      logInfo('toggleDemoModeForProjectLists', 'Demo mode now off; regenerating project list from notes.')
+      await generateAllProjectsList(toSave, true)
+    }
+
+    // Now run the project lists display
+    await renderProjectLists(toSave, true)
+  } catch (error) {
+    logError('toggleDemoModeForProjectLists', JSP(error))
+  }
+}
+
+/**
+ * Internal version of earlier function that doesn't open window if not already open.
  * @param {number?} scrollPos 
  */
 export async function generateProjectListsAndRenderIfOpen(scrollPos: number = 0): Promise<any> {
@@ -393,12 +206,19 @@ export async function generateProjectListsAndRenderIfOpen(scrollPos: number = 0)
     if (!config) throw new Error('No config found. Stopping.')
     logDebug(pluginJson, `generateProjectListsAndRenderIfOpen() starting with scrollPos ${String(scrollPos)}`)
 
-    // Re-calculate the allProjects list (in foreground)
-    await generateAllProjectsList(config, true)
-    logDebug('generateProjectListsAndRenderIfOpen', `generatedAllProjectsList() called, and now will call renderProjectLists() if open`)
+    if (config.useDemoData ?? false) {
+      const copied = await copyDemoDefaultToAllProjectsList()
+      if (!copied) {
+        logWarn('generateProjectListsAndRenderIfOpen', 'Demo mode on but copy of demo list failed.')
+      }
+    } else {
+      // Re-calculate the allProjects list (in foreground)
+      await generateAllProjectsList(config, true)
+      logDebug('generateProjectListsAndRenderIfOpen', `generatedAllProjectsList() called, and now will call renderProjectListsIfOpen()`)
+    }
 
     // Call the relevant rendering function, but only continue if relevant window is open
-    await renderProjectLists(config, false, scrollPos)
+    await renderProjectListsIfOpen(config, scrollPos)
     return {} // just to avoid NP silently failing when called by invokePluginCommandByName
   } catch (error) {
     logError('displayProjectLists', JSP(error))
@@ -434,14 +254,19 @@ export async function renderProjectLists(
 }
 
 /**
- * Render the project list, according to the chosen output style. Note: this does *not* re-calculate the project list.
+ * Render the project list, according to the chosen output style. This does *not* re-calculate the project list.
+ * Note: Called by Dashboard, as well as internally.
+ * @param {any} configIn (optional; will look up if not given)
+ * @param {number} scrollPos for HTML view (optional; defaults to 0)
  * @author @jgclark
  */
 export async function renderProjectListsIfOpen(
-): Promise<any> {
+  configIn?: any,
+  scrollPos?: number = 0
+): Promise<boolean> {
   try {
     logInfo(pluginJson, `renderProjectListsIfOpen ----------------------------------------`)
-    const config = await getReviewSettings()
+    const config = configIn ? configIn : await getReviewSettings()
 
     // If we want Markdown display, call the relevant function with config, but don't open up the display window unless already open.
     if (config.outputStyle.match(/markdown/i)) {
@@ -449,12 +274,13 @@ export async function renderProjectListsIfOpen(
       renderProjectListsMarkdown(config, false)
     }
     if (config.outputStyle.match(/rich/i)) {
-      await renderProjectListsHTML(config, false)
+      await renderProjectListsHTML(config, false, scrollPos)
     }
-    // return {} just to avoid possibility of NP silently failing when called by invokePluginCommandByName
-    return {}
+    // return true to avoid possibility of NP silently failing when called by invokePluginCommandByName
+    return true
   } catch (error) {
     logError('renderProjectListsIfOpen', error.message)
+    return false
   }
 }
 
@@ -472,21 +298,23 @@ export async function renderProjectListsIfOpen(
 export async function renderProjectListsHTML(
   config: any,
   shouldOpen: boolean = true,
-  scrollPos: number = 0
+  scrollPos: number = 0,
 ): Promise<void> {
   try {
+    const useDemoData = config.useDemoData ?? false
     if (config.projectTypeTags.length === 0) {
       throw new Error('No projectTypeTags configured to display')
     }
 
-    if (!shouldOpen && !isHTMLWindowOpen(customRichWinId)) {
+    const richWinId = useDemoData ? customRichWinIdDemo : customRichWinId
+    if (!shouldOpen && !isHTMLWindowOpen(richWinId)) {
       logDebug('renderProjectListsHTML', `not continuing, as HTML window isn't open and 'shouldOpen' is false.`)
       return
     }
 
     const funcTimer = new moment().toDate() // use moment instead of `new Date` to ensure we get a date in the local timezone
     logInfo(pluginJson, `renderProjectLists ----------------------------------------`)
-    logDebug('renderProjectListsHTML', `Starting for ${String(config.projectTypeTags)} tags`)
+    logDebug('renderProjectListsHTML', `Starting for ${String(config.projectTypeTags)} tags${useDemoData ? ' (demo)' : ''}`)
 
     // Test to see if we have the font resources we want
     const res = await checkForWantedResources(pluginID)
@@ -501,59 +329,98 @@ export async function renderProjectListsHTML(
     // Ensure projectTypeTags is an array before proceeding
     if (typeof config.projectTypeTags === 'string') config.projectTypeTags = [config.projectTypeTags]
 
+    // Fetch project list first so we can compute per-tag active counts for the Filters dropdown
+    const [projectsToReview, _numberProjectsUnfiltered] = await filterAndSortProjectsList(config, '', [], true, useDemoData)
+    const wantedTags = config.projectTypeTags ?? []
+    const tagActiveCounts = wantedTags.map((tag) =>
+      projectsToReview.filter(
+        (p) =>
+          !p.isPaused &&
+          !p.isCancelled &&
+          !p.isCompleted &&
+          p.allProjectTags != null &&
+          p.allProjectTags.includes(tag)
+      ).length
+    )
+    config.tagActiveCounts = tagActiveCounts
+
     // String array to save all output
     const outputArray = []
 
-    // Generate top bar HTML
-    outputArray.push(generateTopBarHTML(config))
+    // Generate top bar HTML (uses config.tagActiveCounts for dropdown tag counts)
+    outputArray.push(buildProjectListTopBarHtml(config))
 
     // Start multi-col working (if space)
     outputArray.push(`<div class="multi-cols">`)
 
     logTimer('renderProjectListsHTML', funcTimer, `before main loop`)
-
-    // Make the Summary list, for each projectTag in turn
-    for (const thisTag of config.projectTypeTags) {
-      // Get the summary line for each revelant project
-      const [thisSummaryLines, noteCount, due] = await generateReviewOutputLines(thisTag, 'Rich', config)
-
-      // Generate project tag section header
-      outputArray.push(generateHTMLForProjectTagSectionHeader(thisTag, noteCount, due, config, config.projectTypeTags.length > 1))
-      
-      if (noteCount > 0) {
-        outputArray.push(generateTableStructureHTML(config, noteCount))
-        outputArray.push(thisSummaryLines.join('\n'))
-        outputArray.push('  </div>')
-        outputArray.push(' </div>') // details-content div
-        if (config.projectTypeTags.length > 1) {
-          outputArray.push(`</details>`)
+    const noteCount = projectsToReview.length
+    if (useDemoData && noteCount === 0) {
+      outputArray.push('<p class="project-grid-row demo-file-message">Demo file (allProjectsDemoList.json) not found or empty.</p>')
+    }
+    if (noteCount > 0) {
+      let lastFolder = ''
+      for (const thisProject of projectsToReview) {
+        if (!useDemoData) {
+          const thisNote = DataStore.projectNoteByFilename(thisProject.filename)
+          if (!thisNote) {
+            logWarn('renderProjectListsHTML', `Can't find note for filename ${thisProject.filename}`)
+            continue
+          }
         }
+        if (config.displayGroupedByFolder && lastFolder !== thisProject.folder) {
+          const folderDisplayName = getFolderDisplayNameForHTML(thisProject.folder)
+          let folderPart = folderDisplayName
+          if (config.hideTopLevelFolder) {
+            if (folderDisplayName.includes(']')) {
+              const match = folderDisplayName.match(/^(\[.*?\])\s*(.+)$/)
+              if (match) {
+                const pathPart = match[2]
+                const pathParts = pathPart.split('/').filter(p => p !== '')
+                folderPart = `${match[1]} ${pathParts.length > 0 ? pathParts[pathParts.length - 1] : pathPart}`
+              } else {
+                folderPart = folderDisplayName.split('/').slice(-1)[0] || folderDisplayName
+              }
+            } else {
+              const pathParts = folderDisplayName.split('/').filter(p => p !== '')
+              folderPart = pathParts.length > 0 ? pathParts[pathParts.length - 1] : folderDisplayName
+            }
+          }
+          if (thisProject.folder === '/') folderPart = '(root folder)'
+          outputArray.push(buildFolderGroupHeaderHtml(folderPart, config))
+        }
+        const wantedTagsForRow = (thisProject.allProjectTags != null && wantedTags.length > 0)
+          ? thisProject.allProjectTags.filter(t => wantedTags.includes(t))
+          : []
+        outputArray.push(buildProjectLineForStyle(thisProject, config, 'Rich', wantedTagsForRow))
+        lastFolder = thisProject.folder
       }
-      logTimer('renderProjectListsHTML', funcTimer, `end of loop for ${thisTag}`)
+      outputArray.push('  </div>')
     }
+    logTimer('renderProjectListsHTML', funcTimer, `end single section (${noteCount} projects)`)
 
     // Generate project control dialog HTML
-    outputArray.push(generateProjectControlDialogHTML())
+    outputArray.push(buildProjectControlDialogHtml())
 
     const body = outputArray.join('\n')
     logTimer('renderProjectListsHTML', funcTimer, `end of main loop`)
 
     const setScrollPosJS: string = `
 <script type="text/javascript">
-  console.log('Attemping to set scroll pos to ${scrollPos}');
+  <!-- console.log('Attemping to set scroll pos to ${scrollPos}'); -->
   setScrollPos(${scrollPos});
 </script>`
 
     const winOptions = {
-      windowTitle: windowTitle,
-      customId: customRichWinId,
-      headerTags: `${faLinksInHeader}${stylesheetinksInHeader}\n<meta name="startTime" content="${String(Date.now())}">`,
+      windowTitle: useDemoData ? windowTitleDemo : windowTitle,
+      customId: richWinId,
+      headerTags: `${faLinksInHeader}${stylesheetinksInHeader}\n<meta name="startTime" content="${String(Date.now())}">\n<meta name="autoUpdateAfterIdleTime" content="${String(config.autoUpdateAfterIdleTime ?? 0)}">`,
       generalCSSIn: generateCSSFromTheme(config.reviewsTheme), // either use dashboard-specific theme name, or get general CSS set automatically from current theme
-      specificCSS: '', // now in requiredFiles/reviewListCSS instead
+      specificCSS: '', // now in requiredFiles/projectList.css instead
       makeModal: false, // = not modal window
       bodyOptions: 'onload="showTimeAgo()"',
       preBodyScript: setPercentRingJSFunc + scrollPreLoadJSFuncs,
-      postBodyScript: checkboxHandlerJSFunc + setScrollPosJS + displayFiltersDropdownScript + `<script type="text/javascript" src="../np.Shared/encodeDecode.js"></script>
+      postBodyScript: checkboxHandlerJSFunc + setScrollPosJS + displayFiltersDropdownScript + tagTogglesVisibilityScript + autoRefreshScript + `<script type="text/javascript" src="../np.Shared/encodeDecode.js"></script>
       <script type="text/javascript" src="./showTimeAgo.js" ></script>
       <script type="text/javascript" src="./projectListEvents.js"></script>
       ` + commsBridgeScripts + shortcutsScript + addToggleEvents, // + collapseSection +  resizeListenerScript + unloadListenerScript,
@@ -570,7 +437,7 @@ export async function renderProjectListsHTML(
       iconColor: pluginJson['plugin.iconColor'],
       autoTopPadding: true,
       showReloadButton: true,
-      reloadCommandName: 'displayProjectLists',
+      reloadCommandName: useDemoData ? 'displayProjectListsDemo' : 'displayProjectLists',
       reloadPluginID: 'jgclark.Reviews',
     }
     const thisWindow = await showHTMLV2(body, winOptions)
@@ -789,7 +656,7 @@ export async function generateReviewOutputLines(projectTag: string, style: strin
         continue
       }
       // Make the output line for this project
-      const out = generateProjectOutputLine(thisProject, config, style)
+      const out = buildProjectLineForStyle(thisProject, config, style)
 
       // Add to number of notes to review (if appropriate)
       if (!thisProject.isPaused && thisProject.nextReviewDays != null && !isNaN(thisProject.nextReviewDays) && thisProject.nextReviewDays <= 0) {
@@ -831,7 +698,7 @@ export async function generateReviewOutputLines(projectTag: string, style: strin
         // Handle root folder display - check if original folder was root, not the display name
         if (folder === '/') folderPart = '(root folder)'
         if (style.match(/rich/i)) {
-          outputArray.push(generateFolderHeaderHTML(folderPart, config))
+          outputArray.push(buildFolderGroupHeaderHtml(folderPart, config))
         } else if (style.match(/markdown/i)) {
           outputArray.push(`### ${folderPart}`)
         }
@@ -888,20 +755,30 @@ async function finishReviewCoreLogic(note: CoreNoteFields): Promise<void> {
     }
 
     const possibleThisEditor = getOpenEditorFromFilename(note.filename)
-    if (possibleThisEditor) {
-      logDebug('finishReviewCoreLogic', `Updating Editor '${displayTitle(possibleThisEditor)}' ...`)
-      // First update @review(date) on current open note
-      updateMetadataInEditor(possibleThisEditor, [reviewedTodayString])
+    if (possibleThisEditor && possibleThisEditor.note != null) {
+      logDebug('finishReviewCoreLogic', `Updating EDITOR note '${displayTitle(possibleThisEditor.note)}' ...`)
+      // If project metadata is in frontmatter, replace any body metadata line with migration message (or remove that message)
+      // before we recalculate the metadata line index and update mentions. This ensures that when both frontmatter and
+      // body metadata are present, we first migrate/merge them and then clean up @nextReview/@reviewed mentions once.
+      migrateProjectMetadataLineInEditor(possibleThisEditor)
+      const metadataLineIndex: number = getOrMakeMetadataLineIndex(possibleThisEditor)
       // Remove a @nextReview(date) if there is one, as that is used to skip a review, which is now done.
-      deleteMetadataMentionInEditor(possibleThisEditor, [config.nextReviewMentionStr])
+      deleteMetadataMentionInEditor(possibleThisEditor, metadataLineIndex, [config.nextReviewMentionStr])
+      // Update @review(date) on current open note
+      updateMetadataInEditor(possibleThisEditor, [reviewedTodayString])
       await possibleThisEditor.save()
       // Note: no longer seem to need to update cache
     } else {
       logDebug('finishReviewCoreLogic', `Updating note '${displayTitle(note)}' ...`)
-      // First update @review(date) on the note
-      updateMetadataInNote(note, [reviewedTodayString])
+      // If project metadata is in frontmatter, replace any body metadata line with migration message (or remove that message)
+      // before we recalculate the metadata line index and update mentions. This ensures that when both frontmatter and
+      // body metadata are present, we first migrate/merge them and then clean up @nextReview/@reviewed mentions once.
+      migrateProjectMetadataLineInNote(note)
+      const metadataLineIndex: number = getOrMakeMetadataLineIndex(note)
       // Remove a @nextReview(date) if there is one, as that is used to skip a review, which is now done.
-      deleteMetadataMentionInNote(note, [config.nextReviewMentionStr])
+      deleteMetadataMentionInNote(note, metadataLineIndex, [config.nextReviewMentionStr])
+      // Update @review(date) on the note
+      updateMetadataInNote(note, [reviewedTodayString])
       // $FlowIgnore[prop-missing]
       DataStore.updateCache(note, true)
     }
@@ -926,11 +803,13 @@ async function finishReviewCoreLogic(note: CoreNoteFields): Promise<void> {
 
       // Save changes to allProjects list
       await updateProjectInAllProjectsList(thisNoteAsProject)
-      // Update display for user (but don't open if it isn't already)
-      await renderProjectLists(config, false)
+      // Update display for user (if window is already open)
+      // TODO: How can we keep the scrollPos?
+      await renderProjectListsIfOpen(config)
     } else {
       // Regenerate whole list (and display if window is already open)
       logInfo('finishReviewCoreLogic', `- In allProjects list couldn't find project '${note.filename}'. So regenerating whole list and will display if list is open.`)
+      // TODO: Split the following into just generate...(), and then move the renderProjectListsIfOpen() above to serve both if/else clauses
       await generateProjectListsAndRenderIfOpen()
     }
 
@@ -1179,9 +1058,9 @@ async function skipReviewCoreLogic(note: CoreNoteFields, skipIntervalOrDate: str
       // Write changes to allProjects list
       await updateProjectInAllProjectsList(thisNoteAsProject)
       // Update display for user (but don't open window if not open already)
-      await renderProjectLists(config, false)
+      await renderProjectListsIfOpen(config)
     } else {
-      // Regenerate whole list (and display if window is already open)      
+      // Regenerate whole list (and display if window is already open)
       logWarn('skipReviewCoreLogic', `- Couldn't find project '${note.filename}' in allProjects list. So regenerating whole list and display.`)
       await generateProjectListsAndRenderIfOpen()
     }
@@ -1322,7 +1201,7 @@ export async function setNewReviewInterval(noteArg?: TNote): Promise<void> {
       // Write changes to allProjects list
       await updateProjectInAllProjectsList(thisNoteAsProject)
       // Update display for user (but don't focus)
-      await renderProjectLists(config, false)
+      await renderProjectListsIfOpen(config)
     }
   } catch (error) {
     logError('setNewReviewInterval', error.message)
@@ -1353,7 +1232,8 @@ export async function toggleDisplayFinished(): Promise<void> {
     // logDebug('toggleDisplayFinished', `updatedConfig.displayFinished? now is '${String(updatedConfig.displayFinished)}'`)
     const res = await DataStore.saveJSON(updatedConfig, '../jgclark.Reviews/settings.json', true)
     // clo(updatedConfig, 'updatedConfig at end of toggle...()')
-    await renderProjectLists(updatedConfig, false)
+    // TODO: how to get scrollPos?
+    await renderProjectListsIfOpen(updatedConfig)
   }
   catch (error) {
     logError('toggleDisplayFinished', error.message)
@@ -1376,7 +1256,8 @@ export async function toggleDisplayOnlyDue(): Promise<void> {
     // logDebug('toggleDisplayOnlyDue', `updatedConfig.displayOnlyDue? now is '${String(updatedConfig.displayOnlyDue)}'`)
     const res = await DataStore.saveJSON(updatedConfig, '../jgclark.Reviews/settings.json', true)
     // clo(updatedConfig, 'updatedConfig at end of toggle...()')
-    await renderProjectLists(updatedConfig, false)
+    // TODO: how to get scrollPos?
+    await renderProjectListsIfOpen(updatedConfig)
   }
   catch (error) {
     logError('toggleDisplayOnlyDue', error.message)
@@ -1398,7 +1279,8 @@ export async function toggleDisplayNextActions(): Promise<void> {
     // logDebug('toggleDisplayNextActions', `updatedConfig.displayNextActions? now is '${String(updatedConfig.displayNextActions)}'`)
     const res = await DataStore.saveJSON(updatedConfig, '../jgclark.Reviews/settings.json', true)
     // clo(updatedConfig, 'updatedConfig at end of toggle...()')
-    await renderProjectLists(updatedConfig, false)
+    // TODO: how to get scrollPos?
+    await renderProjectListsIfOpen(updatedConfig)
   }
   catch (error) {
     logError('toggleDisplayNextActions', error.message)
@@ -1407,13 +1289,14 @@ export async function toggleDisplayNextActions(): Promise<void> {
 
 /**
  * Save all display filter settings at once (used by Display filters dropdown).
- * @param {{ displayOnlyDue: boolean, displayFinished: boolean, displayPaused: boolean, displayNextActions: boolean }} data
+ * @param {{ displayOnlyDue: boolean, displayFinished: boolean, displayPaused: boolean, displayNextActions: boolean, displayOrder?: string }} data
  */
 export async function saveDisplayFilters(data: {
   displayOnlyDue: boolean,
   displayFinished: boolean,
   displayPaused: boolean,
   displayNextActions: boolean,
+  displayOrder?: string,
 }): Promise<void> {
   try {
     const config: ReviewConfig = await getReviewSettings()
@@ -1421,8 +1304,11 @@ export async function saveDisplayFilters(data: {
     config.displayFinished = data.displayFinished
     config.displayPaused = data.displayPaused
     config.displayNextActions = data.displayNextActions
+    if (typeof data.displayOrder === 'string' && data.displayOrder !== '') {
+      config.displayOrder = data.displayOrder
+    }
     await DataStore.saveJSON(config, '../jgclark.Reviews/settings.json', true)
-    await renderProjectLists(config, false)
+    await renderProjectListsIfOpen(config)
   } catch (error) {
     logError('saveDisplayFilters', error.message)
   }
diff --git a/jgclark.Reviews/webfonts/fa-duotone-900.woff2 b/jgclark.Reviews/webfonts/fa-duotone-900.woff2
deleted file mode 100644
index 3f214a047..000000000
Binary files a/jgclark.Reviews/webfonts/fa-duotone-900.woff2 and /dev/null differ
diff --git a/jgclark.Reviews/webfonts/fa-regular-400.woff2 b/jgclark.Reviews/webfonts/fa-regular-400.woff2
deleted file mode 100644
index f08e2a2f5..000000000
Binary files a/jgclark.Reviews/webfonts/fa-regular-400.woff2 and /dev/null differ
diff --git a/jgclark.Reviews/webfonts/fa-solid-900.woff2 b/jgclark.Reviews/webfonts/fa-solid-900.woff2
deleted file mode 100644
index d75f8f7f4..000000000
Binary files a/jgclark.Reviews/webfonts/fa-solid-900.woff2 and /dev/null differ
