0.99.19: multiplayer authorship + task due-date reminders + full settings search

This commit is contained in:
Human 2026-06-11 22:49:44 -07:00
parent 4add22ecfc
commit 07638ea7fe
11 changed files with 351 additions and 63 deletions

100
main.js

File diff suppressed because one or more lines are too long

View file

@ -1,7 +1,7 @@
{
"id": "stashpad",
"name": "Stashpad",
"version": "0.99.14",
"version": "0.99.19",
"minAppVersion": "1.7.0",
"description": "Chat-style nested-notes view: rapid capture, outliner navigation, in-place editing.",
"author": "Human",

View file

@ -1,6 +1,6 @@
{
"name": "stashpad-obsidian",
"version": "0.99.14",
"version": "0.99.19",
"private": true,
"scripts": {
"dev": "node esbuild.config.mjs",

28
release-notes/0.99.19.md Normal file
View file

@ -0,0 +1,28 @@
# 0.99.19 — Multiplayer authorship, task reminders, full settings search
## Task due-date reminders
- Setting a due date on a task now actually reminds you. Because a plugin can't
run while the app is closed, reminders fire **when Obsidian next launches**
(for anything that came due while it was closed) and on a periodic check while
it's open. Each is a persistent notification with an **Open** button, recorded
in the notification history under a new **Reminder** category (and respecting
its mute toggle). A reminder won't re-fire for the same task + date once shown.
## Multiplayer authorship (shared vaults)
- **Assigning works across the whole vault now.** The "Assign to" / due-date
picker lists every author found anywhere in the vault — not just the ones this
device's registry happens to know — so a coworker who's only worked in *other*
folders shows up with their real identity, and assigning them no longer creates
a duplicate.
- **New folders auto-populate** with every known author, so you can assign anyone
immediately without waiting for them to contribute there first.
- **New command: "Sync authors across all folders."** Backfills existing folders
so every known author exists everywhere — handy after adding a collaborator.
## Settings search
- Every Stashpad setting is now findable in Obsidian's settings search, including
the **Authorship**, **Templates** (color aliases / note templates), and **JD
Index** pages — previously only their page names matched.

18
scripts/backup-dev-folder Executable file
View file

@ -0,0 +1,18 @@
#!/bin/sh
# Back up a Claude Dev Vault folder to a timestamped zip BEFORE running tests
# that might touch it (especially the real "Stashpad" folder, or vault-wide
# commands). Stored OUTSIDE the vault so it isn't indexed/imported.
# Usage: scripts/backup-dev-folder <FolderName> (e.g. Stashpad)
# Paths are relative to this script (scripts/ inside the dev repo): the dev vault
# is a sibling of the dev repo, under the parent project dir.
DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$DIR/../.." && pwd)" # parent of the dev repo (the project dir)
VAULT="$ROOT/Claude Dev Vault"
DEST="$ROOT/Claude Dev Vault Backups"
F="$1"
[ -n "$F" ] || { echo "usage: backup-dev-folder <FolderName>" >&2; exit 1; }
[ -d "$VAULT/$F" ] || { echo "no such folder: $VAULT/$F" >&2; exit 1; }
mkdir -p "$DEST"
TS=$(date "+%Y-%m-%d-%H%M%S")
OUT="$DEST/$F-$TS.zip"
( cd "$VAULT" && zip -rq "$OUT" "$F" ) && echo "backed up '$F' -> $OUT"

View file

@ -100,7 +100,7 @@ case "$1" in
try { window.__claudeTestBanner.hide(); } catch (e) {}
}
window.__claudeTestBanner = null;
new Notice('✅ Safe to use Stashpad again — Claude has finished testing.', 6000);
new Notice('✅ Safe to use Stashpad again — Claude has finished testing.', 0);
return 'cleared';
})()" >/dev/null 2>&1
echo "guard: banner cleared, all-clear shown"

View file

