From baa73bb3a36e6394ded18aa1cbff11803cbe832d Mon Sep 17 00:00:00 2001
From: stroblme
Date: Thu, 15 Oct 2026 22:03:36 +0200
Subject: [PATCH] no-em-dash pre-commit hook for user-facing strings, existing
em-dashes rewritten
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
frontend/scripts/no-em-dash.ts walks src/ with the TypeScript parser and flags
an em-dash in JSX text, string literals and template text. A bare "—"
placeholder passes, separators go in no-em-dash.allow.json. The script is
identical to index's copy.
---
.pre-commit-config.yaml | 10 +++
frontend/scripts/no-em-dash.allow.json | 1 +
frontend/scripts/no-em-dash.ts | 77 +++++++++++++++++++
.../components/Common/ConnectionNotice.tsx | 4 +-
.../components/Dashboard/DashboardEditor.tsx | 6 +-
frontend/src/components/Flow/FlowDock.tsx | 2 +-
frontend/src/components/Flow/FlowEditor.tsx | 2 +-
frontend/src/components/Flow/FlowNode.tsx | 6 +-
.../src/components/Health/LiveIndicator.tsx | 2 +-
frontend/src/components/Runs/RunsScreen.tsx | 6 +-
frontend/src/components/Runs/queries.ts | 2 +-
.../components/UserSettings/RemoteAccess.tsx | 4 +-
frontend/src/routes/_layout/alerts.tsx | 6 +-
frontend/src/routes/_layout/modules.tsx | 4 +-
frontend/src/routes/_layout/workers.tsx | 6 +-
frontend/src/utils.ts | 4 +-
16 files changed, 115 insertions(+), 27 deletions(-)
create mode 100644 frontend/scripts/no-em-dash.allow.json
create mode 100644 frontend/scripts/no-em-dash.ts
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 3db3dc1..4041dbd 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -40,6 +40,16 @@ repos:
language: pygrep
files: ^frontend/src/.*\.tsx$
+ # No em-dashes in user-facing strings; see Microcopy in the root
+ # DESIGN-GUIDELINES.md. A separator inside one line goes in
+ # frontend/scripts/no-em-dash.allow.json. Whole tree:
+ # `pre-commit run no-em-dash --all-files`.
+ - id: no-em-dash
+ name: no em-dashes in user-facing strings
+ entry: bun frontend/scripts/no-em-dash.ts
+ language: system
+ files: ^frontend/src/.*\.tsx?$
+
- id: local-ruff-check
name: ruff check
entry: uv run ruff check --force-exclude --fix --exit-non-zero-on-fix
diff --git a/frontend/scripts/no-em-dash.allow.json b/frontend/scripts/no-em-dash.allow.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/frontend/scripts/no-em-dash.allow.json
@@ -0,0 +1 @@
+{}
diff --git a/frontend/scripts/no-em-dash.ts b/frontend/scripts/no-em-dash.ts
new file mode 100644
index 0000000..23903b4
--- /dev/null
+++ b/frontend/scripts/no-em-dash.ts
@@ -0,0 +1,77 @@
+/**
+ * Flags em-dashes in user-facing strings: JSX text, string literals and
+ * template literal text under `src/`. Comments are not checked. See Microcopy
+ * in the root DESIGN-GUIDELINES.md.
+ *
+ * bun frontend/scripts/no-em-dash.ts [files…] # no files: all of src/
+ *
+ * A literal that is only "—" is the empty-value placeholder and passes. The
+ * other exception, a separator inside one line, goes in `no-em-dash.allow.json`
+ * as the file (relative to `src/`) and the exact string. A template literal is
+ * listed by its source text, backticks included.
+ *
+ * The same file lives in app/ and index/. Keep the two identical.
+ */
+
+import { readdirSync, readFileSync } from "node:fs"
+import { join, relative, resolve } from "node:path"
+import ts from "typescript"
+import allow from "./no-em-dash.allow.json"
+
+const src = resolve(import.meta.dir, "../src")
+const skip = /(^|\/)client\/|\.gen\.ts$|\.(check|spec|test)\.ts$/
+const dash = /—|—/
+
+const allowed = allow as Record
+const squash = (s: string) => s.replace(/\s+/g, " ").trim()
+
+const walk = (dir: string): string[] =>
+ readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
+ e.isDirectory() ? walk(join(dir, e.name)) : [join(dir, e.name)],
+ )
+
+const args = process.argv.slice(2).map((f) => resolve(f))
+const files = (args.length ? args : walk(src)).filter((f) => {
+ const rel = relative(src, f)
+ return /\.tsx?$/.test(f) && !rel.startsWith("..") && !skip.test(rel)
+})
+
+let found = 0
+for (const file of files) {
+ const rel = relative(src, file)
+ const sf = ts.createSourceFile(
+ file,
+ readFileSync(file, "utf8"),
+ ts.ScriptTarget.Latest,
+ true,
+ file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
+ )
+ const report = (node: ts.Node, text: string) => {
+ if (text === "—" || allowed[rel]?.includes(text)) return
+ const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf))
+ console.log(`${relative(process.cwd(), file)}:${line + 1}: ${text}`)
+ found++
+ }
+ const visit = (node: ts.Node) => {
+ if (ts.isTemplateExpression(node)) {
+ const parts = [node.head, ...node.templateSpans.map((s) => s.literal)]
+ if (parts.some((p) => dash.test(p.text))) report(node, node.getText(sf))
+ } else if (
+ (ts.isStringLiteral(node) ||
+ ts.isNoSubstitutionTemplateLiteral(node) ||
+ ts.isJsxText(node)) &&
+ dash.test(node.text)
+ ) {
+ report(node, squash(node.text))
+ }
+ ts.forEachChild(node, visit)
+ }
+ visit(sf)
+}
+
+if (found) {
+ console.log(
+ `\n${found} em-dash(es) in user-facing strings. Rewrite them (Microcopy in DESIGN-GUIDELINES.md) or allowlist a separator in frontend/scripts/no-em-dash.allow.json.`,
+ )
+ process.exit(1)
+}
diff --git a/frontend/src/components/Common/ConnectionNotice.tsx b/frontend/src/components/Common/ConnectionNotice.tsx
index 03954d2..a13894b 100644
--- a/frontend/src/components/Common/ConnectionNotice.tsx
+++ b/frontend/src/components/Common/ConnectionNotice.tsx
@@ -39,8 +39,8 @@ export function ConnectionNotice() {
}
notify(
connection.lastSeen
- ? `Instance offline — last seen ${ago(connection.lastSeen / 1000)}, reconnecting…`
- : "Instance offline — reconnecting…",
+ ? `Instance offline (last seen ${ago(connection.lastSeen / 1000)}), reconnecting…`
+ : "Instance offline, reconnecting…",
"warning",
{ id: ID, persistent: true },
)
diff --git a/frontend/src/components/Dashboard/DashboardEditor.tsx b/frontend/src/components/Dashboard/DashboardEditor.tsx
index 58604eb..33fac94 100644
--- a/frontend/src/components/Dashboard/DashboardEditor.tsx
+++ b/frontend/src/components/Dashboard/DashboardEditor.tsx
@@ -802,8 +802,8 @@ export function DashboardEditor({
>
{clipped.length === 1
- ? "1 widget sits below the canvas — a panel this size will not show it."
- : `${clipped.length} widgets sit below the canvas — a panel this size will not show them.`}
+ ? "1 widget sits below the canvas. A panel this size will not show it."
+ : `${clipped.length} widgets sit below the canvas. A panel this size will not show them.`}
diff --git a/frontend/src/components/Runs/queries.ts b/frontend/src/components/Runs/queries.ts
index eb186ec..231f1e9 100644
--- a/frontend/src/components/Runs/queries.ts
+++ b/frontend/src/components/Runs/queries.ts
@@ -283,7 +283,7 @@ export async function cancelThenDelete(runId: string) {
* left as an empty chart, which reads as a fault.
*/
export const NO_CURVE =
- "No curve was recorded. A node restored from the cache is read back from the run that produced it, so this draws nothing once that run has been deleted — its outputs are still on the result."
+ "No curve was recorded. A node restored from the cache is read back from the run that produced it, so this draws nothing once that run has been deleted. Its outputs are still on the result."
/**
* Which restored nodes lost their curve, and with which deleted run.
diff --git a/frontend/src/components/UserSettings/RemoteAccess.tsx b/frontend/src/components/UserSettings/RemoteAccess.tsx
index c736ec7..b8c89b7 100644
--- a/frontend/src/components/UserSettings/RemoteAccess.tsx
+++ b/frontend/src/components/UserSettings/RemoteAccess.tsx
@@ -159,7 +159,7 @@ export function RemoteAccess() {
Remote access
- Reach this instance from fluksio.com. Entirely optional — without it,
+ Reach this instance from fluksio.com. Entirely optional. Without it,
this instance talks to nothing outside your network.
@@ -179,7 +179,7 @@ export function RemoteAccess() {
>
) : status.last_error ? (
- `Reconnecting — ${status.last_error}`
+ `Reconnecting: ${status.last_error}`
) : (
"Reconnecting…"
)}
diff --git a/frontend/src/routes/_layout/alerts.tsx b/frontend/src/routes/_layout/alerts.tsx
index b426827..f4bf1c3 100644
--- a/frontend/src/routes/_layout/alerts.tsx
+++ b/frontend/src/routes/_layout/alerts.tsx
@@ -125,7 +125,7 @@ function ThisBrowser() {
if (!webpush.supported()) {
return (
- This browser cannot receive notifications. They need HTTPS — over plain
+ This browser cannot receive notifications. They need HTTPS. Over plain
http on a local address, no browser will allow them.
)
@@ -215,7 +215,7 @@ function AlertsForm({ initial }: { initial: AlertsConfig }) {
{draft.enabled
? "Failures reach the channels below."
- : "Switched off — failures only reach the log."}
+ : "Switched off. Failures only reach the log."}
- Adopted — this is the environment Fluksio itself is installed in.
- Add a package the way you added the rest:{" "}
+ Adopted. This is the environment Fluksio itself is installed in. Add
+ a package the way you added the rest:{" "}
pip install …, then sync or
restart so the workers pick it up. For a venv the engine owns
instead, set NODE_VENV=managed.
diff --git a/frontend/src/routes/_layout/workers.tsx b/frontend/src/routes/_layout/workers.tsx
index c5a73d1..7b1c841 100644
--- a/frontend/src/routes/_layout/workers.tsx
+++ b/frontend/src/routes/_layout/workers.tsx
@@ -117,12 +117,12 @@ function Machines() {
{String(provisioner.name)} can start{" "}
{(provisioner.profiles as string[]).join(", ")}
{outstanding.length > 0
- ? ` — asked for ${outstanding
+ ? `. Asked for ${outstanding
.map((job) => `${job.profile} (job ${job.job})`)
.join(", ")}`
: ""}
{provisioner.last_error
- ? ` — last failed: ${String(provisioner.last_error)}`
+ ? `. Last failed: ${String(provisioner.last_error)}`
: ""}
)
@@ -269,7 +269,7 @@ function Flavors() {
{flavor.cpus} cpu · {Math.round((flavor.ram ?? 0) / 1024)} GB
{flavor.gpus ? ` · ${flavor.gpus} gpu` : ""}
- {flavor.description ? ` — ${flavor.description}` : ""}
+ {flavor.description ? ` · ${flavor.description}` : ""}
{mayEdit ? (
diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts
index eb5f66c..c203d04 100644
--- a/frontend/src/utils.ts
+++ b/frontend/src/utils.ts
@@ -9,8 +9,8 @@ function extractErrorMessage(err: ApiError): string {
const offline = offlineDetail(err)
if (offline) {
return offline.lastSeen
- ? `Not delivered — the instance is offline (last seen ${ago(offline.lastSeen)})`
- : "Not delivered — the instance is offline"
+ ? `Not delivered. The instance is offline (last seen ${ago(offline.lastSeen)})`
+ : "Not delivered. The instance is offline"
}
if (err instanceof AxiosError) {