diff --git a/manifest.ts b/manifest.ts
index 737500f..673de5d 100644
--- a/manifest.ts
+++ b/manifest.ts
@@ -35,7 +35,7 @@ import type { PluginManifest } from '@@/plugins/manifest';
*/
export const manifest: PluginManifest = {
publisher: 'officerdev',
- version: '2026.08.29.08',
+ version: '2026.08.29.09',
platform: '>=1.0.0',
label: 'Fofos',
diff --git a/web/AdministrationPanel.tsx b/web/AdministrationPanel.tsx
index 9e99305..4818775 100644
--- a/web/AdministrationPanel.tsx
+++ b/web/AdministrationPanel.tsx
@@ -1,62 +1,26 @@
-import { Database, FolderRoot } from 'lucide-react';
import { RootLocationBlock } from './RootLocationBlock';
+import { DatabaseBlock } from './DatabaseBlock';
/**
* Administration — this plugin's own machinery, not the client's site.
*
- * ── It is a list of blocks ──
+ * A list of blocks, and nothing else. Each one owns its own heading, its own state and its own status
+ * message; this file decides only which of them exist and in what order.
*
- * Not one form. Administration holds several unrelated settings, and giving each its own titled block
- * means adding the next one moves nothing — as against a single form that has to be broken up the
- * first time anything joins it. The second block cost one entry, which is the point.
- *
- * The order is the order things are needed in: where the site is on disk, then how to read its data.
+ * The order is the order things are needed in: where the site is on this machine, then how to read its
+ * data.
*
* ── What used to be here ──
*
- * An address and a token: where fofos is, over HTTP. That is gone. It has NOT been replaced yet, so
- * nothing can currently configure this plugin — which is why `useReady` stays hardcoded false and
- * every other section stays locked. The two are the same change and neither is finished.
- *
- * The server half is untouched: `/_config` still stores a URL and a token, and the proxy in
- * `api/router.ts` still reads them. Only the way in from the screen is gone.
+ * An address and a token: where fofos is, over HTTP. That is gone from the screen. The server half is
+ * untouched — `/_config` still stores a URL and a token, and the proxy in `api/router.ts` still reads
+ * them — but nothing can set them, which is why `useReady` stays hardcoded false and every other
+ * section stays locked. The two are the same unfinished change.
*/
-/** One titled block. The shape every Administration setting gets, so the second one costs nothing. */
-const Block = ({
- icon: Icon,
- title,
- hint,
- children,
-}: {
- icon: typeof Database;
- title: string;
- hint: string;
- children?: React.ReactNode;
-}) => (
-
-
-
-
-
-
-
- {/* Deliberately empty. The block exists so the shape is settled and so the screen says what is
- coming rather than looking like a section nobody finished — but nothing is decided about what
- goes in it, and guessing at fields would be a decision made in the wrong place. */}
-
Nothing to set yet.
-
+
+
);
diff --git a/web/Block.tsx b/web/Block.tsx
new file mode 100644
index 0000000..78cc1d0
--- /dev/null
+++ b/web/Block.tsx
@@ -0,0 +1,48 @@
+import type { LucideIcon } from 'lucide-react';
+
+/**
+ * One titled block of Administration.
+ *
+ * Every setting gets one, so the second and third cost nothing to add — as against a single form that
+ * has to be broken up the first time anything joins it.
+ *
+ * ── `status` is top-right, and it is for things that come and go ──
+ *
+ * Beside the title rather than under the controls: it is a remark about the block, not part of what
+ * you are doing, and put in the flow it pushes everything below it down by a line and then pulls it
+ * back up — a layout that jumps every time somebody saves.
+ *
+ * Each block renders its OWN Block, rather than the panel wrapping them. The status belongs to the
+ * setting that produced it, and lifting that state up to the panel would put one block's timer in a
+ * component that has nothing to do with it.
+ */
+export const Block = ({
+ icon: Icon,
+ title,
+ hint,
+ status,
+ children,
+}: {
+ icon: LucideIcon;
+ title: string;
+ hint: string;
+ /** Top-right. Usually ephemeral — see `useFlash`. */
+ status?: React.ReactNode;
+ children?: React.ReactNode;
+}) => (
+
+
+
+
+
+ {title}
+
+
{hint}
+
+ {/* Reserves nothing when empty. The status is short and the title is not, so there is no reflow
+ to guard against — and an always-present placeholder would read as a field. */}
+ {status &&
{status}
}
+
+ {children}
+
+);
diff --git a/web/DatabaseBlock.tsx b/web/DatabaseBlock.tsx
new file mode 100644
index 0000000..af3d5b0
--- /dev/null
+++ b/web/DatabaseBlock.tsx
@@ -0,0 +1,15 @@
+import { Database } from 'lucide-react';
+import { Block } from './Block';
+
+/**
+ * Where the fofos site's own data lives.
+ *
+ * Deliberately empty. The block exists so the shape is settled and so the screen says what is coming
+ * rather than looking like a section nobody finished — but nothing is decided about what goes in it,
+ * and inventing fields to fill the space would be deciding it in the wrong place.
+ */
+export const DatabaseBlock = () => (
+
+
Nothing to set yet.
+
+);
diff --git a/web/RootLocationBlock.tsx b/web/RootLocationBlock.tsx
index aa8626d..25446be 100644
--- a/web/RootLocationBlock.tsx
+++ b/web/RootLocationBlock.tsx
@@ -1,14 +1,24 @@
import { useCallback, useEffect, useRef, useState } from 'react';
-import { Check, Loader2, Pencil } from 'lucide-react';
+import { Check, FolderRoot, Loader2, Pencil } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { readableError } from './errors';
import { DirectoryPicker } from './DirectoryPicker';
+import { Block } from './Block';
+import { useFlash } from './useFlash';
/**
* Where the fofos site is on this machine.
*
+ * ── "Saved" is a moment, not a state ──
+ *
+ * It appears top-right when a save actually lands and is gone five seconds later. As a permanent line
+ * it said nothing — everything on this screen has been saved, all the time — while looking exactly
+ * like feedback, so the one moment it meant something was indistinguishable from the rest.
+ *
+ * "Not set yet" is the opposite and stays: that IS a state, and it is the one worth knowing.
+ *
* ── The default is a suggestion until it is saved ──
*
* An unset root reports `/fofos` and says so. It is not written to the database by being
@@ -48,6 +58,7 @@ export const RootLocationBlock = () => {
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const [forbidden, setForbidden] = useState(false);
+ const [flashing, flash] = useFlash();
const load = useCallback(async () => {
try {
@@ -76,6 +87,7 @@ export const RootLocationBlock = () => {
setValue(result.rootPath);
setEditing(false);
setPicking(false);
+ flash();
} catch (failure) {
setError(readableError(failure));
} finally {
@@ -83,12 +95,18 @@ export const RootLocationBlock = () => {
}
};
+ const shell = (children: React.ReactNode, status?: React.ReactNode) => (
+
+ {children}
+
+ );
+
if (forbidden) {
- return
}
- {/* Says which of the two states this is in. "Not set" reading identically to "set to exactly the
- default" is how somebody comes to believe they configured something they did not. */}
-
- {saved.isSet ? 'Saved.' : 'Not set yet — this is the default until you save it.'}
-
+ {/* Only the state worth knowing. Once it is set, this line has nothing to say and goes. */}
+ {!saved.isSet && (
+
Not set yet — this is the default until you save it.
+ )}
{/* The block's bottom row. Everything you can DO is here, on its own line, so the path above it
gets the full width. */}
@@ -163,6 +180,12 @@ export const RootLocationBlock = () => {
)}
-
+ ,
+ flashing ? (
+
+
+ Saved
+
+ ) : null,
);
};
diff --git a/web/useFlash.ts b/web/useFlash.ts
new file mode 100644
index 0000000..375d0f4
--- /dev/null
+++ b/web/useFlash.ts
@@ -0,0 +1,33 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+/**
+ * A message that shows itself and then goes away — "Saved", and whatever joins it.
+ *
+ * ── Why this is not a piece of state somebody sets and clears ──
+ *
+ * The timer is held in a ref and CLEARED before each new one is set. Without that, saving twice in
+ * quick succession leaves the first timeout running, and it fires against the second save — the
+ * confirmation vanishes a moment after appearing, which looks like the second save failed.
+ *
+ * The cleanup on unmount matters for the same reason it always does: this lives in a panel that is
+ * swapped out whenever the nav moves, and a timeout that outlives its component sets state on nothing.
+ */
+export const useFlash = (ms = 5000) => {
+ const [showing, setShowing] = useState(false);
+ const timer = useRef | null>(null);
+
+ const clear = () => {
+ if (timer.current) clearTimeout(timer.current);
+ timer.current = null;
+ };
+
+ const flash = useCallback(() => {
+ clear();
+ setShowing(true);
+ timer.current = setTimeout(() => setShowing(false), ms);
+ }, [ms]);
+
+ useEffect(() => clear, []);
+
+ return [showing, flash] as const;
+};