@ -16,6 +16,7 @@ import {
import { DEFAULT_STOPWORDS, bodyToSlug, buildFilename, parseIdFromFilename } from "./slug-service";
import { getActiveView, onActiveViewChange } from "./active-view";
import { importStashZip, STASH_EXT, splitFrontmatter } from "./stash-package";
import { formatDateTime } from "./format";
import { resolveStashBytes, isEncryptedStash } from "./stash-crypto";
import { StashpadLog } from "./log";
import { ROOT_ID } from "./types";
@ -291,6 +292,9 @@ export default class StashpadPlugin extends Plugin {
// 0.77.7: seed the local user's author page into the new folder so
// their links resolve everywhere from the start.
try { await this.seedLocalAuthorStub(cleaned); } catch {}
// 0.99.17 (#2): also seed every KNOWN author (coworkers from other folders)
// so a new folder auto-populates and you can assign anyone immediately.
try { await this.seedKnownAuthorsInFolder(cleaned); } catch {}
}
/** Tally per-note colors found in EVERY markdown file under `folder`.
@ -710,6 +714,11 @@ export default class StashpadPlugin extends Plugin {
// Vault is fully indexed now — safe to reconcile locked placeholders
// (drop entries whose blob is truly gone, add cross-device blobs).
void this.reconcileLockedRegistry();
// 0.99.19: fire reminders for tasks that came due while Obsidian was
// closed (delay so the metadata cache is populated), then re-check on an
// interval so tasks coming due while it's open also surface.
window.setTimeout(() => void this.checkDueReminders(), 6000);
this.registerInterval(window.setInterval(() => void this.checkDueReminders(), 5 * 60 * 1000));
window.setTimeout(() => { void this.seedLocalAuthorStubsEverywhere(); }, 4000);
// 0.79.12: register each Stashpad folder's _archive in Obsidian's
// "Excluded files" so native search / quick switcher / graph / link
@ -1408,6 +1417,11 @@ export default class StashpadPlugin extends Plugin {
// 0.58.0: rebootstrap as a command palette entry — mirrors the
// "Rebootstrap now" button in settings. Useful when troubleshooting
// / migrating without opening Settings.
this.addCommand({
id: "stashpad-sync-authors",
name: "Sync authors across all folders (multiplayer)",
callback: () => void this.syncAuthorsAcrossFolders(),
});
this.addCommand({
id: "stashpad-rebootstrap-all",
name: "Rebootstrap all Stashpad folders (backfill metadata + rename stale titles)",
@ -3620,29 +3634,150 @@ export default class StashpadPlugin extends Plugin {
* Used when assigning a task to someone so the assignee wikilink
* resolves. No-op if a stub for this id already exists in the dir
* (under any name). Also registers the author. */
async ensureAuthorStubFor(folder: string, id: string, name: string): Promise<void> {
if (!id || !name) return;
async ensureAuthorStubFor(folder: string, id: string, name: string): Promise<boolean> {
if (!id || !name) return false;
this.authorRegistry.record({ id, name });
const dir = `${folder.replace(/\/+$/, "")}/_authors`;
const exists = this.app.vault.getMarkdownFiles().some(
(f) => f.path.startsWith(dir + "/") && this.parseAuthorFilePath(f.path)?.id === id,
);
if (exists) return;
if (exists) return false;
const rec = this.authorRegistry.get(id);
const safe = this.authorNameToSafe(name);
const path = `${dir}/${safe}-${id}.md`;
try {
await this.ensureFolderPath(dir);
if (await this.app.vault.adapter.exists(path)) return;
if (await this.app.vault.adapter.exists(path)) return false;
await this.app.vault.create(path, this.buildAuthorStub(
{ id, name, role: rec?.role, department: rec?.department },
new Date().toISOString(),
));
return true;
} catch (e) {
console.warn("[Stashpad] ensureAuthorStubFor failed", path, e);
return false;
}
}
/** 0.99.17 (#2): seed EVERY known author (vault-wide) into `folder`'s
* `_authors/`, not just the local user so a new folder auto-populates with
* coworkers and assignment works without waiting for them to contribute. Each
* stub reuses the author's real id. Returns how many stubs were created. */
async seedKnownAuthorsInFolder(folder: string): Promise<number> {
let created = 0;
for (const a of this.collectKnownAuthors()) {
if (await this.ensureAuthorStubFor(folder, a.id, a.name)) created++;
}
return created;
}
/** 0.99.19: Task due-date reminders. Obsidian plugins can't fire while the app
* is closed, so this runs at LAUNCH (onLayoutReady) and on an interval while
* running: it finds tasks whose `due` has passed and that haven't been
* reminded yet (tracked by `<id>@<dueRaw>` in settings.notifiedDueKeys, so the
* same task+due never re-fires a changed due date re-keys and reminds again),
* shows a PERSISTENT notification under the "reminder" category (so it lands in
* the history log + respects mute), then records it. */
async checkDueReminders(): Promise<void> {
const now = Date.now();
const notified = new Set(this.settings.notifiedDueKeys ?? []);
const due: Array<{ id: string; folder: string; file: TFile; dueMs: number; key: string }> = [];
for (const f of this.app.vault.getMarkdownFiles()) {
if (f.path.includes("/_authors/")) continue;
const fm = this.app.metadataCache.getFileCache(f)?.frontmatter as { id?: unknown; due?: unknown } | undefined;
if (!fm || fm.due == null) continue;
const id = typeof fm.id === "string" ? fm.id : "";
if (!id) continue;
const dueRaw = String(fm.due);
const dueMs = typeof fm.due === "number" ? fm.due : Date.parse(dueRaw);
if (!Number.isFinite(dueMs) || dueMs > now) continue; // not due yet
const key = `${id}@${dueRaw}`;
if (notified.has(key)) continue;
due.push({ id, folder: (f.parent?.path ?? "").replace(/\/+$/, ""), file: f, dueMs, key });
}
if (due.length === 0) return;
// Record up front so the interval / a fast re-entry can't double-fire.
this.settings.notifiedDueKeys = [...(this.settings.notifiedDueKeys ?? []), ...due.map((d) => d.key)].slice(-2000);
await this.saveSettings();
const titleOf = async (file: TFile): Promise<string> => {
try {
const body = splitFrontmatter(await this.app.vault.cachedRead(file)).body;
const line = body.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0);
if (line) return line.replace(/^[#>\-*\s]+/, "").slice(0, 60);
} catch { /* fall through to filename */ }
return file.basename.replace(/-[a-z0-9]{4,12}$/, "").replace(/-/g, " ");
};
if (due.length <= 3) {
for (const d of due) {
const title = await titleOf(d.file);
this.notifications.show({
message: `⏰ Task due: “${title}” (${formatDateTime(d.dueMs, this.settings)})`,
kind: "warning", category: "reminder", duration: 0, folder: d.folder, affectedIds: [d.id],
actions: [{ label: "Open", onClick: () => void this.revealNoteByRef(d.folder, d.id) }],
});
}
} else {
this.notifications.show({
message: `${due.length} tasks are due — open the Tasks panel to review.`,
kind: "warning", category: "reminder", duration: 0, folder: "",
});
}
}
/** 0.99.17 (#3): the "centralized sync" rebuild the registry from the whole
* vault, then ensure every known author has a stub in every Stashpad folder.
* Backfills existing folders (new folders are handled at creation). */
async syncAuthorsAcrossFolders(): Promise<void> {
await this.rebuildAuthorRegistry(); // learn every author from the vault first
const authors = this.collectKnownAuthors();
const folders = this.discoverStashpadFolders();
if (!authors.length || !folders.length) { new Notice("No authors or Stashpad folders to sync."); return; }
const prog = folders.length * authors.length > 8 ? new Notice("", 0) : null;
let created = 0;
for (const folder of folders) {
prog?.setMessage(`Syncing authors → ${folder.split("/").pop()}`);
for (const a of authors) {
if (await this.ensureAuthorStubFor(folder, a.id, a.name)) created++;
}
}
prog?.hide();
this.notifications.show({
message: `Synced authors across ${folders.length} folder${folders.length === 1 ? "" : "s"}${created} new stub${created === 1 ? "" : "s"} (${authors.length} author${authors.length === 1 ? "" : "s"} known).`,
kind: "success", category: "system", folder: "",
});
}
/** 0.99.16: VAULT-WIDE list of known authors for the assignee pickers the
* union of the LOCAL registry (per-config, so in a shared vault it mostly
* knows just this user) AND a scan of every folder's `_authors/` stub files
* (id from the filename, display name from the stub's aliases/name). This is
* what surfaces COWORKERS who exist in shared folders but aren't in this
* device's registry the reason only the local user showed up before.
* Deduped by id (registry name wins); the local user is listed first. Also
* warms the registry with anything new it finds (idempotent after the first). */
collectKnownAuthors(): Array<{ id: string; name: string }> {
const byId = new Map<string, string>();
// The local user is always "known" (from settings), even before they have a
// stub or any authored notes — and listed first.
const myId = (this.settings.authorId ?? "").trim();
const myName = (this.settings.authorName ?? "").trim();
if (myId && myName) byId.set(myId, myName);
for (const a of this.authorRegistry.all()) if (!byId.has(a.id)) byId.set(a.id, a.name);
for (const f of this.app.vault.getMarkdownFiles()) {
if (!f.path.includes("/_authors/")) continue;
const parsed = this.parseAuthorFilePath(f.path);
if (!parsed) continue;
const fm = this.app.metadataCache.getFileCache(f)?.frontmatter as { aliases?: unknown; name?: unknown } | undefined;
const aliasName = Array.isArray(fm?.aliases)
? ((fm!.aliases as unknown[]).find((x) => typeof x === "string") as string | undefined ?? "")
: (typeof fm?.aliases === "string" ? fm.aliases : "");
const name = (aliasName || (typeof fm?.name === "string" ? fm.name : "") || parsed.name).trim();
if (!byId.has(parsed.id)) byId.set(parsed.id, name);
this.authorRegistry.record({ id: parsed.id, name }); // warm the registry (no-op if unchanged)
}
return [...byId.entries()].map(([id, name]) => ({ id, name }));
}
/** 0.77.2: rebuild the author registry from scratch by scanning the
* vault. The authoritative inputs are (a) the `_authors` stub files
* (id from filename, display name from `aliases`/`name`/H1, plus role/
@ -4141,6 +4276,9 @@ export default class StashpadPlugin extends Plugin {
notificationHistoryLimit: (typeof data?.notificationHistoryLimit === "number" && Number.isFinite(data.notificationHistoryLimit))
? data.notificationHistoryLimit
: 5000,
notifiedDueKeys: Array.isArray(data?.notifiedDueKeys)
? data.notifiedDueKeys.filter((x: unknown): x is string => typeof x === "string").slice(-2000)
: [],
drafts: normalizeDrafts(data?.drafts),
lastSubmitted: data?.lastSubmitted && typeof data.lastSubmitted === "object" ? data.lastSubmitted : {},
// Migrate: when slugStopWords has never been set on this install

View file

@ -24,6 +24,7 @@ export type NotificationCategory =
| "attachment"
| "color" | "reorder"
| "multiplayer"
| "reminder"
| "system";
/** Human-readable labels for each category, used by the settings UI
@ -45,6 +46,7 @@ export const CATEGORY_LABELS: Record<NotificationCategory, { label: string; desc
color: { label: "Color", desc: "Per-note color changes." },
reorder: { label: "Reorder", desc: "Drag-reorder and keyboard moveUp/Down/Top/Bottom." },
multiplayer: { label: "Multiplayer", desc: "Cross-author activity (someone else touched your notes or vice versa)." },
reminder: { label: "Reminder", desc: "Task due-date reminders — surfaced when a task comes due (fired at launch / periodically)." },
system: { label: "System", desc: "Plumbing toasts: backfill progress, integrity warnings, errors not tied to a verb." },
};

View file

@ -406,6 +406,9 @@ export interface StashpadSettings {
* Default 5000. Persisted alongside the live history in
* `<pluginDir>/notifications.json`. */
notificationHistoryLimit: number;
/** Keys (`<id>@<dueRaw>`) of task due-reminders already fired, so they don't
* re-fire on every launch. Bounded; pruned when it grows. */
notifiedDueKeys: string[];
/** 0.71.0 / 0.71.2: JD Index Builder. Two flavors:
* - "Preview" writes a single Index.md inside the designated
* Stashpad folder, showing the would-be hierarchy + non-matches.
@ -503,6 +506,7 @@ export const DEFAULT_SETTINGS: StashpadSettings = {
attachmentsOnlyNotes: {},
mutedNotificationCategories: [],
notificationHistoryLimit: 5000,
notifiedDueKeys: [],
autoNavOnMoveIn: false,
autoNavOnMoveOut: false,
autoExpandCursorRow: false,
@ -657,10 +661,12 @@ export class StashpadSettingTab extends PluginSettingTab {
case "diagnostics": return this.diagnosticsItems();
case "general": return this.generalItems();
case "encryption": return this.encryptionItems();
// authorship/templates/jdindex still render via the fresh-at-open `page:`
// path (searchable by page name). They're per-folder editors that don't
// decompose into clean per-setting entries; render-at-display via the page
// renderer keeps them correct. Returning null falls back to that path.
// 0.99.15: authorship/templates/jdindex decomposed too — static fields as
// per-setting items, the per-folder editors as sectionDefs (rendered fresh
// at display) — so individual settings are searchable, not just page names.
case "authorship": return this.authorshipItems();
case "templates": return this.templatesItems();
case "jdindex": return this.jdIndexItems();
default: return null;
}
}
@ -1449,6 +1455,89 @@ export class StashpadSettingTab extends PluginSettingTab {
* unique links). The id never changes once set, so already-stamped
* notes keep referring to the right person even if they rename
* themselves later. */
/** 0.99.15: Authorship tab decomposed for native settings search static
* fields as per-setting renderDefs; the dynamic "folders worked in" + "known
* authors" lists as sectionDefs (rendered fresh at display). */
private authorshipItems(): SettingDefinitionItem[] {
const items: SettingDefinitionItem[] = [];
items.push(this.renderDef("Author name",
"Your display name. Used in the note footer + as the author/contributor link target. Leave blank to opt out (notes won't be stamped).",
(s) => s.addText((t) => t.setValue(this.plugin.settings.authorName).onChange(async (v) => {
this.plugin.settings.authorName = v.trim();
if (this.plugin.settings.authorName && !this.plugin.settings.authorId) this.plugin.settings.authorId = newId();
await this.plugin.saveSettings();
await this.plugin.syncAuthorFilesToName();
})), ["author", "name", "identity", "stamp"]));
items.push(this.renderDef("Author id (auto-assigned)",
"Stable id appended to your name on links so coworkers with the same name don't collide. Generated once and shouldn't change. To reset it, clear and retype your author name.",
(s) => s.addText((t) => t.setValue(this.plugin.settings.authorId).setDisabled(true)), ["author", "id"]));
items.push(this.renderDef("Title / role",
"Optional. Shown on your author page (e.g. \"Engineer\", \"PM\", \"Designer\").",
(s) => s.addText((t) => t.setValue(this.plugin.settings.authorRole).onChange(async (v) => {
this.plugin.settings.authorRole = v.trim(); await this.plugin.saveSettings(); await this.plugin.syncAuthorFilesToName();
})), ["role", "title", "job"]));
items.push(this.renderDef("Department / team",
"Optional. Shown on your author page (e.g. \"Engineering\", \"Growth\").",
(s) => s.addText((t) => t.setValue(this.plugin.settings.authorDepartment).onChange(async (v) => {
this.plugin.settings.authorDepartment = v.trim(); await this.plugin.saveSettings(); await this.plugin.syncAuthorFilesToName();
})), ["department", "team"]));
const footerToggle = (name: string, get: () => boolean, put: (v: boolean) => void, aliases: string[]): SettingDefinitionItem =>
this.renderDef(name, "", (s) => s.addToggle((t) => t.setValue(get()).onChange(async (v) => { put(v); await this.plugin.saveSettings(); })), aliases);
items.push(footerToggle("Show author in note footer", () => this.plugin.settings.showAuthor, (v) => { this.plugin.settings.showAuthor = v; }, ["author", "footer", "show"]));
items.push(footerToggle("Show contributors in note footer", () => this.plugin.settings.showContributors, (v) => { this.plugin.settings.showContributors = v; }, ["contributors", "footer", "show"]));
items.push(footerToggle("Show last edit time in note footer", () => this.plugin.settings.showLastEdit, (v) => { this.plugin.settings.showLastEdit = v; }, ["last edit", "modified", "footer", "time"]));
items.push(this.sectionDef("Folders you've worked in",
"Folders where you've authored or contributed notes. Click one to open it.",
(host) => this.renderAuthoredFolders(host),
["folders", "authored", "contributed", "worked"]));
items.push(this.sectionDef("Known authors",
"Everyone the plugin has seen, with role/department + rename history; rebuild/restore the registry.",
(host) => this.renderKnownAuthorsSection(host),
["authors", "registry", "rename", "known", "rebuild"]));
return items;
}
/** The "folders you've worked in" list, extracted so the authorship sectionDef
* can render it fresh at display time. */
private renderAuthoredFolders(parent: HTMLElement): void {
const folders = this.plugin.collectAuthoredFolders();
if (folders.length === 0) { parent.createEl("p", { cls: "setting-item-description", text: "No authored or contributed folders yet." }); return; }
const list = parent.createDiv({ cls: "stashpad-authored-folders-list" });
for (const f of folders) {
const row = list.createDiv({ cls: "stashpad-authored-folder-row" });
const a = row.createEl("a", { cls: "stashpad-authored-folder-link", text: f.folder });
a.onclick = (e) => { e.preventDefault(); void this.plugin.activateViewForFolder(f.folder); };
const counts: string[] = [];
if (f.authored > 0) counts.push(`authored ${f.authored}`);
if (f.contributed > 0) counts.push(`contributed to ${f.contributed}`);
row.createSpan({ cls: "stashpad-authored-folder-counts", text: ` · ${counts.join(", ")}` });
}
}
/** 0.99.15: Templates tab — the two per-folder editors as searchable sections. */
private templatesItems(): SettingDefinitionItem[] {
return [
this.sectionDef("Color aliases",
"Give your note colors friendly names, per Stashpad folder.",
(host) => this.renderColorAliasesSection(host),
["color", "colour", "alias", "name", "swatch", "palette", "label"]),
this.sectionDef("Note templates",
"Per-Stashpad note templates — content stamped into new notes.",
(host) => this.renderNoteTemplatesSection(host),
["template", "note", "default", "boilerplate", "snippet"]),
];
}
/** 0.99.15: JD Index tab as a searchable section (scope/preview/build inside). */
private jdIndexItems(): SettingDefinitionItem[] {
return [
this.sectionDef("JD Index (Johnny Decimal)",
"Build a Johnny-Decimal-style index from dotted-prefix note titles — set the scope, preview, then build.",
(host) => this.renderJdIndexSection(host),
["jd", "johnny", "decimal", "index", "scope", "build", "preview", "hierarchy", "folder"]),
];
}
private renderAuthorshipSection(parent: HTMLElement): void {
parent.createEl("h3", { text: "Authorship" });
parent.createEl("p", {

View file

@ -7889,7 +7889,7 @@ export class StashpadView extends ItemView {
const current = curFm && (typeof curFm.due === "string" || typeof curFm.due === "number") ? String(curFm.due) : null;
// 0.78.1: offer known authors (registry, newest-first) for assignment,
// and pre-fill any assignees already on the first target.
const knownAuthors = this.plugin.authorRegistry.all().map((a) => ({ id: a.id, name: a.name }));
const knownAuthors = this.plugin.collectKnownAuthors();
const currentAssignees = parseAssignees(curFm ?? {});
new DueDatePickerModal(this.app, current, (result) => {
void this.applyDue(targets, result.iso, result.assignees);
@ -7984,7 +7984,7 @@ export class StashpadView extends ItemView {
if (targets.length === 0) { new Notice("Nothing to assign."); return; }
const first = targets[0];
const curFm = first.file ? this.app.metadataCache.getFileCache(first.file)?.frontmatter as any : null;
const knownAuthors = this.plugin.authorRegistry.all().map((a) => ({ id: a.id, name: a.name }));
const knownAuthors = this.plugin.collectKnownAuthors();
const currentAssignees = parseAssignees(curFm ?? {});
new AssignModal(this.app, { knownAuthors, currentAssignees }, (assignees) => {
void this.applyAssignees(targets, assignees);

View file

@ -4077,6 +4077,19 @@
background: var(--background-primary);
color: var(--text-normal);
}
/* 0.99.18: explicit selection + focus highlight so the auto-selected HH/MM text
reads clearly in BOTH the search "when" popover AND the due-date modal the
modal context was swallowing the default text-selection highlight. Same class
in both pickers, so this fixes both. */
.stashpad-when-time-field::selection {
background: var(--interactive-accent);
color: var(--text-on-accent);
}
.stashpad-when-time-field:focus {
outline: none;
border-color: var(--interactive-accent);
box-shadow: 0 0 0 2px var(--interactive-accent);
}
.stashpad-when-time-colon { font-size: 1.15em; color: var(--text-muted); }
.stashpad-when-time-period {
display: inline-flex;