artifacty 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/render.js CHANGED
@@ -1,5 +1,7 @@
1
+ import { uiIcon } from "./ui-icons.js";
2
+ import { readFileSync } from "node:fs";
1
3
  import { EDITOR_CLIENT_PATH, VIEWER_CLIENT_PATH, editorImportMapJson } from "./editor-assets.js";
2
- import { createI18n, DEFAULT_LOCALE, editorMessages, localizedHref, switchLocaleHref } from "./i18n.js";
4
+ import { createI18n, DEFAULT_LOCALE, editorMessages, localizedHref, availableLanguages } from "./i18n.js";
3
5
  import { ARTIFACT_FORMATS, ARTIFACT_TYPES, REVIEW_STATUSES, SAVED_VIEW_FILTER_KEYS, VISIBILITY_VALUES } from "./storage.js";
4
6
  import { normalizeMaxEntries as resolveMaxDiffEntries } from "./diff.js";
5
7
  import { parseCsv } from "./csv.js";
@@ -12,62 +14,62 @@ import {
12
14
  import { isNotebookObject } from "./converters.js";
13
15
  import { GROUP_BY_VALUES, groupKeyFor } from "./listing.js";
14
16
 
17
+ const APP_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
18
+
15
19
  export function renderDashboard({ artifacts, baseUrl, filters = {}, pagination, locale = DEFAULT_LOCALE, currentPath = "/", user = null, savedViews = [], embeddingsAvailable = false }) {
16
20
  const view = viewContext(locale, currentPath);
17
21
  const total = pagination?.total ?? artifacts.length;
18
22
  const start = artifacts.length ? (pagination?.offset ?? 0) + 1 : 0;
19
23
  const end = artifacts.length ? (pagination?.offset ?? 0) + artifacts.length : 0;
20
- const searchBackend = pagination?.search?.backend;
21
- const pager = renderDashboardPager({ pagination, filters, view });
24
+ const pager = renderDashboardPager({ pagination: pagination || { total, limit: Number(filters.limit) || 50, offset: 0 }, filters, view });
22
25
  const rowsHtml = renderArtifactRows(artifacts, filters.groupBy, view);
23
- const sidebar = renderSavedViewsSidebar({ savedViews, filters, view });
26
+ const activeFilters = [
27
+ ["tag", "filter.tag"], ["sourceAgent", "filter.source"], ["artifactType", "filter.artifactType"],
28
+ ["publisher", "filter.publisher"], ["createdAfter", "filter.createdAfter"], ["createdBefore", "filter.createdBefore"],
29
+ ["reviewStatus", "filter.reviewStatus"], ["mode", "filter.mode"], ["groupBy", "filter.groupBy"],
30
+ ["relatedTo", "filter.relatedTo"], ["relation", "filter.relation"], ["includeArchived", "filter.archived"]
31
+ ].filter(([key]) => filters[key]);
32
+ const field = (key, label, type = "text") => `<label class="dashboard-field"><span>${view.text(label)}</span><input type="${type}" name="${key}" value="${escapeAttribute(filters[key] || "")}" class="form-control"></label>`;
33
+ const selectField = (label, select) => `<label class="dashboard-field"><span>${view.text(label)}</span>${select}</label>`;
24
34
 
25
35
  return pageShell({
26
36
  title: "Artifacty",
27
37
  body: `
28
- <header class="topbar">
29
- <a class="brand" href="${view.href("/")}">
30
- ${brandMark()}
31
- <span class="brand-text">
32
- <span class="brand-name">artifacty</span>
33
- <span class="brand-tag">local artifact exchange · ${escapeHtml(baseUrl)}</span>
34
- </span>
35
- </a>
36
- <nav>
37
- <a href="${view.href("/new")}">${view.text("nav.new")}</a>
38
- <a href="${view.href("/import")}">${view.text("nav.import")}</a>
39
- <a href="/api/artifacts">${view.text("nav.api")}</a>
40
- ${authNav(user, view)}
41
- ${languageSwitcher(view)}
42
- </nav>
43
- </header>
38
+ ${workspaceHeader(user, view)}
44
39
  <div class="dashboard-layout">
45
- ${sidebar}
46
- <main class="dashboard">
47
- <section class="toolbar">
48
- <span>${view.text("dashboard.range", { start, end, total })}</span>
49
- ${searchBackend ? `<span>${view.text("dashboard.searchBackend", { backend: searchBackend })}</span>` : ""}
50
- </section>
51
- <form class="filter-form" method="get" action="/">
40
+ <main class="dashboard" id="main-content">
41
+ <div class="dashboard-heading"><h1>${view.text("nav.artifacts")}</h1><nav><a class="button-link btn btn-primary" href="${view.href("/new")}">${view.text("nav.new")}</a><a href="${view.href("/import")}">${view.text("nav.import")}</a></nav></div>
42
+ <form class="filter-form" data-auto-apply="filters" method="get" action="/">
52
43
  ${localeInput(view.locale)}
53
44
  ${filters.limit ? `<input type="hidden" name="limit" value="${escapeAttribute(filters.limit)}">` : ""}
54
- <input name="q" value="${escapeAttribute(filters.query || "")}" placeholder="${view.attr("filter.search")}">
55
- <input name="tag" value="${escapeAttribute(filters.tag || "")}" placeholder="${view.attr("filter.tag")}">
56
- <input name="sourceAgent" value="${escapeAttribute(filters.sourceAgent || "")}" placeholder="${view.attr("filter.source")}">
57
- ${filterArtifactTypeSelect(filters.artifactType, view)}
58
- <input name="publisher" value="${escapeAttribute(filters.publisher || "")}" placeholder="${view.attr("filter.publisher")}">
59
- <input type="date" name="createdAfter" value="${escapeAttribute(filters.createdAfter || "")}" title="${view.attr("filter.createdAfter")}">
60
- <input type="date" name="createdBefore" value="${escapeAttribute(filters.createdBefore || "")}" title="${view.attr("filter.createdBefore")}">
61
- ${filterReviewStatusSelect(filters.reviewStatus, view)}
62
- ${embeddingsAvailable ? filterModeSelect(filters.mode, view) : ""}
63
- ${filterGroupBySelect(filters.groupBy, view)}
64
- <label class="checkbox-field"><input type="checkbox" name="includeArchived" value="true"${filters.includeArchived ? " checked" : ""}> ${view.text("filter.archived")}</label>
65
- <button type="submit">${view.text("filter.submit")}</button>
66
- <a href="${view.href("/")}">${view.text("filter.clear")}</a>
45
+ ${!embeddingsAvailable && filters.mode ? `<input type="hidden" name="mode" value="${escapeAttribute(filters.mode)}">` : ""}
46
+ <div class="dashboard-search-row">
47
+ <label class="dashboard-search"><span class="dashboard-sr-only">${view.text("filter.search")}</span><input type="search" name="q" value="${escapeAttribute(filters.query || "")}" placeholder="${view.attr("dashboard.searchPlaceholder")}" class="form-control"></label>
48
+ <button type="submit" class="btn btn-outline-secondary">${view.text("filter.search")}</button>
49
+ <a class="dashboard-clear" href="${view.href("/")}">${view.text("filter.clear")}</a>
50
+ </div>
51
+ <details class="dashboard-advanced">
52
+ <summary>${view.text("dashboard.advanced")}${activeFilters.length ? `<span class="dashboard-filter-count">${view.text("dashboard.activeFilters", { count: activeFilters.length })}</span>` : ""}</summary>
53
+ <div class="dashboard-filter-grid">
54
+ ${field("tag", "filter.tag")}${field("sourceAgent", "filter.source")}
55
+ ${selectField("filter.artifactType", filterArtifactTypeSelect(filters.artifactType, view))}
56
+ ${field("publisher", "filter.publisher")}
57
+ ${field("createdAfter", "filter.createdAfter", "date")}${field("createdBefore", "filter.createdBefore", "date")}
58
+ ${selectField("filter.reviewStatus", filterReviewStatusSelect(filters.reviewStatus, view))}
59
+ ${embeddingsAvailable ? selectField("filter.mode", filterModeSelect(filters.mode, view)) : ""}
60
+ ${selectField("filter.groupBy", filterGroupBySelect(filters.groupBy, view))}
61
+ ${field("relatedTo", "filter.relatedTo")}${field("relation", "filter.relation")}
62
+ <label class="checkbox-field"><input type="checkbox" name="includeArchived" value="true"${filters.includeArchived ? " checked" : ""} class="form-check-input"> ${view.text("filter.archived")}</label>
63
+ <div class="dashboard-filter-actions"><button type="submit" class="btn btn-outline-secondary">${view.text("filter.submit")}</button></div>
64
+ </div>
65
+ </details>
66
+ ${activeFilters.length ? `<p class="dashboard-active-filters">${activeFilters.map(([key, label]) => `<span>${view.text(label)}${key === "includeArchived" ? "" : `: ${escapeHtml(filters[key])}`}</span>`).join("")}</p>` : ""}
67
67
  </form>
68
- <section class="artifact-list">
69
- ${rowsHtml || `<div class="empty">${view.text("dashboard.empty")}</div>`}
68
+ ${renderSavedViewsSidebar({ savedViews, filters, view })}
69
+ <section class="toolbar dashboard-results">
70
+ <span>${view.text("dashboard.range", { start, end, total })}</span>
70
71
  </section>
72
+ <section class="artifact-list">${rowsHtml || `<div class="empty">${view.text("dashboard.empty")}</div>`}</section>
71
73
  ${pager}
72
74
  </main>
73
75
  </div>
@@ -76,7 +78,7 @@ export function renderDashboard({ artifacts, baseUrl, filters = {}, pagination,
76
78
  });
77
79
  }
78
80
 
79
- function renderArtifactRow(artifact, view) {
81
+ function renderArtifactRow(artifact, view, readOnly = false) {
80
82
  const tags = artifact.tags.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`).join("");
81
83
  const status = artifact.archivedAt ? statusBadge("archived") : "";
82
84
  const visibility = visibilityBadge(artifact, view);
@@ -85,27 +87,24 @@ function renderArtifactRow(artifact, view) {
85
87
  ? `<span class="row-snippet">${escapeHtml(artifact.searchSnippet)}</span>`
86
88
  : "";
87
89
  return `
88
- <a class="artifact-row" href="${view.href(`/artifacts/${encodeURIComponent(artifact.id)}`)}">
90
+ <${readOnly ? "div" : "a"} class="artifact-row"${readOnly ? "" : ` href="${view.href(`/artifacts/${encodeURIComponent(artifact.id)}`)}"`}>
89
91
  <span class="row-main">
90
92
  <strong>${escapeHtml(artifact.title)}</strong>
91
- <span>${escapeHtml(artifact.id)}</span>
93
+ <span class="dashboard-sr-only">${escapeHtml(artifact.id)}</span>
92
94
  ${snippet}
93
95
  </span>
94
- <span>${escapeHtml(artifact.sourceAgent)}</span>
95
- <span class="publisher-cell" title="${escapeAttribute(publisherTitle(artifact, view))}">${escapeHtml(publisher)}</span>
96
- ${typeBadge(artifact.artifactType || "document")}
97
- ${formatBadge(artifact.format || "text")}
98
- <span>v${artifact.latestVersion}</span>
96
+ <span class="row-agent"><span>${escapeHtml(artifact.sourceAgent)}</span>${artifact.publisherId || artifact.publisherName ? `<span class="publisher-cell" title="${escapeAttribute(publisherTitle(artifact, view))}">${escapeHtml(publisher)}</span>` : ""}</span>
97
+ <span class="row-formats">${typeBadge(artifact.artifactType || "document")}${formatBadge(artifact.format || "text")}<span>v${artifact.latestVersion}</span></span>
99
98
  <span class="tags">${status}${visibility}${tags}</span>
100
- </a>
99
+ </${readOnly ? "div" : "a"}>
101
100
  `;
102
101
  }
103
102
 
104
103
  // groupBy is purely presentational: it groups the already-fetched, already-
105
104
  // sorted page of artifacts into labeled sections without changing the query.
106
- function renderArtifactRows(artifacts, groupBy, view) {
105
+ function renderArtifactRows(artifacts, groupBy, view, readOnly = false) {
107
106
  if (!GROUP_BY_VALUES.includes(groupBy)) {
108
- return artifacts.map((artifact) => renderArtifactRow(artifact, view)).join("");
107
+ return artifacts.map((artifact) => renderArtifactRow(artifact, view, readOnly)).join("");
109
108
  }
110
109
 
111
110
  const groups = new Map();
@@ -121,7 +120,7 @@ function renderArtifactRows(artifacts, groupBy, view) {
121
120
  .map(([key, group]) => `
122
121
  <section class="artifact-group">
123
122
  <h2 class="artifact-group-heading">${escapeHtml(groupHeadingLabel(key, groupBy, view))}</h2>
124
- ${group.map((artifact) => renderArtifactRow(artifact, view)).join("")}
123
+ ${group.map((artifact) => renderArtifactRow(artifact, view, readOnly)).join("")}
125
124
  </section>
126
125
  `)
127
126
  .join("");
@@ -134,21 +133,21 @@ function groupHeadingLabel(key, groupBy, view) {
134
133
  return key || view.raw("dashboard.groupUnknown");
135
134
  }
136
135
 
137
- // Shared by every <select> whose options are just ARTIFACT_TYPES rendered
136
+ // Shared by every <select class="form-select"> whose options are just ARTIFACT_TYPES rendered
138
137
  // as their own value and (escaped) label.
139
138
  function artifactTypeOptions(selected) {
140
139
  return ARTIFACT_TYPES.map((type) => `<option value="${type}"${type === selected ? " selected" : ""}>${escapeHtml(type)}</option>`).join("");
141
140
  }
142
141
 
143
142
  function filterArtifactTypeSelect(selected, view) {
144
- return `<select name="artifactType">
143
+ return `<select name="artifactType" class="form-select">
145
144
  <option value="">${view.text("filter.artifactTypeAny")}</option>
146
145
  ${artifactTypeOptions(selected)}
147
146
  </select>`;
148
147
  }
149
148
 
150
149
  function filterReviewStatusSelect(selected, view) {
151
- return `<select name="reviewStatus">
150
+ return `<select name="reviewStatus" class="form-select">
152
151
  <option value="">${view.text("filter.reviewStatusAny")}</option>
153
152
  ${REVIEW_STATUSES.map((status) => `<option value="${status}"${status === selected ? " selected" : ""}>${view.text(`reviewStatus.${status}`)}</option>`).join("")}
154
153
  </select>`;
@@ -160,14 +159,14 @@ const SEARCH_MODE_VALUES = ["keyword", "semantic", "hybrid"];
160
159
  // configured (roadmap section 6): semantic/hybrid search silently falls
161
160
  // back to keyword without one, so the toggle would otherwise mislead.
162
161
  function filterModeSelect(selected, view) {
163
- return `<select name="mode">
162
+ return `<select name="mode" class="form-select">
164
163
  <option value="">${view.text("filter.modeDefault")}</option>
165
164
  ${SEARCH_MODE_VALUES.map((value) => `<option value="${value}"${value === selected ? " selected" : ""}>${view.text(`filter.mode.${value}`)}</option>`).join("")}
166
165
  </select>`;
167
166
  }
168
167
 
169
168
  function filterGroupBySelect(selected, view) {
170
- return `<select name="groupBy">
169
+ return `<select name="groupBy" class="form-select">
171
170
  <option value="">${view.text("filter.groupByNone")}</option>
172
171
  ${GROUP_BY_VALUES.map((value) => `<option value="${value}"${value === selected ? " selected" : ""}>${view.text(`filter.groupBy.${value}`)}</option>`).join("")}
173
172
  </select>`;
@@ -181,30 +180,32 @@ function renderSavedViewsSidebar({ savedViews, filters, view }) {
181
180
  ${savedView.shared ? `<span class="badge saved-view-shared">${view.text("views.shared")}</span>` : ""}
182
181
  <form method="post" action="${view.href(`/views/${encodeURIComponent(savedView.id)}/delete`)}" class="saved-view-delete">
183
182
  ${localeInput(view.locale)}
184
- <button type="submit">${view.text("views.delete")}</button>
183
+ <button type="submit" class="btn btn-outline-secondary">${view.text("views.delete")}</button>
185
184
  </form>
186
185
  </li>
187
186
  `).join("")
188
187
  : `<li class="empty">${view.text("views.empty")}</li>`;
189
188
 
190
189
  return `
191
- <aside class="dashboard-sidebar">
192
- <h2>${view.text("views.title")}</h2>
190
+ <details class="dashboard-saved-views">
191
+ <summary>${view.text("views.title")} <span class="dashboard-filter-count">${savedViews.length}</span></summary>
192
+ <div class="dashboard-saved-content">
193
193
  <ul class="saved-view-list">${items}</ul>
194
194
  <form method="post" action="${view.href("/views")}" class="saved-view-form">
195
195
  ${localeInput(view.locale)}
196
196
  ${savedViewFilterHiddenInputs(filters)}
197
- <input name="name" placeholder="${view.attr("views.namePlaceholder")}" required>
198
- <label class="checkbox-field"><input type="checkbox" name="shared" value="true"> ${view.text("views.shared")}</label>
199
- <button type="submit">${view.text("views.save")}</button>
197
+ <label class="dashboard-field"><span>${view.text("views.namePlaceholder")}</span><input name="name" placeholder="${view.attr("views.namePlaceholder")}" required class="form-control"></label>
198
+ <label class="checkbox-field"><input type="checkbox" name="shared" value="true" class="form-check-input"> ${view.text("views.shared")}</label>
199
+ <button type="submit" class="btn btn-outline-secondary">${view.text("views.save")}</button>
200
200
  </form>
201
- </aside>
201
+ </div>
202
+ </details>
202
203
  `;
203
204
  }
204
205
 
205
206
  // Every SAVED_VIEW_FILTER_KEYS entry as a hidden form field, so a filter
206
207
  // added there (e.g. relatedTo/relation) is captured by "save current
207
- // filters" automatically instead of needing a hand-added <input> here too.
208
+ // filters" automatically instead of needing a hand-added <input class="form-control"> here too.
208
209
  function savedViewFilterHiddenInputs(filters) {
209
210
  return SAVED_VIEW_FILTER_KEYS.map((key) => {
210
211
  if (key === "includeArchived") {
@@ -215,18 +216,42 @@ function savedViewFilterHiddenInputs(filters) {
215
216
  }
216
217
 
217
218
  function renderDashboardPager({ pagination, filters, view }) {
218
- if (!pagination || pagination.total <= pagination.limit) {
219
- return "";
220
- }
221
-
222
- const previous = pagination.previousOffset === null
223
- ? `<span class="pager-disabled">${view.text("dashboard.previous")}</span>`
224
- : `<a href="${view.href(dashboardPageHref(filters, pagination.previousOffset))}">${view.text("dashboard.previous")}</a>`;
225
- const next = pagination.nextOffset === null
226
- ? `<span class="pager-disabled">${view.text("dashboard.next")}</span>`
227
- : `<a href="${view.href(dashboardPageHref(filters, pagination.nextOffset))}">${view.text("dashboard.next")}</a>`;
228
-
229
- return `<nav class="pager" aria-label="Pagination">${previous}${next}</nav>`;
219
+ const limit = Math.max(1, Math.min(200, Number(pagination.limit) || 50));
220
+ const offset = Math.max(0, Number(pagination.offset) || 0);
221
+ const total = Math.max(0, Number(pagination.total) || 0);
222
+ const pages = Math.max(1, Math.ceil(total / limit));
223
+ const page = Math.min(pages, Math.floor(offset / limit) + 1);
224
+ const current = new URL(view.currentPath || "/", "http://artifacty.local");
225
+ const params = new URLSearchParams(current.search);
226
+ const resolved = new URL(dashboardPageHref(filters, 0), "http://artifacty.local");
227
+ for (const [name, value] of resolved.searchParams) if (!params.has(name)) params.append(name, value);
228
+ params.delete("limit");
229
+ params.delete("offset");
230
+ params.set("lang", view.locale);
231
+ const pageHref = (nextOffset) => {
232
+ const query = new URLSearchParams(params);
233
+ query.set("limit", String(limit));
234
+ query.set("offset", String(nextOffset));
235
+ return escapeAttribute(`${current.pathname}?${query}${current.hash}`);
236
+ };
237
+ const previousOffset = pagination.previousOffset === undefined ? Math.max(0, offset - limit) : pagination.previousOffset;
238
+ const nextOffset = pagination.nextOffset === undefined ? offset + limit : pagination.nextOffset;
239
+ const previous = offset === 0 || previousOffset === null
240
+ ? `<span class="pager-disabled" aria-disabled="true">${view.text("dashboard.previous")}</span>`
241
+ : `<a rel="prev" href="${pageHref(previousOffset)}">${view.text("dashboard.previous")}</a>`;
242
+ const next = offset + limit >= total || nextOffset === null
243
+ ? `<span class="pager-disabled" aria-disabled="true">${view.text("dashboard.next")}</span>`
244
+ : `<a rel="next" href="${pageHref(nextOffset)}">${view.text("dashboard.next")}</a>`;
245
+ const sizes = [...new Set([10, 25, 50, 100, limit])].sort((a, b) => a - b);
246
+ return `<section class="dashboard-pagination">
247
+ <nav class="pager" aria-label="${view.attr("dashboard.pagination")}">${previous}<span class="pager-position">${view.text("dashboard.pagePosition", { page, pages })}</span>${next}</nav>
248
+ <form class="page-size-form" data-auto-apply="all" method="get" action="${escapeAttribute(`${current.pathname}${current.hash}`)}">
249
+ ${[...params].map(([name, value]) => `<input type="hidden" name="${escapeAttribute(name)}" value="${escapeAttribute(value)}">`).join("")}
250
+ <input type="hidden" name="offset" value="0">
251
+ <label><span>${view.text("dashboard.pageSize")}</span><select name="limit" class="form-select">${sizes.map((size) => `<option value="${size}"${size === limit ? " selected" : ""}>${size}</option>`).join("")}</select></label>
252
+ <button type="submit" class="btn btn-outline-secondary">${view.text("language.apply")}</button>
253
+ </form>
254
+ </section>`;
230
255
  }
231
256
 
232
257
  function dashboardPageHref(filters, offset) {
@@ -260,7 +285,7 @@ function dashboardPageHref(filters, offset) {
260
285
  return query ? `/?${query}` : "/";
261
286
  }
262
287
 
263
- export function renderArtifactFormPage({ mode, baseUrl, artifact, version, content, authToken = "", locale = DEFAULT_LOCALE, currentPath = "/", expectedVersion, conflict = null }) {
288
+ export function renderArtifactFormPage({ user, mode, baseUrl, artifact, version, content, authToken = "", locale = DEFAULT_LOCALE, currentPath = "/", expectedVersion, conflict = null }) {
264
289
  const view = viewContext(locale, currentPath);
265
290
  const isEdit = mode === "edit";
266
291
  const title = isEdit ? view.raw("form.editTitle", { title: artifact.title }) : view.raw("form.newTitle");
@@ -281,6 +306,7 @@ export function renderArtifactFormPage({ mode, baseUrl, artifact, version, conte
281
306
  return pageShell({
282
307
  title,
283
308
  body: `
309
+ ${workspaceHeader(user, view)}
284
310
  <header class="topbar">
285
311
  <div>
286
312
  <h1>${escapeHtml(title)}</h1>
@@ -288,10 +314,10 @@ export function renderArtifactFormPage({ mode, baseUrl, artifact, version, conte
288
314
  </div>
289
315
  <nav>
290
316
  <a href="${view.href(isEdit ? `/artifacts/${encodeURIComponent(artifact.id)}` : "/")}">${view.text("nav.cancel")}</a>
291
- ${languageSwitcher(view)}
317
+
292
318
  </nav>
293
319
  </header>
294
- <main class="artifact-editor">
320
+ <main id="main-content" class="artifact-editor">
295
321
  ${conflictBanner}
296
322
  <form class="editor-form" method="post" action="${action}">
297
323
  ${hiddenToken(authToken)}
@@ -300,7 +326,7 @@ export function renderArtifactFormPage({ mode, baseUrl, artifact, version, conte
300
326
  <section class="editor-fields">
301
327
  <label class="field">
302
328
  <span>${view.text("form.title")}</span>
303
- <input name="title" value="${escapeAttribute(artifact?.title || "")}" autocomplete="off" required>
329
+ <input name="title" value="${escapeAttribute(artifact?.title || "")}" autocomplete="off" required class="form-control">
304
330
  </label>
305
331
  <label class="field">
306
332
  <span>${view.text("form.format")}</span>
@@ -308,7 +334,7 @@ export function renderArtifactFormPage({ mode, baseUrl, artifact, version, conte
308
334
  </label>
309
335
  <label class="field">
310
336
  <span>${view.text("form.source")}</span>
311
- <input name="sourceAgent" value="${escapeAttribute(sourceAgent)}" autocomplete="off">
337
+ <input name="sourceAgent" value="${escapeAttribute(sourceAgent)}" autocomplete="off" class="form-control">
312
338
  </label>
313
339
  <label class="field">
314
340
  <span>${view.text("form.type")}</span>
@@ -316,20 +342,21 @@ export function renderArtifactFormPage({ mode, baseUrl, artifact, version, conte
316
342
  </label>
317
343
  <label class="field">
318
344
  <span>${view.text("form.tags")}</span>
319
- <input name="tags" value="${escapeAttribute(tags)}" autocomplete="off">
345
+ <input name="tags" value="${escapeAttribute(tags)}" autocomplete="off" class="form-control">
320
346
  </label>
321
347
  ${isEdit ? "" : `<label class="field">
322
348
  <span>${view.text("artifact.visibility.label")}</span>
323
349
  ${visibilitySelect(artifact?.visibility || "team", view)}
324
350
  </label>`}
325
351
  </section>
352
+ <p class="muted">${view.text("artifact.visibility.help")}</p>
326
353
  <section class="field content-field">
327
354
  <label for="artifact-content">${view.text("form.content")}</label>
328
- <textarea id="artifact-content" name="content" data-artifacty-editor data-editor-format="${escapeAttribute(format)}" spellcheck="false" required>${escapeHtml(body)}</textarea>
355
+ <textarea id="artifact-content" name="content" data-artifacty-editor data-editor-format="${escapeAttribute(format)}" spellcheck="false" required class="form-control">${escapeHtml(body)}</textarea>
329
356
  </section>
330
357
  <footer class="editor-actions">
331
358
  <a href="${view.href(isEdit ? `/artifacts/${encodeURIComponent(artifact.id)}` : "/")}">${view.text("nav.cancel")}</a>
332
- <button type="submit">${isEdit ? view.text("form.saveVersion") : view.text("form.create")}</button>
359
+ <button type="submit" class="btn btn-primary">${isEdit ? view.text("form.saveVersion") : view.text("form.create")}</button>
333
360
  </footer>
334
361
  </form>
335
362
  </main>
@@ -340,8 +367,8 @@ export function renderArtifactFormPage({ mode, baseUrl, artifact, version, conte
340
367
  });
341
368
  }
342
369
 
343
- export function renderNewArtifactPage({ baseUrl, authToken = "", locale = DEFAULT_LOCALE, currentPath = "/new" }) {
344
- return renderArtifactFormPage({ mode: "new", baseUrl, authToken, locale, currentPath });
370
+ export function renderNewArtifactPage({ user, baseUrl, authToken = "", locale = DEFAULT_LOCALE, currentPath = "/new" }) {
371
+ return renderArtifactFormPage({ mode: "new", baseUrl, authToken, locale, currentPath, user });
345
372
  }
346
373
 
347
374
  export function renderAdminArtifactVersionsPage({ artifact, selectedVersion, content, baseUrl, user, error = "", locale = DEFAULT_LOCALE, currentPath = "/" }) {
@@ -354,14 +381,14 @@ export function renderAdminArtifactVersionsPage({ artifact, selectedVersion, con
354
381
  <tr>
355
382
  <td><a class="version${active}" href="${view.href(`/admin/artifacts/${encodeURIComponent(artifact.id)}/versions?version=${version.version}`)}">v${version.version}</a></td>
356
383
  <td>${formatBadge(version.format)}</td>
357
- <td>${escapeHtml(version.createdAt)}</td>
384
+ <td>${renderDateTime(version.createdAt, view)}</td>
358
385
  <td><code>${escapeHtml(version.sha256.slice(0, 12))}</code></td>
359
386
  <td>${escapeHtml(String(version.sizeBytes))}</td>
360
387
  <td>
361
388
  <form class="inline-action" method="post" action="/admin/artifacts/${encodeURIComponent(artifact.id)}/versions/${version.version}/delete">
362
389
  ${localeInput(view.locale)}
363
- <input name="reason" placeholder="Reason">
364
- <button type="submit"${deleteDisabled}>Delete</button>
390
+ <input name="reason" placeholder="${view.text("ui.reason")}" aria-label="${view.text("ui.reason")}" class="form-control">
391
+ <button type="submit"${deleteDisabled} class="btn btn-outline-danger">${view.text("ui.delete")}</button>
365
392
  </form>
366
393
  </td>
367
394
  </tr>
@@ -369,26 +396,27 @@ export function renderAdminArtifactVersionsPage({ artifact, selectedVersion, con
369
396
  }).join("");
370
397
 
371
398
  return pageShell({
372
- title: `Manage versions · ${artifact.title}`,
399
+ title: `${view.raw("versions.manage")} · ${artifact.title}`,
373
400
  body: `
401
+ ${workspaceHeader(user, view)}
374
402
  <header class="topbar">
375
403
  <div>
376
- <h1>Manage versions</h1>
404
+ <h1>${view.text("versions.manage")}</h1>
377
405
  <p>${escapeHtml(artifact.title)} · ${escapeHtml(artifact.id)} · ${escapeHtml(baseUrl)}</p>
378
406
  </div>
379
407
  <nav>
380
- <a href="${view.href(artifactPath)}">Artifact</a>
408
+ <a href="${view.href(artifactPath)}">${view.text("nav.artifact")}</a>
381
409
  <a href="${view.href(`${artifactPath}/diff`)}">${view.text("nav.diff")}</a>
382
- ${authNav(user, view)}
383
- ${languageSwitcher(view)}
410
+
411
+
384
412
  </nav>
385
413
  </header>
386
- <main class="artifact-editor">
414
+ <main id="main-content" class="artifact-editor">
387
415
  ${error ? `<p class="auth-error">${escapeHtml(error)}</p>` : ""}
388
416
  <section class="meta-card">
389
- <h2>Versions</h2>
417
+ <h2>${view.text("nav.versions")}</h2>
390
418
  <table class="data-table">
391
- <thead><tr><th>Version</th><th>Format</th><th>Created</th><th>SHA</th><th>Bytes</th><th>Action</th></tr></thead>
419
+ <thead><tr><th>${view.text("ui.version")}</th><th>${view.text("form.format")}</th><th>${view.text("ui.created")}</th><th>SHA</th><th>${view.text("ui.bytes")}</th><th>${view.text("ui.action")}</th></tr></thead>
392
420
  <tbody>${versionRows}</tbody>
393
421
  </table>
394
422
  </section>
@@ -400,17 +428,17 @@ export function renderAdminArtifactVersionsPage({ artifact, selectedVersion, con
400
428
  ${formatSelect(selectedVersion.format)}
401
429
  </label>
402
430
  <label class="field">
403
- <span>Reason</span>
404
- <input name="reason" value="" autocomplete="off">
431
+ <span>${view.text("ui.reason")}</span>
432
+ <input name="reason" value="" autocomplete="off" class="form-control">
405
433
  </label>
406
434
  </section>
407
435
  <section class="field content-field">
408
- <label for="artifact-content">v${selectedVersion.version} content</label>
409
- <textarea id="artifact-content" name="content" data-artifacty-editor data-editor-format="${escapeAttribute(selectedVersion.format)}" spellcheck="false" required>${escapeHtml(content)}</textarea>
436
+ <label for="artifact-content">${view.text("versions.content", { version: selectedVersion.version })}</label>
437
+ <textarea id="artifact-content" name="content" data-artifacty-editor data-editor-format="${escapeAttribute(selectedVersion.format)}" spellcheck="false" required class="form-control">${escapeHtml(content)}</textarea>
410
438
  </section>
411
439
  <footer class="editor-actions">
412
- <a href="${view.href(`${artifactPath}?version=${selectedVersion.version}`)}">View version</a>
413
- <button type="submit">Repair version</button>
440
+ <a href="${view.href(`${artifactPath}?version=${selectedVersion.version}`)}">${view.text("versions.view")}</a>
441
+ <button type="submit" class="btn btn-outline-secondary">${view.text("versions.repair")}</button>
414
442
  </footer>
415
443
  </form>
416
444
  </main>
@@ -421,59 +449,57 @@ export function renderAdminArtifactVersionsPage({ artifact, selectedVersion, con
421
449
  });
422
450
  }
423
451
 
424
- export function renderImportArtifactPage({ baseUrl, authToken = "", locale = DEFAULT_LOCALE, currentPath = "/import" }) {
452
+ export function renderImportArtifactPage({ user, baseUrl, authToken = "", locale = DEFAULT_LOCALE, currentPath = "/import" }) {
425
453
  const view = viewContext(locale, currentPath);
426
454
  const title = view.raw("form.importTitle");
427
455
  return pageShell({
428
456
  title,
429
457
  body: `
458
+ ${workspaceHeader(user, view)}
430
459
  <header class="topbar">
431
460
  <div>
432
461
  <h1>${escapeHtml(title)}</h1>
433
462
  <p>${escapeHtml(baseUrl)}</p>
434
463
  </div>
435
- <nav>
436
- <a href="${view.href("/")}">${view.text("nav.index")}</a>
437
- ${languageSwitcher(view)}
438
- </nav>
464
+
439
465
  </header>
440
- <main class="artifact-editor">
466
+ <main id="main-content" class="artifact-editor">
441
467
  <form class="editor-form" method="post" action="/import">
442
468
  ${hiddenToken(authToken)}
443
469
  ${localeInput(view.locale)}
444
470
  <section class="editor-fields">
445
471
  <label class="field">
446
472
  <span>${view.text("form.agent")}</span>
447
- <select name="agent">
473
+ <select name="agent" class="form-select">
448
474
  <option value="auto">${view.text("form.auto")}</option>
449
475
  <option value="claude">Claude</option>
450
476
  <option value="codex">Codex</option>
451
477
  <option value="gemini">Gemini</option>
452
478
  <option value="copilot">GitHub Copilot</option>
453
479
  <option value="cursor">Cursor</option>
454
- <option value="generic">Generic</option>
480
+ <option value="generic">${view.text("import.generic")}</option>
455
481
  </select>
456
482
  </label>
457
483
  <label class="field">
458
484
  <span>${view.text("form.title")}</span>
459
- <input name="title" autocomplete="off">
485
+ <input name="title" autocomplete="off" class="form-control">
460
486
  </label>
461
487
  <label class="field">
462
488
  <span>${view.text("form.fileName")}</span>
463
- <input name="fileName" autocomplete="off">
489
+ <input name="fileName" autocomplete="off" class="form-control">
464
490
  </label>
465
491
  <label class="field">
466
492
  <span>${view.text("form.tags")}</span>
467
- <input name="tags" autocomplete="off">
493
+ <input name="tags" autocomplete="off" class="form-control">
468
494
  </label>
469
495
  </section>
470
496
  <section class="field content-field">
471
497
  <label for="import-content">${view.text("form.importContent")}</label>
472
- <textarea id="import-content" name="content" data-artifacty-editor data-editor-format="auto" spellcheck="false" required></textarea>
498
+ <textarea id="import-content" name="content" data-artifacty-editor data-editor-format="auto" spellcheck="false" required class="form-control"></textarea>
473
499
  </section>
474
500
  <footer class="editor-actions">
475
501
  <a href="${view.href("/")}">${view.text("nav.cancel")}</a>
476
- <button type="submit">${view.text("form.importSubmit")}</button>
502
+ <button type="submit" class="btn btn-outline-secondary">${view.text("form.importSubmit")}</button>
477
503
  </footer>
478
504
  </form>
479
505
  </main>
@@ -486,40 +512,38 @@ export function renderImportArtifactPage({ baseUrl, authToken = "", locale = DEF
486
512
 
487
513
  export function renderLoginPage({ baseUrl, setup = false, error = "", locale = DEFAULT_LOCALE, currentPath = "/login" }) {
488
514
  const view = viewContext(locale, currentPath);
489
- const title = setup ? "Create admin account" : "Sign in";
515
+ const title = setup ? view.raw("login.setup") : view.raw("nav.signIn");
490
516
  return pageShell({
491
517
  title,
492
518
  body: `
519
+ ${workspaceHeader(null, view)}
493
520
  <header class="topbar">
494
521
  <div>
495
522
  <h1>${escapeHtml(title)}</h1>
496
523
  <p>${escapeHtml(baseUrl)}</p>
497
524
  </div>
498
- <nav>
499
- <a href="${view.href("/")}">${view.text("nav.index")}</a>
500
- ${languageSwitcher(view)}
501
- </nav>
525
+
502
526
  </header>
503
- <main class="artifact-editor auth-panel">
527
+ <main id="main-content" class="artifact-editor auth-panel">
504
528
  ${error ? `<p class="auth-error">${escapeHtml(error)}</p>` : ""}
505
- ${setup ? `<p class="muted">No users exist yet. The first account becomes an administrator.</p>` : ""}
529
+ ${setup ? `<p class="muted">${view.text("login.setupHelp")}</p>` : ""}
506
530
  <form class="editor-form auth-form" method="post" action="/login">
507
531
  <section class="editor-fields">
508
532
  <label class="field">
509
- <span>Email</span>
510
- <input type="email" name="email" autocomplete="username" required>
533
+ <span>${view.text("ui.email")}</span>
534
+ <input type="email" name="email" autocomplete="username" required class="form-control">
511
535
  </label>
512
536
  ${setup ? `<label class="field">
513
- <span>Name</span>
514
- <input name="name" autocomplete="name">
537
+ <span>${view.text("ui.name")}</span>
538
+ <input name="name" autocomplete="name" class="form-control">
515
539
  </label>` : ""}
516
540
  <label class="field">
517
- <span>Password</span>
518
- <input type="password" name="password" autocomplete="${setup ? "new-password" : "current-password"}" minlength="8" required>
541
+ <span>${view.text("ui.password")}</span>
542
+ <input type="password" name="password" autocomplete="${setup ? "new-password" : "current-password"}" minlength="8" required class="form-control">
519
543
  </label>
520
544
  </section>
521
545
  <footer class="editor-actions">
522
- <button type="submit">${setup ? "Create admin" : "Sign in"}</button>
546
+ <button type="submit" class="btn btn-primary">${setup ? view.text("login.create") : view.text("nav.signIn")}</button>
523
547
  </footer>
524
548
  </form>
525
549
  </main>
@@ -534,7 +558,7 @@ export function renderAccountPage({ baseUrl, user, tokens = [], createdToken = "
534
558
  <tr>
535
559
  <td><a href="${view.href(`/artifacts/${encodeURIComponent(artifact.id)}`)}">${escapeHtml(artifact.title)}</a></td>
536
560
  <td>${escapeHtml(artifact.id)}</td>
537
- <td>${escapeHtml(artifact.updatedAt)}</td>
561
+ <td>${renderDateTime(artifact.updatedAt, view)}</td>
538
562
  <td>${artifact.archivedAt ? statusBadge(view.text("artifact.archived", { date: artifact.archivedAt })) : ""}</td>
539
563
  </tr>
540
564
  `).join("");
@@ -542,70 +566,65 @@ export function renderAccountPage({ baseUrl, user, tokens = [], createdToken = "
542
566
  <tr>
543
567
  <td>${escapeHtml(token.name)}</td>
544
568
  <td>${escapeHtml((token.scopes || ["read", "write"]).join(", "))}</td>
545
- <td>${escapeHtml(token.createdAt)}</td>
546
- <td>${token.lastUsedAt ? escapeHtml(token.lastUsedAt) : "Never"}</td>
547
- <td>${token.revokedAt ? escapeHtml(token.revokedAt) : "Active"}</td>
569
+ <td>${renderDateTime(token.createdAt, view)}</td>
570
+ <td>${token.lastUsedAt ? renderDateTime(token.lastUsedAt, view) : view.text("ui.never")}</td>
571
+ <td>${token.revokedAt ? renderDateTime(token.revokedAt, view) : view.text("ui.active")}</td>
548
572
  <td>
549
573
  ${token.revokedAt ? "" : `<form method="post" action="/account/tokens/${encodeURIComponent(token.id)}/revoke">
550
- <button type="submit">Revoke</button>
574
+ <button type="submit" class="btn btn-outline-danger">${view.text("ui.revoke")}</button>
551
575
  </form>`}
552
576
  </td>
553
577
  </tr>
554
578
  `).join("");
555
579
 
556
580
  return pageShell({
557
- title: "Account",
581
+ title: view.raw("nav.account"),
558
582
  body: `
583
+ ${workspaceHeader(user, view)}
559
584
  <header class="topbar">
560
585
  <div>
561
- <h1>Account</h1>
562
- <p>${escapeHtml(user.email)} · ${escapeHtml(user.role)} · ${escapeHtml(baseUrl)}</p>
586
+ <h1>${view.text("nav.account")}</h1>
587
+ <p>${escapeHtml(user.email)} · ${view.text(`ui.role.${user.role}`)} · ${escapeHtml(baseUrl)}</p>
563
588
  </div>
564
- <nav>
565
- <a href="${view.href("/")}">${view.text("nav.index")}</a>
566
- ${user.role === "admin" ? `<a href="/admin/users">Users</a><a href="/admin/backup">Backup</a><a href="${view.href("/admin/access")}">${view.text("access.title")}</a>` : ""}
567
- <a href="/account/password">Password</a>
568
- <form class="nav-form" method="post" action="/logout"><button type="submit">Sign out</button></form>
569
- ${languageSwitcher(view)}
570
- </nav>
589
+
571
590
  </header>
572
- <main class="artifact-view">
591
+ <main id="main-content" class="settings-page artifact-view">
573
592
  ${createdToken ? `<section class="token-once">
574
- <h2>New API token</h2>
575
- <p>Copy this token now. Artifacty stores only its hash and cannot show it again.</p>
593
+ <h2>${view.text("account.newToken")}</h2>
594
+ <p>${view.text("account.copyToken")}</p>
576
595
  <pre class="artifact-code"><code>${escapeHtml(createdToken)}</code></pre>
577
596
  </section>` : ""}
578
597
  <section class="meta-card">
579
- <h2>Profile</h2>
598
+ <h2>${view.text("account.profile")}</h2>
580
599
  <p><strong>${escapeHtml(user.name)}</strong></p>
581
600
  <p>${escapeHtml(user.email)}</p>
582
- <p>Role: ${escapeHtml(user.role)}</p>
601
+ <p>${view.text("ui.role")}: ${view.text(`ui.role.${user.role}`)}</p>
583
602
  </section>
584
603
  <section class="meta-card">
585
604
  <h2>${view.text("tokens.createTitle")}</h2>
586
605
  <form class="inline-action token-create-form" method="post" action="/account/tokens">
587
- <input name="name" placeholder="Token name" autocomplete="off" required>
606
+ <input name="name" placeholder="${view.text("account.tokenName")}" aria-label="${view.text("account.tokenName")}" autocomplete="off" required class="form-control">
588
607
  <fieldset class="token-scopes">
589
608
  <legend>${view.text("tokens.scopes")}</legend>
590
- <label><input type="checkbox" name="scopeRead" checked> ${view.text("tokens.scope.read")}</label>
591
- <label><input type="checkbox" name="scopeWrite" checked> ${view.text("tokens.scope.write")}</label>
592
- ${user.role === "admin" ? `<label><input type="checkbox" name="scopeAdmin"> ${view.text("tokens.scope.admin")}</label>` : ""}
609
+ <label><input type="checkbox" name="scopeRead" checked class="form-check-input"> ${view.text("tokens.scope.read")}</label>
610
+ <label><input type="checkbox" name="scopeWrite" checked class="form-check-input"> ${view.text("tokens.scope.write")}</label>
611
+ ${user.role === "admin" ? `<label><input type="checkbox" name="scopeAdmin" class="form-check-input"> ${view.text("tokens.scope.admin")}</label>` : ""}
593
612
  </fieldset>
594
- <button type="submit">Create token</button>
613
+ <button type="submit" class="btn btn-primary">${view.text("account.createToken")}</button>
595
614
  </form>
596
615
  </section>
597
616
  <section class="meta-card">
598
- <h2>API tokens</h2>
617
+ <h2>${view.text("account.tokens")}</h2>
599
618
  <table class="data-table">
600
- <thead><tr><th>Name</th><th>${view.text("tokens.scopes")}</th><th>Created</th><th>Last used</th><th>Status</th><th></th></tr></thead>
601
- <tbody>${rows || `<tr><td colspan="6">No API tokens.</td></tr>`}</tbody>
619
+ <thead><tr><th>${view.text("ui.name")}</th><th>${view.text("tokens.scopes")}</th><th>${view.text("ui.created")}</th><th>${view.text("ui.lastUsed")}</th><th>${view.text("ui.status")}</th><th></th></tr></thead>
620
+ <tbody>${rows || `<tr><td colspan="6">${view.text("account.noTokens")}</td></tr>`}</tbody>
602
621
  </table>
603
622
  </section>
604
623
  <section class="meta-card">
605
624
  <h2>${view.text("account.privateArtifacts")}</h2>
606
625
  <table class="data-table">
607
- <thead><tr><th>Title</th><th>ID</th><th>Updated</th><th>Status</th></tr></thead>
608
- <tbody>${privateArtifactRows || `<tr><td colspan="4">None.</td></tr>`}</tbody>
626
+ <thead><tr><th>${view.text("ui.title")}</th><th>ID</th><th>${view.text("ui.updated")}</th><th>${view.text("ui.status")}</th></tr></thead>
627
+ <tbody>${privateArtifactRows || `<tr><td colspan="4">${view.text("ui.none")}</td></tr>`}</tbody>
609
628
  </table>
610
629
  </section>
611
630
  </main>
@@ -617,41 +636,37 @@ export function renderAccountPage({ baseUrl, user, tokens = [], createdToken = "
617
636
  export function renderPasswordPage({ baseUrl, user, required = false, error = "", success = "", locale = DEFAULT_LOCALE, currentPath = "/account/password" }) {
618
637
  const view = viewContext(locale, currentPath);
619
638
  return pageShell({
620
- title: "Change Password",
639
+ title: view.raw("password.title"),
621
640
  body: `
641
+ ${workspaceHeader(user, view)}
622
642
  <header class="topbar">
623
643
  <div>
624
- <h1>Change Password</h1>
644
+ <h1>${view.text("password.title")}</h1>
625
645
  <p>${escapeHtml(user.email)} · ${escapeHtml(baseUrl)}</p>
626
646
  </div>
627
- <nav>
628
- <a href="${view.href("/")}">${view.text("nav.index")}</a>
629
- ${required ? "" : `<a href="/account">Account</a>`}
630
- <form class="nav-form" method="post" action="/logout"><button type="submit">Sign out</button></form>
631
- ${languageSwitcher(view)}
632
- </nav>
647
+
633
648
  </header>
634
- <main class="artifact-editor auth-panel">
635
- ${required ? `<p class="auth-warning">Password change is required before continuing.</p>` : ""}
649
+ <main id="main-content" class="settings-page artifact-editor auth-panel">
650
+ ${required ? `<p class="auth-warning">${view.text("password.required")}</p>` : ""}
636
651
  ${error ? `<p class="auth-error">${escapeHtml(error)}</p>` : ""}
637
652
  ${success ? `<p class="auth-success">${escapeHtml(success)}</p>` : ""}
638
653
  <form class="editor-form auth-form" method="post" action="/account/password">
639
654
  <section class="editor-fields">
640
655
  <label class="field">
641
- <span>Current password</span>
642
- <input type="password" name="currentPassword" autocomplete="current-password" required>
656
+ <span>${view.text("password.current")}</span>
657
+ <input type="password" name="currentPassword" autocomplete="current-password" required class="form-control">
643
658
  </label>
644
659
  <label class="field">
645
- <span>New password</span>
646
- <input type="password" name="newPassword" autocomplete="new-password" minlength="8" required>
660
+ <span>${view.text("password.new")}</span>
661
+ <input type="password" name="newPassword" autocomplete="new-password" minlength="8" required class="form-control">
647
662
  </label>
648
663
  <label class="field">
649
- <span>Confirm password</span>
650
- <input type="password" name="confirmPassword" autocomplete="new-password" minlength="8" required>
664
+ <span>${view.text("password.confirm")}</span>
665
+ <input type="password" name="confirmPassword" autocomplete="new-password" minlength="8" required class="form-control">
651
666
  </label>
652
667
  </section>
653
668
  <footer class="editor-actions">
654
- <button type="submit">Change password</button>
669
+ <button type="submit" class="btn btn-primary">${view.text("password.submit")}</button>
655
670
  </footer>
656
671
  </form>
657
672
  </main>
@@ -670,65 +685,60 @@ function renderFullImportSummary(tableCounts) {
670
685
  return parts ? ` (${parts})` : "";
671
686
  }
672
687
 
673
- export function renderAdminBackupPage({ baseUrl, integrity, importResult = null, importError = "", locale = DEFAULT_LOCALE, currentPath = "/admin/backup" }) {
688
+ export function renderAdminBackupPage({ user, baseUrl, integrity, importResult = null, importError = "", locale = DEFAULT_LOCALE, currentPath = "/admin/backup" }) {
674
689
  const view = viewContext(locale, currentPath);
675
690
  return pageShell({
676
- title: "Backup",
691
+ title: view.raw("nav.backup"),
677
692
  body: `
693
+ ${workspaceHeader(user, view)}
678
694
  <header class="topbar">
679
695
  <div>
680
- <h1>Backup</h1>
696
+ <h1>${view.text("nav.backup")}</h1>
681
697
  <p>${escapeHtml(baseUrl)}</p>
682
698
  </div>
683
- <nav>
684
- <a href="${view.href("/")}">${view.text("nav.index")}</a>
685
- <a href="/admin/users">Users</a><a href="${view.href("/admin/access")}">${view.text("access.title")}</a>
686
- <a href="/admin/retention">${view.text("retention.navLabel")}</a>
687
- <a href="/account">Account</a>
688
- ${languageSwitcher(view)}
689
- </nav>
699
+
690
700
  </header>
691
- <main class="artifact-view">
692
- ${importResult ? `<p class="auth-success">Restore complete. ${escapeHtml(importResult.artifactCount)} artifacts imported${importResult.scope === "full" ? renderFullImportSummary(importResult.tableCounts) : ""}.</p>` : ""}
701
+ <main id="main-content" class="settings-page artifact-view">
702
+ ${importResult ? `<p class="auth-success">${view.text("backup.complete", { count: importResult.artifactCount })}${importResult.scope === "full" ? renderFullImportSummary(importResult.tableCounts) : ""}.</p>` : ""}
693
703
  ${importError ? `<p class="auth-error">${escapeHtml(importError)}</p>` : ""}
694
704
  <section class="meta-card">
695
- <h2>Store status</h2>
705
+ <h2>${view.text("backup.status")}</h2>
696
706
  <table class="data-table">
697
707
  <tbody>
698
- <tr><th>Home</th><td>${escapeHtml(integrity.store)}</td></tr>
699
- <tr><th>Artifacts</th><td>${escapeHtml(integrity.artifactCount)}</td></tr>
700
- <tr><th>Versions</th><td>${escapeHtml(integrity.versionCount)}</td></tr>
701
- <tr><th>Content bytes</th><td>${escapeHtml(integrity.totalBytes)}</td></tr>
702
- <tr><th>Integrity</th><td>${integrity.ok ? "OK" : "Needs attention"}</td></tr>
708
+ <tr><th>${view.text("backup.home")}</th><td>${escapeHtml(integrity.store)}</td></tr>
709
+ <tr><th>${view.text("nav.artifacts")}</th><td>${escapeHtml(integrity.artifactCount)}</td></tr>
710
+ <tr><th>${view.text("nav.versions")}</th><td>${escapeHtml(integrity.versionCount)}</td></tr>
711
+ <tr><th>${view.text("backup.bytes")}</th><td>${escapeHtml(integrity.totalBytes)}</td></tr>
712
+ <tr><th>${view.text("backup.integrity")}</th><td>${integrity.ok ? view.text("backup.ok") : view.text("backup.attention")}</td></tr>
703
713
  </tbody>
704
714
  </table>
705
- ${integrity.ok ? "" : `<p class="auth-warning">Run <code>artifacty integrity</code> before migration. Current backup will only include readable referenced version files.</p>`}
715
+ ${integrity.ok ? "" : `<p class="auth-warning">${view.text("backup.integrityHelp")}</p>`}
706
716
  </section>
707
717
  <section class="meta-card">
708
- <h2>Download backup</h2>
709
- <p class="muted">Exports artifact metadata and version contents as one JSON bundle. The full scope also includes users, API tokens, audit history, relations, and webhooks (secrets are never included and must be re-issued after restore).</p>
718
+ <h2>${view.text("backup.download")}</h2>
719
+ <p class="muted">${view.text("backup.exportHelp")}</p>
710
720
  <form class="editor-form" method="get" action="/admin/backup/export">
711
721
  <label class="field">
712
722
  <span>${view.text("backup.scopeLabel")}</span>
713
- <select name="scope">
723
+ <select name="scope" class="form-select">
714
724
  <option value="artifacts">${view.text("backup.scopeArtifacts")}</option>
715
725
  <option value="full">${view.text("backup.scopeFull")}</option>
716
726
  </select>
717
727
  </label>
718
- <footer class="editor-actions"><button type="submit">${view.text("backup.downloadButton")}</button></footer>
728
+ <footer class="editor-actions"><button type="submit" class="btn btn-outline-secondary">${view.text("backup.downloadButton")}</button></footer>
719
729
  </form>
720
730
  </section>
721
731
  <section class="meta-card">
722
- <h2>Restore backup</h2>
723
- <p class="auth-warning">Restore replaces the target server's artifact records and version files with the backup contents. An artifacts-only bundle leaves users, sessions, API tokens, and audit logs unchanged. A full-scope bundle also replaces users, API tokens, audit history, relations, and webhooks (disabled on import; they must be re-issued) — check both boxes below to proceed, and force-users only if this server already has accounts.</p>
732
+ <h2>${view.text("backup.restore")}</h2>
733
+ <p class="auth-warning">${view.text("backup.restoreHelp")}</p>
724
734
  <form class="editor-form" method="post" action="/admin/backup/import">
725
735
  <label class="field content-field">
726
- <span>Backup JSON</span>
727
- <textarea class="compact-textarea" name="backup" spellcheck="false" placeholder="{&quot;schemaVersion&quot;:1,&quot;artifacts&quot;:[...]}" required></textarea>
736
+ <span>${view.text("backup.json")}</span>
737
+ <textarea class="compact-textarea form-control" name="backup" spellcheck="false" placeholder="{&quot;schemaVersion&quot;:1,&quot;artifacts&quot;:[...]}" required></textarea>
728
738
  </label>
729
- <label class="checkbox-field"><input type="checkbox" name="confirm" value="replace-all"> ${view.text("backup.confirmLabel")}</label>
730
- <label class="checkbox-field"><input type="checkbox" name="forceUsers" value="1"> ${view.text("backup.forceUsersLabel")}</label>
731
- <footer class="editor-actions"><button type="submit">Restore backup</button></footer>
739
+ <label class="checkbox-field"><input type="checkbox" name="confirm" value="replace-all" class="form-check-input"> ${view.text("backup.confirmLabel")}</label>
740
+ <label class="checkbox-field"><input type="checkbox" name="forceUsers" value="1" class="form-check-input"> ${view.text("backup.forceUsersLabel")}</label>
741
+ <footer class="editor-actions"><button type="submit" class="btn btn-outline-secondary">${view.text("backup.restore")}</button></footer>
732
742
  </form>
733
743
  </section>
734
744
  </main>
@@ -737,50 +747,175 @@ export function renderAdminBackupPage({ baseUrl, integrity, importResult = null,
737
747
  });
738
748
  }
739
749
 
750
+ export function renderAdminEmailPage({ settings = {}, user, baseUrl, locale = DEFAULT_LOCALE, currentPath = "/admin/email", error = "", success = "" }) {
751
+ const view = viewContext(locale, currentPath);
752
+ return pageShell({ title: view.raw("smtp.title"), locale: view.locale,
753
+ body: `${workspaceHeader(user, view)}
754
+ <header class="topbar"><div><h1>${view.text("smtp.title")}</h1><p>${escapeHtml(baseUrl)}</p></div></header>
755
+ <main id="main-content" class="settings-page artifact-view">
756
+ ${error ? `<p class="auth-error" role="alert">${view.text("smtp.saveFailed")}</p>` : ""}
757
+ ${success ? `<p class="auth-success" role="status">${view.text("smtp.saved")}</p>` : ""}
758
+ <section class="meta-card"><h2>${view.text("smtp.settings")}</h2>
759
+ <p>${view.text("smtp.help")}</p><p class="muted">${view.text("smtp.security")}</p>
760
+ <form class="editor-form smtp-settings-form" method="post" action="/admin/email">${localeInput(view.locale)}
761
+ <label><input type="checkbox" name="enabled" value="true"${settings.enabled ? " checked" : ""} class="form-check-input"> ${view.text("smtp.enabled")}</label>
762
+ <div class="smtp-field-grid">
763
+ <label class="field"><span>${view.text("smtp.host")}</span><input name="host" value="${escapeAttribute(settings.host || "")}" autocomplete="off" class="form-control"></label>
764
+ <label class="field"><span>${view.text("smtp.port")}</span><input name="port" type="number" min="1" max="65535" value="${escapeAttribute(settings.port || 465)}" class="form-control"></label>
765
+ <label class="field"><span>${view.text("smtp.tlsMode")}</span><select name="tlsMode" required class="form-select">
766
+ <option value="tls"${settings.tlsMode !== "starttls" ? " selected" : ""}>${view.text("smtp.tls")}</option>
767
+ <option value="starttls"${settings.tlsMode === "starttls" ? " selected" : ""}>${view.text("smtp.starttls")}</option>
768
+ </select></label>
769
+ <label class="field"><span>${view.text("smtp.from")}</span><input name="from" type="email" value="${escapeAttribute(settings.from || "")}" autocomplete="off" class="form-control"></label>
770
+ <label class="field"><span>${view.text("smtp.username")}</span><input name="username" value="${escapeAttribute(settings.username || "")}" autocomplete="off" class="form-control"></label>
771
+ <label class="field"><span>${view.text("smtp.password")}</span><input name="password" type="password" value="" autocomplete="new-password" placeholder="${view.attr(settings.hasPassword ? "smtp.keepPassword" : "smtp.enterPassword")}" class="form-control"></label>
772
+ </div>
773
+ <p class="muted">${view.text("smtp.authHelp")}</p>
774
+ <p class="muted">${view.text("smtp.restoreNote")}</p>
775
+ <footer class="editor-actions"><button type="submit" class="btn btn-primary">${view.text("smtp.save")}</button></footer>
776
+ </form>
777
+ </section>
778
+ </main>` });
779
+ }
780
+
781
+ export function renderAdminLanguagesPage({ settings, user, baseUrl, locale = DEFAULT_LOCALE, currentPath = "/admin/languages", error = "", success = "" }) {
782
+ const view = viewContext(locale, currentPath);
783
+ const installed = availableLanguages();
784
+ return pageShell({ title: view.raw("languageAdmin.title"), locale: view.locale,
785
+ body: `${workspaceHeader(user, view)}
786
+ <header class="topbar"><div><h1>${view.text("languageAdmin.title")}</h1><p>${escapeHtml(baseUrl)}</p></div></header>
787
+ <main id="main-content" class="settings-page artifact-view">
788
+ ${error ? `<p class="auth-error" role="alert">${escapeHtml(error)}</p>` : ""}
789
+ ${success ? `<p class="auth-success" role="status">${escapeHtml(success)}</p>` : ""}
790
+ <section class="meta-card"><h2>${view.text("languageAdmin.settings")}</h2>
791
+ <form class="editor-form" method="post" action="/admin/languages">${localeInput(view.locale)}
792
+ <fieldset><legend>${view.text("languageAdmin.enabled")}</legend>
793
+ ${installed.map((language) => `<label class="check-field"><input type="checkbox" name="enabledLocales" value="${escapeAttribute(language.locale)}"${settings.enabledLocales.includes(language.locale) ? " checked" : ""} class="form-check-input"> ${escapeHtml(language.nativeName)} (${escapeHtml(language.locale)})</label>`).join("")}
794
+ </fieldset>
795
+ <label class="field"><span>${view.text("languageAdmin.default")}</span><select name="defaultLocale" class="form-select">
796
+ ${installed.map((language) => `<option value="${escapeAttribute(language.locale)}"${settings.defaultLocale === language.locale ? " selected" : ""}>${escapeHtml(language.nativeName)}</option>`).join("")}
797
+ </select></label>
798
+ <p class="muted">${view.text("languageAdmin.defaultHelp")}</p>
799
+ <button type="submit" class="btn btn-primary">${view.text("languageAdmin.save")}</button>
800
+ </form>
801
+ </section>
802
+ <section class="meta-card"><h2>${view.text("languageAdmin.import")}</h2>
803
+ <p><a href="${view.href("/admin/languages/template")}">${view.text("languageAdmin.template")}</a></p>
804
+ <p>${view.text("languageAdmin.templateHelp")}</p>
805
+ <p>${view.text("languageAdmin.importHelp", { count: "{count}" })}</p>
806
+ <p class="muted">${view.text("languageAdmin.disabledHelp")}</p>
807
+ <form class="editor-form" method="post" action="/admin/languages/import">${localeInput(view.locale)}
808
+ <label class="field"><span>${view.text("languageAdmin.pack")}</span><textarea class="compact-textarea form-control" name="pack" maxlength="524288" spellcheck="false" required placeholder="${escapeAttribute('{"locale":"fr","nativeName":"Français","messages":{"language.label":"Langue","language.apply":"Appliquer"}}')}"></textarea></label>
809
+ <button type="submit" class="btn btn-outline-secondary">${view.text("languageAdmin.import")}</button>
810
+ </form>
811
+ </section>
812
+ </main>` });
813
+ }
814
+
815
+ export function renderAdminUpdatePage({ baseUrl, user, status = {}, locale = DEFAULT_LOCALE, currentPath = "/admin/update" }) {
816
+ const view = viewContext(locale, currentPath);
817
+ const check = status.check;
818
+ const run = status.run;
819
+ const active = ["queued", "running"].includes(run?.state);
820
+ const hasUpdate = check?.updateAvailable && check.targetVersion && status.currentVersion !== check.targetVersion;
821
+ const canStart = check?.supported && !check.errorCode && check.targetVersion && check.updateAvailable && check.confirmationId && Date.parse(check.expiresAt) > Date.now() && !active && run?.state !== "interrupted" && status.currentVersion !== check.targetVersion;
822
+ const states = Object.fromEntries(["queued", "running", "succeeded", "failed", "interrupted"].map((state) => [state, view.raw(`update.${state}`)]));
823
+ const messages = { ...states, reconnect: view.raw("update.reconnect"), signIn: view.raw("update.signIn") };
824
+ return pageShell({
825
+ title: view.text("update.title"), locale: view.locale,
826
+ body: `${workspaceHeader(user, view)}
827
+ <header class="topbar"><div><h1>${view.text("update.title")}</h1><p>${escapeHtml(baseUrl)}</p></div></header>
828
+ <main id="main-content" class="settings-page artifact-view">
829
+ <section class="meta-card"><h2>${view.text("update.installed")}</h2><p>Artifacty <strong data-update-version>${escapeHtml(status.currentVersion || APP_VERSION)}</strong></p>
830
+ <p>${view.text("update.help")}</p>
831
+ <form method="post" action="/admin/update/check">${localeInput(view.locale)}<button type="submit"${active ? " disabled" : ""} class="btn btn-outline-secondary">${view.text("update.check")}</button></form>
832
+ </section>
833
+ ${check ? `<section class="meta-card">${hasUpdate ? `<h2>${view.text("update.latest")}</h2><p>${escapeHtml(status.currentVersion || APP_VERSION)} → <strong>${escapeHtml(check.targetVersion)}</strong></p>` : ""}
834
+ ${hasUpdate ? `<p><a href="https://github.com/raeseoklee/artifacty/releases/tag/v${escapeAttribute(encodeURIComponent(check.targetVersion))}" target="_blank" rel="noopener noreferrer">${view.text("update.releaseNotes")}</a></p>` : ""}
835
+ ${check.errorCode ? `<p role="alert">${view.text("update.checkFailed")}</p>` : !check.supported ? `<p>${view.text("update.unsupported")}</p><p>${view.text("update.manual")}</p>` : !hasUpdate && !check.errorCode && check.targetVersion ? `<p>${view.text("update.current")}</p>` : ""}
836
+ ${check.supported && check.updateAvailable && !canStart && !active && !check.errorCode && status.currentVersion !== check.targetVersion ? `<p>${view.text("update.checkAgain")}</p>` : ""}
837
+ ${canStart ? `<p>${view.text("update.available", { version: check.targetVersion })}</p><p>${view.text("update.backup")}</p>
838
+ <form class="editor-form" method="post" action="/admin/update/start">${localeInput(view.locale)}
839
+ <input type="hidden" name="confirmationId" value="${escapeAttribute(check.confirmationId)}">
840
+ <input type="hidden" name="targetVersion" value="${escapeAttribute(check.targetVersion)}">
841
+ <label><input type="checkbox" name="confirmUpdate" value="true" required class="form-check-input"> ${view.text("update.confirm")}</label>
842
+ ${check.expiresAt ? `<p class="muted">${view.text("update.expires")}: ${`<time datetime="${escapeAttribute(check.expiresAt)}" title="${escapeAttribute(check.expiresAt)}">${escapeHtml(new Intl.DateTimeFormat(view.locale, { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" }).format(new Date(check.expiresAt)))} UTC</time>`}</p>` : ""}
843
+ <button type="submit" class="btn btn-primary">${view.text("update.start")}</button>
844
+ </form>` : ""}
845
+ </section>` : ""}
846
+ ${run ? `<section class="meta-card" data-update-status data-active="${active}" data-messages="${escapeAttribute(JSON.stringify(messages))}"><h2>${view.text("update.progress")}</h2>
847
+ <p>${view.text("update.latest")}: ${escapeHtml(run.targetVersion || "—")}</p>
848
+ <p role="status" aria-live="polite" data-update-state>${escapeHtml(states[run.state] || states.running)}</p>
849
+ <p data-update-error>${escapeHtml(run.error || "")}</p>
850
+ <a href="${view.href("/admin/update")}">${view.text("update.refresh")}</a> <a data-update-login hidden href="${view.href("/login")}">${view.text("nav.signIn")}</a>
851
+ </section>` : ""}
852
+ </main>`,
853
+ afterBody: active ? '<script type="module" src="/assets/update.js"></script>' : ""
854
+ });
855
+ }
856
+
857
+ export function renderAccessModePreviewPage({ user, baseUrl, mode, artifacts = [], anonymousArtifactCount = 0, locale = DEFAULT_LOCALE, currentPath = "/admin/access/preview" }) {
858
+ const view = viewContext(locale, currentPath);
859
+ const isPublic = mode === "public";
860
+ return pageShell({ title: view.raw("access.previewTitle"), locale: view.locale,
861
+ body: `<header class="topbar"><div><h1>${view.text("access.previewTitle")}</h1><p>${escapeHtml(baseUrl)}</p></div>
862
+ <nav><a href="${view.href(`/admin/access?mode=${isPublic ? "public" : "private"}`)}">${view.text("access.backToSettings")}</a></nav></header>
863
+ <main id="main-content" class="dashboard">
864
+ <section class="meta-card" role="status"><h2>${view.text(isPublic ? "access.public" : "access.private")}</h2>
865
+ <p>${view.text("access.previewAnonymous")}</p><p>${view.text("access.previewUnsaved")}</p>
866
+ </section>
867
+ ${isPublic ? `<div class="dashboard-heading"><h1>${view.text("nav.artifacts")}</h1></div>
868
+ <p class="muted">${view.text("access.previewListCount", { shown: artifacts.length, total: anonymousArtifactCount })}</p>
869
+ <section class="artifact-list">${renderArtifactRows(artifacts, "", view, true) || `<div class="empty">${view.text("dashboard.empty")}</div>`}</section>`
870
+ : `<section class="meta-card"><h2>${view.text("access.previewSignIn")}</h2><p>${view.text("access.previewPrivate")}</p></section>`}
871
+ </main>` });
872
+ }
873
+
740
874
  export function renderAdminAccessPage({ baseUrl, user, mode, teams = [], users = [], networkPolicy = { allowedCidrs: [] }, currentIp = "", preview = null, pendingNetworkPolicy = null, shareLinkPolicy = {}, locale = DEFAULT_LOCALE, currentPath = "/admin/access" }) {
741
875
  const view = viewContext(locale, currentPath);
742
876
  return pageShell({
743
877
  title: view.text("access.title"),
744
878
  locale: view.locale,
745
- body: `<header class="topbar"><div><h1>${view.text("access.title")}</h1><p>${escapeHtml(baseUrl)}</p></div>
746
- <nav><a href="${view.href("/")}">${view.text("nav.index")}</a>${authNav(user, view)}${languageSwitcher(view)}</nav></header>
747
- <main class="artifact-view">
879
+ body: `${workspaceHeader(user, view)}
880
+ <header class="topbar"><div><h1>${view.text("access.title")}</h1><p>${escapeHtml(baseUrl)}</p></div>
881
+ </header>
882
+ <main id="main-content" class="settings-page artifact-view">
748
883
  <section class="meta-card"><h2>${view.text("access.mode")}</h2>
749
884
  <form class="editor-form" method="post" action="/admin/access/preview">${localeInput(view.locale)}
750
- <label><input type="radio" name="mode" value="public"${mode === "public" ? " checked" : ""}> ${view.text("access.public")}</label>
885
+ <label><input type="radio" name="mode" value="public"${(preview?.mode || mode) === "public" ? " checked" : ""} class="form-check-input"> ${view.text("access.public")}</label>
751
886
  <p class="muted">${view.text("access.publicHelp")}</p>
752
- <label><input type="radio" name="mode" value="private"${mode === "private" ? " checked" : ""}> ${view.text("access.private")}</label>
887
+ <label><input type="radio" name="mode" value="private"${(preview?.mode || mode) === "private" ? " checked" : ""} class="form-check-input"> ${view.text("access.private")}</label>
753
888
  <p class="muted">${view.text("access.privateHelp")}</p>
754
- <button type="submit">${view.text("access.preview")}</button>
889
+ <button type="submit" class="btn btn-outline-secondary">${view.text("access.preview")}</button>
755
890
  </form>
756
891
  </section>
757
892
  ${preview ? `<section class="meta-card"><h2>${view.text("access.preview")}</h2><p>${view.text("access.previewCount", { count: preview.anonymousArtifactCount, mode: preview.mode })}</p>
758
- <form method="post" action="/admin/access">${localeInput(view.locale)}<input type="hidden" name="mode" value="${escapeAttribute(preview.mode)}"><button type="submit">${view.text("access.save")}</button></form></section>` : ""}
759
- <section class="meta-card"><h2>${view.text("sharing.links")}</h2><form method="post" action="/admin/access/share-links">${localeInput(view.locale)}
760
- <label><input type="checkbox" name="enabledInPrivate" value="true"${shareLinkPolicy.enabledInPrivate ? " checked" : ""}> ${view.text("sharing.enablePrivate")}</label><button type="submit">${view.text("access.save")}</button></form></section>
893
+ <form method="post" action="/admin/access">${localeInput(view.locale)}<input type="hidden" name="mode" value="${escapeAttribute(preview.mode)}"><button type="submit" class="btn btn-primary">${view.text("access.save")}</button></form></section>` : ""}
894
+ <section class="meta-card"><h2>${view.text("sharing.links")}</h2><form class="editor-form" method="post" action="/admin/access/share-links">${localeInput(view.locale)}
895
+ <label><input type="checkbox" name="enabledInPrivate" value="true"${shareLinkPolicy.enabledInPrivate ? " checked" : ""} class="form-check-input"> ${view.text("sharing.enablePrivate")}</label><footer class="editor-actions"><button type="submit" class="btn btn-primary">${view.text("access.save")}</button></footer></form></section>
761
896
  ${pendingNetworkPolicy ? `<section class="meta-card"><h2>${view.text("access.pendingNetwork")}</h2><p>${view.text("access.pendingHelp", { expiresAt: pendingNetworkPolicy.expiresAt })}</p>
762
- ${["confirm", "rollback"].map((action) => `<form method="post" action="/admin/access/network/${action}">${localeInput(view.locale)}<input type="hidden" name="id" value="${escapeAttribute(pendingNetworkPolicy.id)}"><button type="submit">${view.text(`access.${action}`)}</button></form>`).join("")}</section>` : ""}
897
+ ${["confirm", "rollback"].map((action) => `<form method="post" action="/admin/access/network/${action}">${localeInput(view.locale)}<input type="hidden" name="id" value="${escapeAttribute(pendingNetworkPolicy.id)}"><button type="submit" class="btn btn-outline-secondary">${view.text(`access.${action}`)}</button></form>`).join("")}</section>` : ""}
763
898
  <section class="meta-card"><h2>${view.text("access.network")}</h2>
764
899
  <p class="muted">${view.text("access.networkHelp")}</p>
765
900
  <p>${view.text("access.currentIp")}: <code>${escapeHtml(currentIp)}</code></p>
766
901
  <form class="editor-form" method="post" action="/admin/access/network">${localeInput(view.locale)}
767
- <label class="field"><span>${view.text("access.allowedCidrs")}</span><textarea name="allowedCidrs" rows="5">${escapeHtml((networkPolicy.allowedCidrs || []).join("\n"))}</textarea></label>
768
- <button type="submit">${view.text("access.saveNetwork")}</button>
902
+ <label class="field"><span>${view.text("access.allowedCidrs")}</span><textarea name="allowedCidrs" rows="5" class="form-control">${escapeHtml((networkPolicy.allowedCidrs || []).join("\n"))}</textarea></label>
903
+ <button type="submit" class="btn btn-primary">${view.text("access.saveNetwork")}</button>
769
904
  </form>
770
905
  </section>
771
- <section class="meta-card"><h2>${view.text("access.samples")}</h2><p>${view.text("access.samplesHelp")}</p>
772
- ${["install", "remove"].map((action) => `<form method="post" action="/admin/samples/${action}">${localeInput(view.locale)}<button type="submit">${view.text(`access.samples.${action}`)}</button></form>`).join("")}</section>
773
906
  <section class="meta-card"><h2>${view.text("access.teams")}</h2>
774
907
  <form class="editor-form auth-form" method="post" action="/admin/teams">${localeInput(view.locale)}
775
- <label class="field"><span>${view.text("access.teamName")}</span><input name="name" required></label>
776
- <button type="submit">${view.text("access.createTeam")}</button>
908
+ <label class="field"><span>${view.text("access.teamName")}</span><input name="name" required class="form-control"></label>
909
+ <button type="submit" class="btn btn-primary">${view.text("access.createTeam")}</button>
777
910
  </form>
778
911
  ${teams.length ? teams.map((team) => `<section><h3>${escapeHtml(team.name)}</h3>
779
912
  <form class="editor-form" method="post" action="/admin/teams/${encodeURIComponent(team.id)}/members">${localeInput(view.locale)}
780
913
  <fieldset><legend>${view.text("access.members")}</legend>${accessCheckboxes(users, "userIds", team.memberIds || [], view)}</fieldset>
781
- <button type="submit">${view.text("access.saveMembers")}</button>
914
+ <button type="submit" class="btn btn-primary">${view.text("access.saveMembers")}</button>
782
915
  </form></section>`).join("") : `<p class="muted">${view.text("access.noTeams")}</p>`}
783
916
  </section>
917
+ <section class="meta-card"><h2>${view.text("access.samples")}</h2><p>${view.text("access.samplesHelp")}</p>
918
+ ${["install", "remove"].map((action) => `<form method="post" action="/admin/samples/${action}">${localeInput(view.locale)}<button type="submit" class="btn btn-outline-secondary">${view.text(`access.samples.${action}`)}</button></form>`).join("")}</section>
784
919
  </main>`
785
920
  });
786
921
  }
@@ -790,14 +925,15 @@ export function renderArtifactSharingPage({ baseUrl, user, artifact, sharing = {
790
925
  return pageShell({
791
926
  title: view.text("sharing.title"),
792
927
  locale: view.locale,
793
- body: `<header class="topbar"><div><h1>${view.text("sharing.title")}: ${escapeHtml(artifact.title)}</h1><p>${escapeHtml(baseUrl)}</p></div>
794
- <nav><a href="${view.href(`/artifacts/${encodeURIComponent(artifact.id)}`)}">${view.text("nav.artifact")}</a>${authNav(user, view)}${languageSwitcher(view)}</nav></header>
795
- <main class="artifact-view"><section class="meta-card">
928
+ body: `${workspaceHeader(user, view)}
929
+ <header class="topbar"><div><h1>${view.text("sharing.title")}: ${escapeHtml(artifact.title)}</h1><p>${escapeHtml(baseUrl)}</p></div>
930
+ <nav><a href="${view.href(`/artifacts/${encodeURIComponent(artifact.id)}`)}">${view.text("nav.artifact")}</a></nav></header>
931
+ <main id="main-content" class="settings-page artifact-view"><section class="meta-card">
796
932
  <p>${view.text("sharing.help")}</p>
797
933
  <form class="editor-form" method="post" action="/artifacts/${encodeURIComponent(artifact.id)}/sharing">${localeInput(view.locale)}
798
934
  <fieldset><legend>${view.text("sharing.users")}</legend>${accessCheckboxes(users, "userIds", sharing.userIds || [], view, sharing.userPermissions || {})}</fieldset>
799
935
  <fieldset><legend>${view.text("sharing.teams")}</legend>${accessCheckboxes(teams, "teamIds", sharing.teamIds || [], view, sharing.teamPermissions || {})}</fieldset>
800
- <button type="submit">${view.text("sharing.save")}</button>
936
+ <button type="submit" class="btn btn-outline-secondary">${view.text("sharing.save")}</button>
801
937
  </form>
802
938
  </section>
803
939
  ${accessReport && (user?.role === "admin" || user?.id === artifact.ownerUserId) ? `<section class="meta-card"><h2>${view.text("sharing.effective")}</h2><p>${view.text("sharing.networkCondition")}: ${escapeHtml(networkPolicy.allowedCidrs?.length ? networkPolicy.allowedCidrs.join(", ") : view.text("sharing.unrestricted"))}</p><p>${view.text("sharing.anonymous")}: ${view.text(accessReport.anonymousRead ? "sharing.allowed" : "sharing.denied")}</p>
@@ -805,27 +941,28 @@ export function renderArtifactSharingPage({ baseUrl, user, artifact, sharing = {
805
941
  <section class="meta-card"><h2>${view.text("sharing.links")}</h2><p>${view.text("sharing.linksHelp")}</p>
806
942
  ${createdShareLink?.token ? `<p>${view.text("sharing.copyNow")}</p><a href="${escapeAttribute(`${baseUrl.replace(/\/$/, "")}/s/${encodeURIComponent(createdShareLink.token)}`)}">${escapeHtml(`${baseUrl.replace(/\/$/, "")}/s/${encodeURIComponent(createdShareLink.token)}`)}</a>` : ""}
807
943
  <form class="editor-form" method="post" action="/artifacts/${encodeURIComponent(artifact.id)}/share-links">${localeInput(view.locale)}
808
- <label class="field"><span>${view.text("sharing.version")}</span><select name="version"><option value="latest">${view.text("sharing.latest")}</option>${(artifact.versions || []).map((item) => `<option value="${Number(item.version)}">v${Number(item.version)}</option>`).join("")}</select></label>
809
- <label class="field"><span>${view.text("sharing.days")}</span><input type="number" name="days" min="1" max="365" value="7" required></label><button type="submit">${view.text("sharing.createLink")}</button></form>
810
- ${shareLinks.map((link) => `<section><p>${escapeHtml(link.id)} · ${link.version == null ? view.text("sharing.latest") : `v${Number(link.version)}`} · ${view.text("sharing.expires")}: ${escapeHtml(link.expiresAt)}</p>
811
- ${link.revokedAt ? `<p>${view.text("sharing.revoked")}</p>` : `<form method="post" action="/artifacts/${encodeURIComponent(artifact.id)}/share-links/${encodeURIComponent(link.id)}/revoke">${localeInput(view.locale)}<button type="submit">${view.text("sharing.revoke")}</button></form>`}</section>`).join("")}
944
+ <label class="field"><span>${view.text("sharing.version")}</span><select name="version" class="form-select"><option value="latest">${view.text("sharing.latest")}</option>${(artifact.versions || []).map((item) => `<option value="${Number(item.version)}">v${Number(item.version)}</option>`).join("")}</select></label>
945
+ <label class="field"><span>${view.text("sharing.days")}</span><input type="number" name="days" min="1" max="365" value="7" required class="form-control"></label><button type="submit" class="btn btn-outline-secondary">${view.text("sharing.createLink")}</button></form>
946
+ ${shareLinks.map((link) => `<section><p>${escapeHtml(link.id)} · ${link.version == null ? view.text("sharing.latest") : `v${Number(link.version)}`} · ${view.text("sharing.expires")}: ${renderDateTime(link.expiresAt, view)}</p>
947
+ ${link.revokedAt ? `<p>${view.text("sharing.revoked")}</p>` : `<form method="post" action="/artifacts/${encodeURIComponent(artifact.id)}/share-links/${encodeURIComponent(link.id)}/revoke">${localeInput(view.locale)}<button type="submit" class="btn btn-outline-danger">${view.text("sharing.revoke")}</button></form>`}</section>`).join("")}
812
948
  </section></main>`
813
949
  });
814
950
  }
815
951
 
816
952
  function accessCheckboxes(items, name, selectedIds, view, permissions = null) {
817
953
  if (!items.length) return `<p class="muted">${view.text(name === "userIds" ? "access.noUsers" : "access.noTeams")}</p>`;
818
- return items.filter((item) => item.active !== false).map((item) => `<label><input type="checkbox" name="${name}" value="${escapeAttribute(item.id)}"${selectedIds.includes(item.id) ? " checked" : ""}> ${escapeHtml(item.name || item.email || item.id)}${item.email && item.name ? ` (${escapeHtml(item.email)})` : ""}</label>${permissions ? `<label>${view.text("sharing.permission")}<select name="${name === "userIds" ? "userPermission" : "teamPermission"}:${escapeAttribute(item.id)}">${["read", "comment", "edit"].map((permission) => `<option value="${permission}"${(permissions[item.id] || "read") === permission ? " selected" : ""}>${view.text(`sharing.permission.${permission}`)}</option>`).join("")}</select></label>` : ""}`).join("");
954
+ return items.filter((item) => item.active !== false).map((item) => `<label><input type="checkbox" name="${name}" value="${escapeAttribute(item.id)}"${selectedIds.includes(item.id) ? " checked" : ""} class="form-check-input"> ${escapeHtml(item.name || item.email || item.id)}${item.email && item.name ? ` (${escapeHtml(item.email)})` : ""}</label>${permissions ? `<label>${view.text("sharing.permission")}<select name="${name === "userIds" ? "userPermission" : "teamPermission"}:${escapeAttribute(item.id)}" class="form-select">${["read", "comment", "edit"].map((permission) => `<option value="${permission}"${(permissions[item.id] || "read") === permission ? " selected" : ""}>${view.text(`sharing.permission.${permission}`)}</option>`).join("")}</select></label>` : ""}`).join("");
819
955
  }
820
956
 
821
957
  export function renderInboxPage({ baseUrl, user, notifications = [], unreadCount = 0, locale = DEFAULT_LOCALE, currentPath = "/inbox" }) {
822
958
  const view = viewContext(locale, currentPath);
823
959
  return pageShell({ title: view.text("inbox.title"), locale: view.locale,
824
- body: `<header class="topbar"><div><h1>${view.text("inbox.title")}</h1><p>${escapeHtml(baseUrl)}</p></div><nav><a href="${view.href("/")}">${view.text("nav.index")}</a>${authNav(user, view)}${languageSwitcher(view)}</nav></header>
825
- <main class="artifact-view"><section class="meta-card"><p>${view.text("inbox.unread", { count: unreadCount })}</p>
826
- <form method="post" action="/inbox/read-all">${localeInput(view.locale)}<button type="submit">${view.text("inbox.readAll")}</button></form>
827
- ${notifications.length ? notifications.map((item) => `<section><h2><a href="${view.href(`/artifacts/${encodeURIComponent(item.artifactId)}`)}">${escapeHtml(item.title)}</a></h2><p>${escapeHtml(item.type)} · ${escapeHtml(item.createdAt)}</p>
828
- ${item.readAt ? `<p>${view.text("inbox.read")}</p>` : `<form method="post" action="/inbox/${encodeURIComponent(item.id)}/read">${localeInput(view.locale)}<button type="submit">${view.text("inbox.markRead")}</button></form>`}</section>`).join("") : `<p>${view.text("inbox.empty")}</p>`}
960
+ body: `${workspaceHeader(user, view)}
961
+ <header class="topbar"><div><h1>${view.text("inbox.title")}</h1><p>${escapeHtml(baseUrl)}</p></div></header>
962
+ <main id="main-content" class="artifact-view"><section class="meta-card"><p>${view.text("inbox.unread", { count: unreadCount })}</p>
963
+ <form method="post" action="/inbox/read-all">${localeInput(view.locale)}<button type="submit" class="btn btn-outline-secondary">${view.text("inbox.readAll")}</button></form>
964
+ ${notifications.length ? notifications.map((item) => `<section><h2><a href="${view.href(`/artifacts/${encodeURIComponent(item.artifactId)}`)}">${escapeHtml(item.title)}</a></h2><p>${["shared-with-me", "comment", "new-version", "review-requested"].includes(item.type) ? view.text(`inbox.type.${item.type}`) : escapeHtml(item.type)} · ${renderDateTime(item.createdAt, view)}</p>
965
+ ${item.readAt ? `<p>${view.text("inbox.read")}</p>` : `<form method="post" action="/inbox/${encodeURIComponent(item.id)}/read">${localeInput(view.locale)}<button type="submit" class="btn btn-outline-secondary">${view.text("inbox.markRead")}</button></form>`}</section>`).join("") : `<p>${view.text("inbox.empty")}</p>`}
829
966
  </section></main>` });
830
967
  }
831
968
 
@@ -842,33 +979,40 @@ export function renderSharedArtifactPage({ artifact, version, content, locale =
842
979
  });
843
980
  const needsViewer = ["code", "markdown", "notebook", "sarif", "csv"].includes(version.format);
844
981
  return pageShell({ title: artifact.title, locale: view.locale,
845
- body: `<header class="topbar"><div><h1>${escapeHtml(artifact.title)}</h1><p>${view.text("sharing.readOnly")} · v${Number(version.version)}</p></div></header><main class="artifact-view">${rendered}</main>`,
982
+ body: `<header class="topbar"><div><h1>${escapeHtml(artifact.title)}</h1><p>${view.text("sharing.readOnly")} · v${Number(version.version)}</p></div></header><main id="main-content" class="artifact-view">${rendered}</main>`,
846
983
  head: needsViewer ? editorHead() : "",
847
- afterBody: `${frameResizeScript()}${needsViewer ? viewerScript() : ""}` });
984
+ afterBody: `${frameResizeScript()}${needsViewer ? viewerScript(view.locale) : ""}` });
848
985
  }
849
986
 
850
- export function renderAdminUsersPage({ baseUrl, user, users = [], importResult = null, importError = "", locale = DEFAULT_LOCALE, currentPath = "/admin/users" }) {
987
+ export function renderAdminUsersPage({ baseUrl, user, users = [], importResult = null, importError = "", roleError = "", resetResult = null, resetError = "", locale = DEFAULT_LOCALE, currentPath = "/admin/users" }) {
851
988
  const view = viewContext(locale, currentPath);
852
989
  const rows = users.map((item) => `
853
990
  <tr>
854
991
  <td>${escapeHtml(item.email)}</td>
855
992
  <td>${escapeHtml(item.name)}</td>
856
- <td>${escapeHtml(item.role)}</td>
857
- <td>${item.active ? "Active" : "Disabled"}${item.passwordResetRequired ? " · Reset required" : ""}</td>
858
- <td>${escapeHtml(item.createdAt)}</td>
859
- <td>
860
- ${item.id === user.id ? "" : `<form method="post" action="/admin/users/${encodeURIComponent(item.id)}/${item.active ? "disable" : "enable"}">
861
- <button type="submit">${item.active ? "Disable" : "Enable"}</button>
993
+ <td><form class="user-role-form" method="post" action="/admin/users/${encodeURIComponent(item.id)}/role">${localeInput(view.locale)}
994
+ <select name="role" aria-label="${view.text("users.roleFor", { email: item.email })}" class="form-select">
995
+ ${["user", "admin"].map((role) => `<option value="${role}"${item.role === role ? " selected" : ""}>${view.text(`ui.role.${role}`)}</option>`).join("")}
996
+ </select><button type="submit" aria-label="${view.text("users.saveRoleFor", { email: item.email })}" class="btn btn-outline-secondary">${view.text("users.saveRole")}</button>
997
+ </form></td>
998
+ <td>${item.active ? view.text("ui.active") : view.text("ui.disabled")}${item.passwordResetRequired ? ` · ${view.text("users.resetRequired")}` : ""}</td>
999
+ <td>${renderDateTime(item.createdAt, view)}</td>
1000
+ <td><div class="user-account-actions">
1001
+ <form method="post" action="/admin/users/${encodeURIComponent(item.id)}/reset-password">${localeInput(view.locale)}
1002
+ <button type="submit" aria-label="${view.text("users.resetFor", { email: item.email })}" class="btn btn-ghost">${view.text("users.resetPassword")}</button>
1003
+ </form>
1004
+ ${item.id === user.id ? "" : `<form method="post" action="/admin/users/${encodeURIComponent(item.id)}/${item.active ? "disable" : "enable"}">${localeInput(view.locale)}
1005
+ <button type="submit" class="btn btn-outline-danger">${item.active ? view.text("ui.disable") : view.text("ui.enable")}</button>
862
1006
  </form>`}
863
- </td>
1007
+ </div></td>
864
1008
  </tr>
865
1009
  `).join("");
866
1010
  const createdRows = (importResult?.created || []).map((item) => `
867
1011
  <tr>
868
1012
  <td>${escapeHtml(item.user.email)}</td>
869
- <td>${escapeHtml(item.user.role)}</td>
870
- <td>${item.user.passwordResetRequired ? "Yes" : "No"}</td>
871
- <td>${item.passwordGenerated ? `<code>${escapeHtml(item.temporaryPassword)}</code>` : "Provided in CSV"}</td>
1013
+ <td>${view.text(`ui.role.${item.user.role}`)}</td>
1014
+ <td>${item.user.passwordResetRequired ? view.text("ui.yes") : view.text("ui.no")}</td>
1015
+ <td>${item.passwordGenerated ? `<code>${escapeHtml(item.temporaryPassword)}</code>` : view.text("users.provided")}</td>
872
1016
  </tr>
873
1017
  `).join("");
874
1018
  const skippedRows = (importResult?.skipped || []).map((item) => `
@@ -879,72 +1023,76 @@ export function renderAdminUsersPage({ baseUrl, user, users = [], importResult =
879
1023
  `).join("");
880
1024
  const importSummary = importResult ? `
881
1025
  <section class="meta-card">
882
- <h2>Import results</h2>
883
- <p class="muted">${importResult.created.length} created, ${importResult.skipped.length} skipped, ${importResult.failed.length} failed.</p>
1026
+ <h2>${view.text("users.importResults")}</h2>
1027
+ <p class="muted">${view.text("users.summary", { created: importResult.created.length, skipped: importResult.skipped.length, failed: importResult.failed.length })}</p>
884
1028
  ${createdRows ? `<table class="data-table">
885
- <thead><tr><th>Email</th><th>Role</th><th>Reset required</th><th>Temporary password</th></tr></thead>
1029
+ <thead><tr><th>${view.text("ui.email")}</th><th>${view.text("ui.role")}</th><th>${view.text("users.resetRequired")}</th><th>${view.text("users.temporaryPassword")}</th></tr></thead>
886
1030
  <tbody>${createdRows}</tbody>
887
1031
  </table>` : ""}
888
- ${skippedRows ? `<h3>Skipped</h3><table class="data-table">
889
- <thead><tr><th>Row</th><th>Email</th><th>Reason</th></tr></thead>
1032
+ ${skippedRows ? `<h3>${view.text("users.skipped")}</h3><table class="data-table">
1033
+ <thead><tr><th>${view.text("ui.row")}</th><th>${view.text("ui.email")}</th><th>${view.text("ui.reason")}</th></tr></thead>
890
1034
  <tbody>${skippedRows}</tbody>
891
1035
  </table>` : ""}
892
- ${failedRows ? `<h3>Failed</h3><table class="data-table">
893
- <thead><tr><th>Row</th><th>Email</th><th>Error</th></tr></thead>
1036
+ ${failedRows ? `<h3>${view.text("users.failed")}</h3><table class="data-table">
1037
+ <thead><tr><th>${view.text("ui.row")}</th><th>${view.text("ui.email")}</th><th>${view.text("ui.error")}</th></tr></thead>
894
1038
  <tbody>${failedRows}</tbody>
895
1039
  </table>` : ""}
896
1040
  </section>
897
1041
  ` : "";
898
1042
 
899
1043
  return pageShell({
900
- title: "Users",
1044
+ title: view.raw("nav.users"),
901
1045
  body: `
1046
+ ${workspaceHeader(user, view)}
902
1047
  <header class="topbar">
903
1048
  <div>
904
- <h1>Users</h1>
1049
+ <h1>${view.text("nav.users")}</h1>
905
1050
  <p>${escapeHtml(baseUrl)}</p>
906
1051
  </div>
907
- <nav>
908
- <a href="${view.href("/")}">${view.text("nav.index")}</a>
909
- <a href="/admin/backup">Backup</a><a href="${view.href("/admin/access")}">${view.text("access.title")}</a>
910
- <a href="/admin/retention">${view.text("retention.navLabel")}</a>
911
- <a href="/account">Account</a>
912
- ${languageSwitcher(view)}
913
- </nav>
1052
+
914
1053
  </header>
915
- <main class="artifact-view">
1054
+ <main id="main-content" class="settings-page artifact-view">
1055
+ ${resetError ? `<p class="auth-error" role="alert">${escapeHtml(resetError)}</p>` : ""}
1056
+ ${resetResult ? `<section class="token-once" role="status"><h2>${view.text("users.resetReady")}</h2>
1057
+ <p>${view.text("users.resetAccount", { email: resetResult.user.email })}</p>
1058
+ <pre class="artifact-code"><code>${escapeHtml(resetResult.temporaryPassword)}</code></pre>
1059
+ <p>${view.text("users.resetHelp")}</p>
1060
+ <p>${view.text(`smtp.mailStatus.${["sent", "failed", "not-configured"].includes(resetResult.mailStatus) ? resetResult.mailStatus : "not-configured"}`)}</p>
1061
+ </section>` : ""}
916
1062
  <section class="meta-card">
917
- <h2>Create user</h2>
1063
+ <h2>${view.text("users.create")}</h2>
918
1064
  <form class="editor-form auth-form" method="post" action="/admin/users">
919
1065
  <section class="editor-fields">
920
- <label class="field"><span>Email</span><input type="email" name="email" required></label>
921
- <label class="field"><span>Name</span><input name="name"></label>
922
- <label class="field"><span>Role</span><select name="role"><option value="user">user</option><option value="admin">admin</option></select></label>
923
- <label class="field"><span>Password</span><input type="password" name="password" minlength="8" required></label>
924
- <label class="check-field"><input type="checkbox" name="passwordResetRequired"> Require password change</label>
1066
+ <label class="field"><span>${view.text("ui.email")}</span><input type="email" name="email" required class="form-control"></label>
1067
+ <label class="field"><span>${view.text("ui.name")}</span><input name="name" class="form-control"></label>
1068
+ <label class="field"><span>${view.text("ui.role")}</span><select name="role" class="form-select"><option value="user">${view.text("ui.role.user")}</option><option value="admin">${view.text("ui.role.admin")}</option></select></label>
1069
+ <label class="field"><span>${view.text("ui.password")}</span><input type="password" name="password" minlength="8" required class="form-control"></label>
1070
+ <label class="check-field"><input type="checkbox" name="passwordResetRequired" class="form-check-input"> ${view.text("users.requirePassword")}</label>
925
1071
  </section>
926
- <footer class="editor-actions"><button type="submit">Create user</button></footer>
1072
+ <footer class="editor-actions"><button type="submit" class="btn btn-primary">${view.text("users.create")}</button></footer>
927
1073
  </form>
928
1074
  </section>
929
1075
  <section class="meta-card">
930
- <h2>Import users from CSV</h2>
931
- <p class="muted">Use headers: email, name, role, password, password_reset_required. Missing passwords are generated and must be changed at first sign-in.</p>
1076
+ <h2>${view.text("users.importTitle")}</h2>
1077
+ <p class="muted">${view.text("users.csvHelp")}</p>
932
1078
  ${importError ? `<p class="auth-error">${escapeHtml(importError)}</p>` : ""}
933
1079
  <form class="editor-form" method="post" action="/admin/users/import">
934
1080
  <label class="field content-field">
935
1081
  <span>CSV</span>
936
- <textarea class="compact-textarea" name="csv" spellcheck="false" placeholder="email,name,role&#10;user@example.com,User,user" required></textarea>
1082
+ <textarea class="compact-textarea form-control" name="csv" spellcheck="false" placeholder="email,name,role&#10;user@example.com,User,user" required></textarea>
937
1083
  </label>
938
- <footer class="editor-actions"><button type="submit">Import users</button></footer>
1084
+ <footer class="editor-actions"><button type="submit" class="btn btn-outline-secondary">${view.text("users.import")}</button></footer>
939
1085
  </form>
940
1086
  </section>
941
1087
  ${importSummary}
942
1088
  <section class="meta-card">
943
- <h2>Existing users</h2>
944
- <table class="data-table">
945
- <thead><tr><th>Email</th><th>Name</th><th>Role</th><th>Status</th><th>Created</th><th></th></tr></thead>
1089
+ <h2>${view.text("users.existing")}</h2>
1090
+ ${roleError ? `<p class="auth-error" role="alert">${escapeHtml(roleError)}</p>` : ""}
1091
+ <p class="muted">${view.text("users.lastAdmin")}</p>
1092
+ <div class="artifact-table-scroll"><table class="data-table">
1093
+ <thead><tr><th>${view.text("ui.email")}</th><th>${view.text("ui.name")}</th><th>${view.text("ui.role")}</th><th>${view.text("ui.status")}</th><th>${view.text("ui.created")}</th><th></th></tr></thead>
946
1094
  <tbody>${rows}</tbody>
947
- </table>
1095
+ </table></div>
948
1096
  </section>
949
1097
  </main>
950
1098
  `,
@@ -952,7 +1100,7 @@ export function renderAdminUsersPage({ baseUrl, user, users = [], importResult =
952
1100
  });
953
1101
  }
954
1102
 
955
- export function renderAdminWebhooksPage({ baseUrl, webhooks = [], createdSecret = null, createdWebhookId = null, locale = DEFAULT_LOCALE, currentPath = "/admin/webhooks" }) {
1103
+ export function renderAdminWebhooksPage({ user, baseUrl, webhooks = [], createdSecret = null, createdWebhookId = null, locale = DEFAULT_LOCALE, currentPath = "/admin/webhooks" }) {
956
1104
  const view = viewContext(locale, currentPath);
957
1105
  const rows = webhooks.map((webhook) => `
958
1106
  <tr>
@@ -963,7 +1111,7 @@ export function renderAdminWebhooksPage({ baseUrl, webhooks = [], createdSecret
963
1111
  <td>${webhook.failureCount}</td>
964
1112
  <td>
965
1113
  <form method="post" action="/admin/webhooks/${encodeURIComponent(webhook.id)}/delete" onsubmit="return confirm('${escapeAttribute(view.text("webhooks.confirmDelete"))}')">
966
- <button type="submit">${view.text("webhooks.delete")}</button>
1114
+ <button type="submit" class="btn btn-outline-danger">${view.text("webhooks.delete")}</button>
967
1115
  </form>
968
1116
  </td>
969
1117
  </tr>
@@ -981,30 +1129,24 @@ export function renderAdminWebhooksPage({ baseUrl, webhooks = [], createdSecret
981
1129
  return pageShell({
982
1130
  title: view.text("webhooks.title"),
983
1131
  body: `
1132
+ ${workspaceHeader(user, view)}
984
1133
  <header class="topbar">
985
1134
  <div>
986
1135
  <h1>${view.text("webhooks.title")}</h1>
987
1136
  <p>${escapeHtml(baseUrl)}</p>
988
1137
  </div>
989
- <nav>
990
- <a href="${view.href("/")}">${view.text("nav.index")}</a>
991
- <a href="/admin/backup">Backup</a><a href="${view.href("/admin/access")}">${view.text("access.title")}</a>
992
- <a href="/admin/users">Users</a>
993
- <a href="/admin/retention">${view.text("retention.navLabel")}</a>
994
- <a href="/account">Account</a>
995
- ${languageSwitcher(view)}
996
- </nav>
1138
+
997
1139
  </header>
998
- <main class="artifact-view">
1140
+ <main id="main-content" class="settings-page artifact-view">
999
1141
  ${secretBanner}
1000
1142
  <section class="meta-card">
1001
1143
  <h2>${view.text("webhooks.createTitle")}</h2>
1002
1144
  <form class="editor-form" method="post" action="/admin/webhooks">
1003
1145
  <section class="editor-fields">
1004
- <label class="field"><span>${view.text("webhooks.url")}</span><input type="url" name="url" placeholder="https://example.com/hooks/artifacty" required></label>
1005
- <label class="field"><span>${view.text("webhooks.eventTypes")}</span><input name="eventTypes" placeholder="${view.text("webhooks.eventTypesPlaceholder")}"></label>
1146
+ <label class="field"><span>${view.text("webhooks.url")}</span><input type="url" name="url" placeholder="https://example.com/hooks/artifacty" required class="form-control"></label>
1147
+ <label class="field"><span>${view.text("webhooks.eventTypes")}</span><input name="eventTypes" placeholder="${view.text("webhooks.eventTypesPlaceholder")}" class="form-control"></label>
1006
1148
  </section>
1007
- <footer class="editor-actions"><button type="submit">${view.text("webhooks.create")}</button></footer>
1149
+ <footer class="editor-actions"><button type="submit" class="btn btn-outline-secondary">${view.text("webhooks.create")}</button></footer>
1008
1150
  </form>
1009
1151
  </section>
1010
1152
  <section class="meta-card">
@@ -1020,7 +1162,7 @@ export function renderAdminWebhooksPage({ baseUrl, webhooks = [], createdSecret
1020
1162
  });
1021
1163
  }
1022
1164
 
1023
- export function renderAdminRetentionPage({ baseUrl, policy, lastSweep = null, report = null, locale = DEFAULT_LOCALE, currentPath = "/admin/retention" }) {
1165
+ export function renderAdminRetentionPage({ user, baseUrl, policy, lastSweep = null, report = null, locale = DEFAULT_LOCALE, currentPath = "/admin/retention" }) {
1024
1166
  const view = viewContext(locale, currentPath);
1025
1167
  const byTypeText = Object.entries(policy.archiveAfterDays.byType || {})
1026
1168
  .map(([type, days]) => `${type}=${days}`)
@@ -1031,7 +1173,7 @@ export function renderAdminRetentionPage({ baseUrl, policy, lastSweep = null, re
1031
1173
  <h2>${view.text("retention.lastSweepTitle")}</h2>
1032
1174
  <table class="data-table">
1033
1175
  <tbody>
1034
- <tr><th>${view.text("retention.sweepAt")}</th><td>${escapeHtml(lastSweep.createdAt)}</td></tr>
1176
+ <tr><th>${view.text("retention.sweepAt")}</th><td>${renderDateTime(lastSweep.createdAt, view)}</td></tr>
1035
1177
  <tr><th>${view.text("retention.archived")}</th><td>${escapeHtml(lastSweep.metadata?.archived ?? 0)}</td></tr>
1036
1178
  <tr><th>${view.text("retention.purged")}</th><td>${escapeHtml(lastSweep.metadata?.purged ?? 0)}</td></tr>
1037
1179
  <tr><th>${view.text("retention.purgeSkipped")}</th><td>${lastSweep.metadata?.purgeSkipped ? view.text("retention.yes") : view.text("retention.no")}</td></tr>
@@ -1070,32 +1212,35 @@ export function renderAdminRetentionPage({ baseUrl, policy, lastSweep = null, re
1070
1212
  return pageShell({
1071
1213
  title: view.text("retention.title"),
1072
1214
  body: `
1215
+ ${workspaceHeader(user, view)}
1073
1216
  <header class="topbar">
1074
1217
  <div>
1075
1218
  <h1>${view.text("retention.title")}</h1>
1076
1219
  <p>${escapeHtml(baseUrl)}</p>
1077
1220
  </div>
1078
- <nav>
1079
- <a href="${view.href("/")}">${view.text("nav.index")}</a>
1080
- <a href="/admin/backup">Backup</a><a href="${view.href("/admin/access")}">${view.text("access.title")}</a>
1081
- <a href="/admin/users">Users</a><a href="${view.href("/admin/access")}">${view.text("access.title")}</a>
1082
- <a href="/account">Account</a>
1083
- ${languageSwitcher(view)}
1084
- </nav>
1221
+
1085
1222
  </header>
1086
- <main class="artifact-view">
1223
+ <main id="main-content" class="settings-page artifact-view">
1087
1224
  <section class="meta-card">
1088
1225
  <h2>${view.text("retention.policyTitle")}</h2>
1089
- <form class="editor-form" method="post" action="/admin/retention">
1090
- <section class="editor-fields">
1091
- <label class="field"><span>${view.text("retention.archiveAfterDaysDefault")}</span><input type="number" min="1" name="archiveAfterDaysDefault" value="${policy.archiveAfterDays.default ?? ""}"></label>
1092
- <label class="field content-field"><span>${view.text("retention.archiveAfterDaysByType")}</span><textarea class="compact-textarea" name="archiveAfterDaysByType" placeholder="test-report=30">${escapeHtml(byTypeText)}</textarea></label>
1093
- <label class="field"><span>${view.text("retention.purgeArchivedAfterDays")}</span><input type="number" min="1" name="purgeArchivedAfterDays" value="${policy.purgeArchivedAfterDays ?? ""}"></label>
1094
- <label class="field"><span>${view.text("retention.auditRetentionDays")}</span><input type="number" min="1" name="auditRetentionDays" value="${policy.auditRetentionDays ?? ""}"></label>
1095
- <label class="field"><span>${view.text("retention.eventRetentionRows")}</span><input type="number" min="1" name="eventRetentionRows" value="${policy.eventRetentionRows ?? ""}"></label>
1096
- <label class="field"><span>${view.text("retention.keepTags")}</span><input name="keepTags" value="${escapeAttribute((policy.keepTags || []).join(", "))}" placeholder="pinned, release"></label>
1097
- </section>
1098
- <footer class="editor-actions"><button type="submit">${view.text("retention.saveButton")}</button></footer>
1226
+ <form class="editor-form retention-policy-form" method="post" action="/admin/retention">${localeInput(view.locale)}
1227
+ <fieldset class="retention-section"><legend>${view.text("retention.artifactsSection")}</legend>
1228
+ <div class="retention-field-grid">
1229
+ <label class="field"><span>${view.text("retention.archiveAfterDaysDefault")}</span><input type="number" min="1" name="archiveAfterDaysDefault" value="${policy.archiveAfterDays.default ?? ""}" class="form-control"></label>
1230
+ <label class="field"><span>${view.text("retention.purgeArchivedAfterDays")}</span><input type="number" min="1" name="purgeArchivedAfterDays" value="${policy.purgeArchivedAfterDays ?? ""}" class="form-control"></label>
1231
+ </div>
1232
+ <label class="field"><span>${view.text("retention.keepTags")}</span><input name="keepTags" value="${escapeAttribute((policy.keepTags || []).join(", "))}" placeholder="pinned, release" class="form-control"></label>
1233
+ </fieldset>
1234
+ <fieldset class="retention-section"><legend>${view.text("retention.typeRulesSection")}</legend>
1235
+ <label class="field"><span>${view.text("retention.archiveAfterDaysByType")}</span><textarea class="compact-textarea form-control" name="archiveAfterDaysByType" placeholder="test-report=30">${escapeHtml(byTypeText)}</textarea></label>
1236
+ </fieldset>
1237
+ <fieldset class="retention-section"><legend>${view.text("retention.historySection")}</legend>
1238
+ <div class="retention-field-grid">
1239
+ <label class="field"><span>${view.text("retention.auditRetentionDays")}</span><input type="number" min="1" name="auditRetentionDays" value="${policy.auditRetentionDays ?? ""}" class="form-control"></label>
1240
+ <label class="field"><span>${view.text("retention.eventRetentionRows")}</span><input type="number" min="1" name="eventRetentionRows" value="${policy.eventRetentionRows ?? ""}" class="form-control"></label>
1241
+ </div>
1242
+ </fieldset>
1243
+ <footer class="editor-actions"><button type="submit" class="btn btn-primary">${view.text("retention.saveButton")}</button></footer>
1099
1244
  </form>
1100
1245
  </section>
1101
1246
  ${lastSweepSection}
@@ -1103,9 +1248,9 @@ export function renderAdminRetentionPage({ baseUrl, policy, lastSweep = null, re
1103
1248
  <h2>${view.text("retention.runTitle")}</h2>
1104
1249
  <p class="auth-warning">${view.text("retention.purgeGateNote")}</p>
1105
1250
  <form class="editor-form" method="post" action="/admin/retention/run">
1106
- <label class="checkbox-field"><input type="checkbox" name="dryRun" value="1" checked> ${view.text("retention.dryRunLabel")}</label>
1107
- <label class="checkbox-field"><input type="checkbox" name="allowPurge" value="1"> ${view.text("retention.allowPurgeLabel")}</label>
1108
- <footer class="editor-actions"><button type="submit">${view.text("retention.runButton")}</button></footer>
1251
+ <label class="checkbox-field"><input type="checkbox" name="dryRun" value="1" checked class="form-check-input"> ${view.text("retention.dryRunLabel")}</label>
1252
+ <label class="checkbox-field"><input type="checkbox" name="allowPurge" value="1" class="form-check-input"> ${view.text("retention.allowPurgeLabel")}</label>
1253
+ <footer class="editor-actions"><button type="submit" class="btn btn-outline-secondary">${view.text("retention.runButton")}</button></footer>
1109
1254
  </form>
1110
1255
  </section>
1111
1256
  ${reportSection}
@@ -1161,45 +1306,48 @@ export function renderArtifactPage({ artifact, version, content, comments = [],
1161
1306
  <span>${view.text("artifact.visibility.label")}</span>
1162
1307
  ${visibilitySelect(artifact.visibility || "team", view)}
1163
1308
  </label>
1164
- <button type="submit">${view.text("artifact.visibility.change")}</button>
1165
- </form>`
1309
+ <button type="submit" class="btn btn-outline-secondary">${view.text("artifact.visibility.change")}</button>
1310
+ </form><p class="muted">${view.text("artifact.visibility.help")}</p>`
1166
1311
  : "";
1167
1312
 
1168
1313
  return pageShell({
1169
1314
  title: artifact.title,
1170
1315
  body: `
1316
+ ${workspaceHeader(user, view)}
1171
1317
  <header class="topbar">
1172
1318
  <div>
1173
1319
  <h1>${escapeHtml(artifact.title)}</h1>
1174
1320
  <p>${escapeHtml(artifact.id)} · ${escapeHtml(artifact.sourceAgent)} · ${escapeHtml(baseUrl)}</p>
1175
1321
  </div>
1176
1322
  <nav>
1177
- <a href="${view.href("/")}">${view.text("nav.index")}</a>
1323
+
1178
1324
  ${canEdit ? `<a href="${view.href(`/artifacts/${encodeURIComponent(artifact.id)}/edit`)}">${view.text("nav.edit")}</a>` : ""}
1179
1325
  <a href="${view.href(`/artifacts/${encodeURIComponent(artifact.id)}/diff`)}">${view.text("nav.diff")}</a>
1180
- ${user?.role === "admin" ? `<a href="${view.href(`/admin/artifacts/${encodeURIComponent(artifact.id)}/versions`)}">Versions</a>` : ""}
1326
+ ${user?.role === "admin" ? `<a href="${view.href(`/admin/artifacts/${encodeURIComponent(artifact.id)}/versions`)}">${view.text("nav.versions")}</a>` : ""}
1181
1327
  <a href="${view.href(rawUrl)}">${view.text("nav.raw")}</a>
1182
1328
  ${user && canManageVisibility ? `<a href="${view.href(`/artifacts/${encodeURIComponent(artifact.id)}/sharing`)}">${view.text("sharing.title")}</a>` : ""}
1183
- ${authNav(user, view)}
1184
- ${languageSwitcher(view)}
1329
+
1330
+
1185
1331
  </nav>
1186
1332
  </header>
1187
- <main class="${viewClass}">
1188
- <section class="meta-strip">
1189
- ${formatBadge(version.format)}
1190
- ${typeBadge(artifact.artifactType || "document")}
1191
- <span>${view.text("artifact.schema", { version: artifact.schemaVersion || 1 })}</span>
1333
+ <main id="main-content" class="${viewClass}">
1334
+ <section class="meta-strip artifact-overview">
1335
+ <span>${escapeHtml(version.format)} · v${version.version}</span>
1192
1336
  ${artifact.archivedAt ? statusBadge(view.text("artifact.archived", { date: artifact.archivedAt })) : ""}
1193
1337
  ${visibilityBadge(artifact, view)}
1194
- <span title="${escapeAttribute(publisherTitle(artifact, view))}">${view.text("artifact.publisher", { publisher })}</span>
1195
- <span>${view.text("artifact.bytes", { size: version.sizeBytes })}</span>
1196
- <span>${escapeHtml(version.createdAt)}</span>
1197
- <span>${escapeHtml(version.sha256.slice(0, 12))}</span>
1338
+ <details class="artifact-details"><summary>${view.text("artifact.details")}</summary><div class="meta-strip">
1339
+ ${typeBadge(artifact.artifactType || "document")}
1340
+ <span>${view.text("artifact.schema", { version: artifact.schemaVersion || 1 })}</span>
1341
+ <span title="${escapeAttribute(publisherTitle(artifact, view))}">${view.text("artifact.publisher", { publisher })}</span>
1342
+ <span>${view.text("artifact.bytes", { size: version.sizeBytes })}</span>
1343
+ <span>${renderDateTime(version.createdAt, view)}</span>
1344
+ <span>${escapeHtml(version.sha256.slice(0, 12))}</span>
1345
+ </div></details>
1198
1346
  </section>
1199
1347
  ${canArchive ? `<form class="inline-action" method="post" action="/artifacts/${encodeURIComponent(artifact.id)}/${archiveAction}">
1200
1348
  ${hiddenToken(authToken)}
1201
1349
  ${localeInput(view.locale)}
1202
- <button type="submit">${archiveLabel}</button>
1350
+ <button type="submit" class="btn btn-outline-secondary">${archiveLabel}</button>
1203
1351
  </form>` : ""}
1204
1352
  ${visibilityForm}
1205
1353
  <section class="version-strip">${versionLinks}</section>
@@ -1209,7 +1357,7 @@ export function renderArtifactPage({ artifact, version, content, comments = [],
1209
1357
  </main>
1210
1358
  `,
1211
1359
  head: needsViewerScript ? editorHead() : "",
1212
- afterBody: `${frameResizeScript()}${needsViewerScript ? viewerScript() : ""}`,
1360
+ afterBody: `${frameResizeScript()}${needsViewerScript ? viewerScript(view.locale) : ""}`,
1213
1361
  locale: view.locale
1214
1362
  });
1215
1363
  }
@@ -1298,14 +1446,14 @@ function renderCommentEntry(artifact, comment, view, { authToken, canModerate, c
1298
1446
  ? `<form class="inline-action" method="post" action="/artifacts/${encodeURIComponent(artifact.id)}/comments/${encodeURIComponent(comment.id)}/resolve">
1299
1447
  ${hiddenToken(authToken)}
1300
1448
  ${localeInput(view.locale)}
1301
- <button type="submit">${view.text("comments.resolve")}</button>
1449
+ <button type="submit" class="btn btn-outline-secondary">${view.text("comments.resolve")}</button>
1302
1450
  </form>`
1303
1451
  : "";
1304
1452
  const deleteForm = canModerate
1305
1453
  ? `<form class="inline-action" method="post" action="/artifacts/${encodeURIComponent(artifact.id)}/comments/${encodeURIComponent(comment.id)}/delete">
1306
1454
  ${hiddenToken(authToken)}
1307
1455
  ${localeInput(view.locale)}
1308
- <button type="submit">${view.text("comments.delete")}</button>
1456
+ <button type="submit" class="btn btn-outline-secondary">${view.text("comments.delete")}</button>
1309
1457
  </form>`
1310
1458
  : "";
1311
1459
 
@@ -1313,7 +1461,7 @@ function renderCommentEntry(artifact, comment, view, { authToken, canModerate, c
1313
1461
  <li class="comment-item${isReply ? " comment-reply" : ""}${resolved ? " comment-resolved" : ""}">
1314
1462
  <div class="comment-header">
1315
1463
  <span class="comment-author">${escapeHtml(comment.authorLabel)}</span>
1316
- <span class="comment-meta">${escapeHtml(comment.createdAt)}</span>
1464
+ <span class="comment-meta">${renderDateTime(comment.createdAt, view)}</span>
1317
1465
  ${anchorLabel ? `<span class="comment-anchor">${anchorLabel}</span>` : ""}
1318
1466
  ${resolved ? `<span class="badge s-archived">${view.text("comments.resolved")}</span>` : ""}
1319
1467
  </div>
@@ -1351,13 +1499,13 @@ function renderCommentForm(artifact, view, { version, authToken, parentId, compa
1351
1499
  ${parentId ? `<input type="hidden" name="parentId" value="${escapeAttribute(parentId)}">` : ""}
1352
1500
  <label>
1353
1501
  <span>${view.text(compact ? "comments.replyLabel" : "comments.bodyLabel")}</span>
1354
- <textarea name="body" required maxlength="16384"></textarea>
1502
+ <textarea name="body" required maxlength="16384" class="form-control"></textarea>
1355
1503
  </label>
1356
1504
  ${compact ? "" : `<label class="comment-line-label">
1357
1505
  <span>${view.text("comments.lineLabel")}</span>
1358
- <input type="number" name="line" min="1">
1506
+ <input type="number" name="line" min="1" class="form-control">
1359
1507
  </label>`}
1360
- <button type="submit">${view.text(compact ? "comments.reply" : "comments.submit")}</button>
1508
+ <button type="submit" class="btn btn-primary">${view.text(compact ? "comments.reply" : "comments.submit")}</button>
1361
1509
  </form>
1362
1510
  `;
1363
1511
  }
@@ -1370,11 +1518,11 @@ function renderReviewStatusForm(artifact, view, authToken) {
1370
1518
  ${localeInput(view.locale)}
1371
1519
  <label>
1372
1520
  <span>${view.text("comments.reviewStatus.label")}</span>
1373
- <select name="status">
1521
+ <select name="status" class="form-select">
1374
1522
  ${REVIEW_STATUSES.map((status) => `<option value="${status}"${status === current ? " selected" : ""}>${view.text(`comments.reviewStatus.${status}`)}</option>`).join("")}
1375
1523
  </select>
1376
1524
  </label>
1377
- <button type="submit">${view.text("comments.reviewStatus.change")}</button>
1525
+ <button type="submit" class="btn btn-outline-secondary">${view.text("comments.reviewStatus.change")}</button>
1378
1526
  </form>
1379
1527
  `;
1380
1528
  }
@@ -1456,7 +1604,7 @@ function artifactViewClass({ artifact, version }) {
1456
1604
  return classes.join(" ");
1457
1605
  }
1458
1606
 
1459
- export function renderDiffPage({ artifact, fromVersion, toVersion, fromContent, toContent, diffRows, view: diffView = "lines", structuredDiff, baseUrl, locale = DEFAULT_LOCALE, currentPath = "/" }) {
1607
+ export function renderDiffPage({ user, artifact, fromVersion, toVersion, fromContent, toContent, diffRows, view: diffView = "lines", structuredDiff, baseUrl, locale = DEFAULT_LOCALE, currentPath = "/" }) {
1460
1608
  const view = viewContext(locale, currentPath);
1461
1609
  const title = view.raw("diff.title", { title: artifact.title });
1462
1610
  const versionOptions = artifact.versions
@@ -1481,8 +1629,8 @@ export function renderDiffPage({ artifact, fromVersion, toVersion, fromContent,
1481
1629
 
1482
1630
  const preview = toVersion.format === "html"
1483
1631
  ? `<section class="split-preview">
1484
- <div>${renderContent(fromVersion.format, fromContent)}</div>
1485
- <div>${renderContent(toVersion.format, toContent)}</div>
1632
+ <div>${renderContent(fromVersion.format, fromContent, {}, { view })}</div>
1633
+ <div>${renderContent(toVersion.format, toContent, {}, { view })}</div>
1486
1634
  </section>`
1487
1635
  : "";
1488
1636
 
@@ -1504,6 +1652,7 @@ export function renderDiffPage({ artifact, fromVersion, toVersion, fromContent,
1504
1652
  return pageShell({
1505
1653
  title,
1506
1654
  body: `
1655
+ ${workspaceHeader(user, view)}
1507
1656
  <header class="topbar">
1508
1657
  <div>
1509
1658
  <h1>${escapeHtml(title)}</h1>
@@ -1511,22 +1660,22 @@ export function renderDiffPage({ artifact, fromVersion, toVersion, fromContent,
1511
1660
  </div>
1512
1661
  <nav>
1513
1662
  <a href="${view.href(`/artifacts/${encodeURIComponent(artifact.id)}`)}">${view.text("nav.artifact")}</a>
1514
- ${languageSwitcher(view)}
1663
+
1515
1664
  </nav>
1516
1665
  </header>
1517
- <main class="artifact-view">
1666
+ <main id="main-content" class="artifact-view">
1518
1667
  <form class="diff-form" method="get" action="/artifacts/${encodeURIComponent(artifact.id)}/diff">
1519
1668
  ${localeInput(view.locale)}
1520
1669
  <input type="hidden" name="view" value="${escapeAttribute(resolvedView)}">
1521
1670
  <label class="field">
1522
1671
  <span>${view.text("diff.from")}</span>
1523
- <select name="from">${versionOptions}</select>
1672
+ <select name="from" class="form-select">${versionOptions}</select>
1524
1673
  </label>
1525
1674
  <label class="field">
1526
1675
  <span>${view.text("diff.to")}</span>
1527
- <select name="to">${versionOptions}</select>
1676
+ <select name="to" class="form-select">${versionOptions}</select>
1528
1677
  </label>
1529
- <button type="submit">${view.text("diff.compare")}</button>
1678
+ <button type="submit" class="btn btn-outline-secondary">${view.text("diff.compare")}</button>
1530
1679
  </form>
1531
1680
  <script>
1532
1681
  document.querySelector('select[name="from"]').value = ${JSON.stringify(String(fromVersion.version))};
@@ -1755,6 +1904,7 @@ function renderBundleFiles(content, options = {}) {
1755
1904
  }
1756
1905
 
1757
1906
  export function renderContent(format, content, metadata = {}, options = {}) {
1907
+ const view = options.view || viewContext(options.locale || DEFAULT_LOCALE, "/");
1758
1908
  if (format === "html") {
1759
1909
  return `<iframe class="artifact-frame" sandbox="allow-scripts allow-forms allow-popups" srcdoc="${escapeAttribute(htmlFrameContent(content))}"></iframe>`;
1760
1910
  }
@@ -1782,15 +1932,15 @@ export function renderContent(format, content, metadata = {}, options = {}) {
1782
1932
  }
1783
1933
 
1784
1934
  if (format === "notebook") {
1785
- return renderNotebook(content);
1935
+ return renderNotebook(content, view);
1786
1936
  }
1787
1937
 
1788
1938
  if (format === "sarif") {
1789
- return renderSarif(content);
1939
+ return renderSarif(content, view);
1790
1940
  }
1791
1941
 
1792
1942
  if (format === "csv") {
1793
- return renderCsv(content);
1943
+ return renderCsv(content, view);
1794
1944
  }
1795
1945
 
1796
1946
  if (format === "image") {
@@ -1804,7 +1954,7 @@ export function renderContent(format, content, metadata = {}, options = {}) {
1804
1954
  if (format === "code") {
1805
1955
  const language = metadata.language || metadata.artifactyImport?.language || "";
1806
1956
  return `<section class="artifact-code-viewer" data-artifacty-code-viewer data-language="${escapeAttribute(language)}">
1807
- <textarea hidden>${escapeHtml(content)}</textarea>
1957
+ <textarea hidden class="form-control">${escapeHtml(content)}</textarea>
1808
1958
  <pre class="artifact-code artifact-code-fallback"><code>${escapeHtml(content)}</code></pre>
1809
1959
  </section>`;
1810
1960
  }
@@ -1814,7 +1964,7 @@ export function renderContent(format, content, metadata = {}, options = {}) {
1814
1964
  return `<iframe class="artifact-frame artifact-react-frame" sandbox="allow-scripts" src="${escapeAttribute(options.reactFrameUrl)}"></iframe>`;
1815
1965
  }
1816
1966
  return `<section class="artifact-react-disabled">
1817
- <p>React rendering is disabled. Set <code>ARTIFACTY_ENABLE_REACT_RENDERER=true</code> to run this component in a sandboxed frame.</p>
1967
+ <p>${view.text("react.disabled")}</p>
1818
1968
  <pre class="artifact-code"><code>${escapeHtml(content)}</code></pre>
1819
1969
  </section>`;
1820
1970
  }
@@ -1905,53 +2055,62 @@ export function renderReactFramePage({ title, content }) {
1905
2055
  }
1906
2056
 
1907
2057
  export function pageShell({ title, body, head = "", afterBody = "", locale = DEFAULT_LOCALE }) {
2058
+ const darkThemeVariables = ` --bg: #15171b;
2059
+ --grid: rgba(120, 160, 200, 0.06);
2060
+ --panel: #1c1f24;
2061
+ --panel-2: #25292f;
2062
+ --text: #e6edf3;
2063
+ --muted: #adb5c0;
2064
+ --faint: #9aa5b2;
2065
+ --line: #343b44;
2066
+ --line-2: #596575;
2067
+ --accent: #9cbcf5;
2068
+ --accent-2: #bbd1f8;
2069
+ --accent-ink: #18253d;
2070
+ --accent-soft: #29374e;
2071
+ --ring: #9cbcf5;
2072
+ --code: #080c12;
2073
+ --code-text: #e6edf3;
2074
+ `;
1908
2075
  return `<!doctype html>
1909
2076
  <html lang="${escapeAttribute(locale)}">
1910
2077
  <head>
1911
2078
  <meta charset="utf-8">
2079
+ <script src="/assets/theme.js"></script><script type="module" src="/assets/auto-apply.js"></script>
2080
+ <script src="/assets/sidebar.js"></script>
1912
2081
  <meta name="viewport" content="width=device-width, initial-scale=1">
1913
2082
  <title>${escapeHtml(title)}</title>
1914
2083
  ${head}
1915
2084
  <style>
1916
2085
  :root {
1917
- color-scheme: light dark;
1918
- --bg: #eef1f5;
2086
+ color-scheme: light;
2087
+ --bg: #f5f6f8;
1919
2088
  --grid: rgba(31, 58, 99, 0.05);
1920
2089
  --panel: #ffffff;
1921
- --panel-2: #f3f6f9;
1922
- --text: #151b24;
1923
- --muted: #586273;
1924
- --faint: #828c9b;
1925
- --line: #e0e5ec;
1926
- --line-2: #cfd6df;
1927
- --accent: #0e7490;
1928
- --accent-2: #0b5e75;
2090
+ --panel-2: #eef0f3;
2091
+ --text: #20242b;
2092
+ --muted: #58616e;
2093
+ --faint: #626c79;
2094
+ --line: #dfe3e8;
2095
+ --line-2: #b9c1cc;
2096
+ --accent: #315ca8;
2097
+ --accent-2: #264a89;
1929
2098
  --accent-ink: #ffffff;
1930
- --accent-soft: rgba(14, 116, 144, 0.10);
1931
- --ring: rgba(14, 116, 144, 0.32);
2099
+ --accent-soft: #eaf0fb;
2100
+ --ring: #315ca8;
1932
2101
  --code: #0e1320;
1933
2102
  --code-text: #e6edf3;
1934
2103
  --sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans KR", sans-serif;
1935
2104
  --mono: ui-monospace, "SFMono-Regular", "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
1936
2105
  }
2106
+ :root[data-theme="dark"] {
2107
+ color-scheme: dark;
2108
+ ${darkThemeVariables}
2109
+ }
1937
2110
  @media (prefers-color-scheme: dark) {
1938
- :root {
1939
- --bg: #0b0f14;
1940
- --grid: rgba(120, 160, 200, 0.06);
1941
- --panel: #12171f;
1942
- --panel-2: #171d27;
1943
- --text: #e6edf3;
1944
- --muted: #9aa6b3;
1945
- --faint: #6b7686;
1946
- --line: #222a35;
1947
- --line-2: #2d3744;
1948
- --accent: #22b8cf;
1949
- --accent-2: #3ccbdd;
1950
- --accent-ink: #04161c;
1951
- --accent-soft: rgba(34, 184, 207, 0.14);
1952
- --ring: rgba(34, 184, 207, 0.40);
1953
- --code: #080c12;
1954
- --code-text: #e6edf3;
2111
+ :root:not([data-theme="light"]) {
2112
+ color-scheme: dark;
2113
+ ${darkThemeVariables}
1955
2114
  }
1956
2115
  }
1957
2116
  * { box-sizing: border-box; }
@@ -1959,13 +2118,10 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
1959
2118
  margin: 0;
1960
2119
  min-height: 100vh;
1961
2120
  background-color: var(--bg);
1962
- background-image:
1963
- linear-gradient(var(--grid) 1px, transparent 1px),
1964
- linear-gradient(90deg, var(--grid) 1px, transparent 1px);
1965
- background-size: 28px 28px;
1966
- background-position: center top;
2121
+
1967
2122
  color: var(--text);
1968
2123
  font-family: var(--sans);
2124
+ font-size: 14px;
1969
2125
  line-height: 1.55;
1970
2126
  letter-spacing: 0;
1971
2127
  -webkit-font-smoothing: antialiased;
@@ -1973,28 +2129,28 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
1973
2129
  a { color: var(--accent); text-decoration: none; }
1974
2130
  a:hover { color: var(--accent-2); text-decoration: underline; }
1975
2131
  .topbar {
1976
- position: sticky;
1977
- top: 0;
1978
- z-index: 10;
2132
+ position: relative;
2133
+ width: min(1440px, calc(100% - 64px));
2134
+ margin: 28px auto 20px;
1979
2135
  display: flex;
1980
2136
  align-items: center;
1981
2137
  justify-content: space-between;
1982
2138
  gap: 24px;
1983
- padding: 15px 28px;
2139
+ padding: 0 0 20px;
1984
2140
  border-bottom: 1px solid var(--line);
1985
- background: color-mix(in srgb, var(--panel) 86%, transparent);
1986
- backdrop-filter: saturate(150%) blur(10px);
2141
+ background: transparent;
1987
2142
  }
1988
2143
  .topbar h1 {
1989
2144
  margin: 0;
1990
- font-size: 20px;
1991
- line-height: 1.25;
2145
+ font-size: 25px;
2146
+ font-weight: 650;
2147
+ line-height: 1.3;
1992
2148
  letter-spacing: -0.01em;
1993
2149
  }
1994
2150
  .topbar p {
1995
2151
  margin: 5px 0 0;
1996
2152
  color: var(--muted);
1997
- font-family: var(--mono);
2153
+ font-family: var(--sans);
1998
2154
  font-size: 12px;
1999
2155
  overflow-wrap: anywhere;
2000
2156
  }
@@ -2008,14 +2164,14 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2008
2164
  .brand-mark { display: inline-flex; color: var(--accent); }
2009
2165
  .brand-text { display: grid; gap: 2px; }
2010
2166
  .brand-name {
2011
- font-family: var(--mono);
2167
+ font-family: var(--sans);
2012
2168
  font-size: 19px;
2013
2169
  font-weight: 600;
2014
2170
  letter-spacing: -0.01em;
2015
2171
  }
2016
2172
  .brand-tag {
2017
2173
  color: var(--muted);
2018
- font-family: var(--mono);
2174
+ font-family: var(--sans);
2019
2175
  font-size: 12px;
2020
2176
  overflow-wrap: anywhere;
2021
2177
  }
@@ -2024,7 +2180,7 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2024
2180
  align-items: center;
2025
2181
  gap: 3px;
2026
2182
  flex-wrap: wrap;
2027
- font-family: var(--mono);
2183
+ font-family: var(--sans);
2028
2184
  font-size: 13px;
2029
2185
  }
2030
2186
  .topbar nav > a {
@@ -2037,38 +2193,24 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2037
2193
  color: var(--text);
2038
2194
  text-decoration: none;
2039
2195
  }
2040
- .language-switcher {
2041
- display: inline-flex;
2042
- gap: 2px;
2043
- margin-left: 5px;
2044
- padding-left: 9px;
2045
- border-left: 1px solid var(--line);
2046
- }
2047
- .language-switcher a,
2048
- .language-switcher .active {
2049
- padding: 5px 8px;
2050
- border-radius: 7px;
2051
- }
2052
- .language-switcher .active {
2053
- color: var(--text);
2054
- font-weight: 700;
2055
- text-decoration: none;
2056
- cursor: default;
2057
- }
2196
+ .language-switcher { display: grid; gap: 8px; min-width: 0; }
2197
+ .language-switcher label { display: grid; gap: 6px; min-width: 0; }
2198
+ .language-switcher select { width: 100%; min-width: 0; max-width: 100%; }
2199
+ .language-switcher button { justify-self: start; }
2058
2200
  .dashboard,
2059
2201
  .artifact-view,
2060
2202
  .artifact-editor {
2061
- width: min(1180px, calc(100vw - 32px));
2203
+ width: min(1440px, calc(100% - 64px));
2062
2204
  margin: 28px auto 72px;
2063
2205
  }
2064
2206
  .artifact-view-wide {
2065
- width: min(1760px, calc(100vw - 24px));
2207
+ width: min(1760px, calc(100% - 64px));
2066
2208
  }
2067
2209
  .toolbar {
2068
- font-family: var(--mono);
2210
+ font-family: var(--sans);
2069
2211
  font-size: 12px;
2070
- letter-spacing: 0.08em;
2071
- text-transform: uppercase;
2212
+ letter-spacing: 0;
2213
+ text-transform: none;
2072
2214
  color: var(--faint);
2073
2215
  }
2074
2216
  .toolbar,
@@ -2096,34 +2238,8 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2096
2238
  .filter-form select {
2097
2239
  min-width: 120px;
2098
2240
  }
2099
- .dashboard-layout {
2100
- display: flex;
2101
- align-items: flex-start;
2102
- gap: 24px;
2103
- width: min(1180px, calc(100vw - 32px));
2104
- margin: 28px auto 72px;
2105
- }
2106
- .dashboard-layout .dashboard {
2107
- flex: 1;
2108
- min-width: 0;
2109
- width: auto;
2110
- margin: 0;
2111
- }
2112
- .dashboard-sidebar {
2113
- width: 240px;
2114
- flex: none;
2115
- border: 1px solid var(--line);
2116
- border-radius: 12px;
2117
- background: var(--panel);
2118
- padding: 14px;
2119
- }
2120
- .dashboard-sidebar h2 {
2121
- margin: 0 0 10px;
2122
- font-size: 13px;
2123
- text-transform: uppercase;
2124
- letter-spacing: 0.06em;
2125
- color: var(--faint);
2126
- }
2241
+ .dashboard-layout { width: min(1440px, calc(100% - 64px)); margin: 28px auto 48px; }
2242
+ .dashboard-layout .dashboard { min-width: 0; width: 100%; margin: 0; }
2127
2243
  .saved-view-list {
2128
2244
  list-style: none;
2129
2245
  margin: 0 0 14px;
@@ -2160,8 +2276,8 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2160
2276
  margin: 0;
2161
2277
  padding: 10px 14px;
2162
2278
  font-size: 12px;
2163
- text-transform: uppercase;
2164
- letter-spacing: 0.06em;
2279
+ text-transform: none;
2280
+ letter-spacing: 0;
2165
2281
  color: var(--faint);
2166
2282
  background: var(--panel-2);
2167
2283
  border-bottom: 1px solid var(--line);
@@ -2226,17 +2342,24 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2226
2342
  }
2227
2343
  .artifact-list {
2228
2344
  border: 1px solid var(--line);
2229
- border-radius: 12px;
2345
+ border-radius: 8px;
2230
2346
  overflow: hidden;
2231
2347
  background: var(--panel);
2232
- box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
2233
- }
2348
+ box-shadow: none;
2349
+ }
2350
+ .dashboard-pagination { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; margin-top: 16px; }
2351
+ .page-size-form, .page-size-form label { display: flex; align-items: center; gap: 8px; }
2352
+ .page-size-form { margin: 0; }
2353
+ .page-size-form select { min-width: 76px; width: auto; min-height: 44px; }
2354
+ .page-size-form button { min-height: 44px; }
2355
+ .pager-position { align-self: center; white-space: nowrap; }
2234
2356
  .pager {
2235
2357
  display: flex;
2236
- justify-content: flex-end;
2358
+ flex-wrap: wrap;
2359
+ justify-content: flex-start;
2237
2360
  gap: 8px;
2238
- margin-top: 14px;
2239
- font-family: var(--mono);
2361
+ margin-top: 0;
2362
+ font-family: var(--sans);
2240
2363
  font-size: 12.5px;
2241
2364
  }
2242
2365
  .pager a,
@@ -2342,8 +2465,8 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2342
2465
  color: var(--faint);
2343
2466
  font-family: var(--mono);
2344
2467
  font-size: 11.5px;
2345
- letter-spacing: 0.06em;
2346
- text-transform: uppercase;
2468
+ letter-spacing: 0;
2469
+ text-transform: none;
2347
2470
  }
2348
2471
  .data-table form {
2349
2472
  margin: 0;
@@ -2361,11 +2484,12 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2361
2484
  font-size: 13px;
2362
2485
  }
2363
2486
  .field > span {
2364
- font-family: var(--mono);
2365
- font-size: 11.5px;
2366
- letter-spacing: 0.06em;
2367
- text-transform: uppercase;
2368
- color: var(--faint);
2487
+ font-family: var(--sans);
2488
+ font-size: 12px;
2489
+ font-weight: 500;
2490
+ letter-spacing: 0;
2491
+ text-transform: none;
2492
+ color: var(--muted);
2369
2493
  }
2370
2494
  .check-field {
2371
2495
  display: flex;
@@ -2380,8 +2504,6 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2380
2504
  min-height: 0;
2381
2505
  padding: 0;
2382
2506
  }
2383
- input,
2384
- select,
2385
2507
  textarea {
2386
2508
  width: 100%;
2387
2509
  border: 1px solid var(--line-2);
@@ -2393,13 +2515,13 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2393
2515
  }
2394
2516
  input::placeholder,
2395
2517
  textarea::placeholder { color: var(--faint); }
2396
- input,
2518
+ input:not([type="checkbox"]):not([type="radio"]),
2397
2519
  select {
2398
2520
  min-height: 38px;
2399
2521
  padding: 7px 10px;
2400
2522
  }
2401
2523
  textarea {
2402
- min-height: 52vh;
2524
+ min-height: 120px;
2403
2525
  padding: 14px;
2404
2526
  resize: vertical;
2405
2527
  font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
@@ -2408,6 +2530,8 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2408
2530
  white-space: pre;
2409
2531
  overflow: auto;
2410
2532
  }
2533
+ textarea[data-artifacty-editor] { min-height: 52vh; }
2534
+ .comments textarea { min-height: 96px; white-space: pre-wrap; }
2411
2535
  .compact-textarea {
2412
2536
  min-height: 160px;
2413
2537
  }
@@ -2465,60 +2589,13 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2465
2589
  border: 0;
2466
2590
  background: var(--panel);
2467
2591
  }
2468
- input:focus,
2469
- select:focus,
2470
- textarea:focus {
2471
- outline: 3px solid var(--ring);
2472
- outline-offset: 0;
2473
- border-color: var(--accent);
2474
- }
2592
+ button:not(.btn) { font: inherit; color: var(--text); background: var(--panel); border: 1px solid var(--line-2); border-radius: 6px; padding: 6px 10px; cursor: pointer; }
2475
2593
  .editor-actions {
2476
2594
  display: flex;
2477
2595
  align-items: center;
2478
2596
  justify-content: flex-end;
2479
2597
  gap: 12px;
2480
2598
  }
2481
- button {
2482
- min-height: 38px;
2483
- border: 1px solid var(--accent);
2484
- border-radius: 8px;
2485
- padding: 7px 15px;
2486
- background: var(--accent);
2487
- color: var(--accent-ink);
2488
- font: inherit;
2489
- font-weight: 600;
2490
- cursor: pointer;
2491
- transition: background 0.15s ease, border-color 0.15s ease;
2492
- }
2493
- button:hover { background: var(--accent-2); border-color: var(--accent-2); }
2494
- .button-link {
2495
- display: inline-flex;
2496
- min-height: 38px;
2497
- align-items: center;
2498
- justify-content: center;
2499
- padding: 7px 15px;
2500
- border: 1px solid var(--accent);
2501
- border-radius: 8px;
2502
- background: var(--accent);
2503
- color: var(--accent-ink);
2504
- font-weight: 600;
2505
- }
2506
- .button-link:hover {
2507
- border-color: var(--accent-2);
2508
- background: var(--accent-2);
2509
- color: var(--accent-ink);
2510
- text-decoration: none;
2511
- }
2512
- .inline-action button {
2513
- min-height: 32px;
2514
- padding: 5px 12px;
2515
- font-family: var(--mono);
2516
- font-size: 12px;
2517
- font-weight: 500;
2518
- border-color: var(--line-2);
2519
- background: var(--panel);
2520
- color: var(--muted);
2521
- }
2522
2599
  .inline-action button:hover {
2523
2600
  background: var(--panel-2);
2524
2601
  border-color: var(--accent);
@@ -2570,7 +2647,7 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2570
2647
  overflow-wrap: anywhere;
2571
2648
  }
2572
2649
  .artifact-row > span:not(.row-main):not(.tags):not(.badge) {
2573
- font-family: var(--mono);
2650
+ font-family: var(--sans);
2574
2651
  font-size: 12.5px;
2575
2652
  color: var(--muted);
2576
2653
  }
@@ -2596,7 +2673,7 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2596
2673
  border-radius: 999px;
2597
2674
  background: var(--panel-2);
2598
2675
  color: var(--muted);
2599
- font-family: var(--mono);
2676
+ font-family: var(--sans);
2600
2677
  font-size: 11.5px;
2601
2678
  }
2602
2679
  .badge {
@@ -2604,10 +2681,10 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2604
2681
  align-items: center;
2605
2682
  padding: 3px 9px;
2606
2683
  border-radius: 999px;
2607
- border: 1px solid color-mix(in srgb, var(--bh, var(--muted)) 38%, var(--line));
2608
- background: color-mix(in srgb, var(--bh, var(--muted)) 13%, transparent);
2609
- color: color-mix(in srgb, var(--bh, var(--muted)) 62%, var(--text));
2610
- font-family: var(--mono);
2684
+ border: 1px solid var(--line);
2685
+ background: var(--panel-2);
2686
+ color: var(--muted);
2687
+ font-family: var(--sans);
2611
2688
  font-size: 11.5px;
2612
2689
  line-height: 1.45;
2613
2690
  white-space: nowrap;
@@ -2694,9 +2771,9 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2694
2771
  width: 100%;
2695
2772
  min-height: 320px;
2696
2773
  border: 1px solid var(--line);
2697
- border-radius: 12px;
2774
+ border-radius: 8px;
2698
2775
  background: var(--panel);
2699
- box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
2776
+ box-shadow: none;
2700
2777
  }
2701
2778
  .split-preview {
2702
2779
  display: grid;
@@ -2790,12 +2867,12 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2790
2867
  .artifact-code {
2791
2868
  margin: 0;
2792
2869
  border: 1px solid var(--line);
2793
- border-radius: 12px;
2870
+ border-radius: 8px;
2794
2871
  background: var(--panel);
2795
2872
  }
2796
2873
  .artifact-doc {
2797
2874
  padding: 32px;
2798
- box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
2875
+ box-shadow: none;
2799
2876
  }
2800
2877
  .artifact-doc h1,
2801
2878
  .artifact-doc h2,
@@ -2831,10 +2908,16 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2831
2908
  border-radius: 10px;
2832
2909
  overflow: hidden;
2833
2910
  }
2834
- .artifact-doc-codemirror .cm-editor {
2835
- background: var(--code);
2836
- color: var(--code-text);
2837
- }
2911
+ .artifact-doc-codemirror .cm-editor,
2912
+ .artifact-code-viewer .cm-editor { min-height: 0; background: #f8f9fb; color: #20242b; }
2913
+ .artifact-doc-codemirror .cm-editor .cm-content,
2914
+ .artifact-code-viewer .cm-editor .cm-content { min-height: 0; }
2915
+ .artifact-doc-codemirror .cm-editor .cm-gutters,
2916
+ .artifact-code-viewer .cm-editor .cm-gutters { background: #eef0f3; color: #626c79; border-color: #dfe3e8; }
2917
+ .artifact-doc-codemirror .cm-editor .cm-activeLine,
2918
+ .artifact-doc-codemirror .cm-editor .cm-activeLineGutter,
2919
+ .artifact-code-viewer .cm-editor .cm-activeLine,
2920
+ .artifact-code-viewer .cm-editor .cm-activeLineGutter { background: transparent; }
2838
2921
  .artifact-doc-codemirror .cm-scroller {
2839
2922
  font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
2840
2923
  font-size: 13px;
@@ -2887,7 +2970,7 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2887
2970
  .artifact-notebook-cell-code {
2888
2971
  padding: 14px;
2889
2972
  border: 1px solid var(--line);
2890
- border-radius: 12px;
2973
+ border-radius: 8px;
2891
2974
  background: var(--panel);
2892
2975
  }
2893
2976
  .artifact-notebook-execcount {
@@ -2976,8 +3059,8 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2976
3059
  color: var(--muted);
2977
3060
  font-family: var(--mono);
2978
3061
  font-size: 11.5px;
2979
- text-transform: uppercase;
2980
- letter-spacing: 0.06em;
3062
+ text-transform: none;
3063
+ letter-spacing: 0;
2981
3064
  }
2982
3065
  .summary-card strong {
2983
3066
  font-size: 20px;
@@ -3070,7 +3153,7 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
3070
3153
  }
3071
3154
  .artifact-raw-details {
3072
3155
  border: 1px solid var(--line);
3073
- border-radius: 12px;
3156
+ border-radius: 8px;
3074
3157
  background: var(--panel);
3075
3158
  overflow: hidden;
3076
3159
  }
@@ -3092,9 +3175,9 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
3092
3175
  margin: 0;
3093
3176
  padding: 14px;
3094
3177
  border: 1px solid var(--line);
3095
- border-radius: 12px;
3178
+ border-radius: 8px;
3096
3179
  background: var(--panel);
3097
- box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
3180
+ box-shadow: none;
3098
3181
  }
3099
3182
  .artifact-media img,
3100
3183
  .artifact-media video {
@@ -3123,25 +3206,17 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
3123
3206
  }
3124
3207
  .artifact-code-viewer {
3125
3208
  border: 1px solid var(--line);
3126
- border-radius: 12px;
3209
+ border-radius: 8px;
3127
3210
  overflow: hidden;
3128
3211
  background: var(--code);
3129
3212
  }
3130
- .artifact-code-viewer .cm-editor {
3131
- min-height: 50vh;
3132
- background: var(--code);
3133
- color: var(--code-text);
3134
- }
3213
+
3135
3214
  .artifact-code-viewer .cm-scroller {
3136
3215
  font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
3137
3216
  font-size: 13px;
3138
3217
  line-height: 1.55;
3139
3218
  }
3140
- .artifact-code-viewer .cm-gutters {
3141
- background: color-mix(in srgb, var(--code) 84%, white);
3142
- color: var(--muted);
3143
- border-color: rgba(255, 255, 255, 0.08);
3144
- }
3219
+
3145
3220
  .artifact-react-disabled {
3146
3221
  display: grid;
3147
3222
  gap: 12px;
@@ -3160,11 +3235,9 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
3160
3235
  font-size: 13px;
3161
3236
  }
3162
3237
  @media (max-width: 760px) {
3163
- .topbar {
3164
- align-items: flex-start;
3165
- flex-direction: column;
3166
- padding: 20px;
3167
- }
3238
+ .topbar { align-items: flex-start; flex-direction: column; gap: 12px; }
3239
+ .topbar, .dashboard-layout, .artifact-view, .artifact-editor, .artifact-view-wide { width: calc(100% - 32px); margin-top: 20px; }
3240
+ input:not([type="checkbox"]):not([type="radio"]), select, button { min-height: 44px; }
3168
3241
  .artifact-row {
3169
3242
  grid-template-columns: 1fr;
3170
3243
  gap: 6px;
@@ -3186,7 +3259,163 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
3186
3259
  padding: 18px;
3187
3260
  }
3188
3261
  }
3262
+ .artifact-editor { max-width: 1000px; }
3263
+ .auth-panel { max-width: 520px; }
3264
+ .meta-card { padding: 24px; }
3265
+ .meta-card h2 { font-size: 16px; font-weight: 650; }
3266
+ .meta-card table { display: block; max-width: 100%; overflow-x: auto; }
3267
+ .artifact-doc { font-size: 15px; line-height: 1.7; }
3268
+ .workspace-header { background: var(--panel); border-bottom: 1px solid var(--line); padding: 12px 24px; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
3269
+ .workspace-brand-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
3270
+ .workspace-brand { display: flex; align-items: center; gap: 10px; color: var(--text); font-size: 19px; font-weight: 700; letter-spacing: -.04em; }
3271
+ .workspace-brand svg { width: 27px; height: 27px; color: var(--accent); }
3272
+ .workspace-desktop { display: none; }
3273
+ .workspace-mobile > summary { cursor: pointer; padding: 8px 12px; border: 1px solid var(--line-2); border-radius: 6px; }
3274
+ .workspace-mobile[open] { flex-basis: 100%; }
3275
+ .workspace-header:has(.workspace-mobile[open]) { flex-wrap: wrap; }
3276
+ .workspace-mobile nav { padding-top: 16px; }
3277
+ .workspace-primary, .workspace-actions { display: grid; gap: 4px; }
3278
+ .workspace-primary { padding-bottom: 16px; border-bottom: 1px solid var(--line); margin-bottom: 16px; }
3279
+ .workspace-header nav a, .workspace-dropdown summary, .workspace-dropdown .nav-form button { display: flex; align-items: center; gap: 10px; min-height: 38px; padding: 8px 10px; color: var(--muted); border-radius: 6px; font-size: 14px; text-align: left; }
3280
+ .workspace-header nav a:hover, .workspace-dropdown summary:hover { background: var(--panel-2); color: var(--text); text-decoration: none; }
3281
+ .workspace-header [aria-current="page"], .workspace-header .is-current { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
3282
+ .workspace-dropdown summary { cursor: pointer; list-style: none; }
3283
+ .workspace-dropdown summary::-webkit-details-marker { display: none; }
3284
+ .workspace-chevron { margin-left: auto; display: inline-flex; }
3285
+ .workspace-dropdown[open] > summary .workspace-chevron { transform: rotate(180deg); }
3286
+ .workspace-version { color: var(--faint); font-size: 12px; padding: 10px; }
3287
+ .settings-page { max-width: 1100px; }
3288
+ .settings-page .meta-card { gap: 16px; }
3289
+ .settings-page .meta-card p { margin: 0; }
3290
+ .settings-page form > button { justify-self: start; width: fit-content; }
3291
+ .settings-page form:not(.editor-form) { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
3292
+ .settings-page .field { min-width: 0; }
3293
+ .settings-page textarea[name="allowedCidrs"] { min-height: 120px; height: 140px; }
3294
+ .settings-page .editor-form { gap: 12px; }
3295
+ .settings-page .user-role-form { display: flex; flex-wrap: nowrap; gap: 8px; align-items: center; }
3296
+ .user-account-actions { display: grid; gap: 8px; justify-items: start; }
3297
+
3298
+ .user-role-form select { width: auto; min-width: 105px; }
3299
+ .user-role-form select, .user-role-form button { margin: 0; }
3300
+
3301
+ .meta-card:has(> form[action="/admin/retention/run"]) { min-width: 0; overflow-wrap: anywhere; }
3302
+ form[action="/admin/retention/run"], form[action="/admin/retention/run"] > * { min-width: 0; }
3303
+ form[action="/admin/retention/run"] .checkbox-field { display: block; white-space: normal; overflow-wrap: anywhere; line-height: 1.6; }
3304
+ form[action="/admin/retention/run"] .checkbox-field input { margin-right: 6px; vertical-align: middle; }
3305
+ .retention-policy-form .retention-section { display: grid; gap: 16px; min-width: 0; margin: 0; padding: 16px; border: 1px solid var(--line); border-radius: 6px; }
3306
+ .retention-policy-form legend { padding: 0 6px; font-weight: 600; }
3307
+ .smtp-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; align-items: start; }
3308
+ .smtp-settings-form input, .smtp-settings-form select { min-width: 0; }
3309
+ .smtp-settings-form .field { align-content: start; }
3310
+ @media (max-width: 640px) { .smtp-field-grid { grid-template-columns: minmax(0, 1fr); } }
3311
+ .retention-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; gap: 16px; }
3312
+ .retention-policy-form .field { display: grid; gap: 8px; align-content: start; }
3313
+ .retention-policy-form input, .retention-policy-form textarea { width: 100%; min-width: 0; box-sizing: border-box; }
3314
+ .retention-policy-form textarea { min-height: 112px; height: 112px; }
3315
+ @media (max-width: 640px) { .retention-field-grid { grid-template-columns: minmax(0, 1fr); } }
3316
+
3317
+ .workspace-dropdown-content { display: grid; padding-left: 10px; border-left: 1px solid var(--line); margin: 4px 0 8px 10px; }
3318
+ .workspace-dropdown-content .nav-form { display: block; }
3319
+ .workspace-identity { overflow-wrap: anywhere; font-size: 12px; color: var(--faint); padding: 8px 10px; }
3320
+ .theme-controls { border: 0; min-width: 0; margin: 16px 10px 0; padding: 0; }
3321
+ .theme-controls legend { color: var(--muted); font-size: 12px; margin-bottom: 6px; }
3322
+ .theme-choices { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); padding: 3px; gap: 2px; background: var(--panel-2); border-radius: 8px; }
3323
+ .theme-choices .btn { padding: 6px; min-width: 0; border: 0; display: inline-flex; justify-content: center; align-items: center; background: transparent; color: var(--muted); }
3324
+ .theme-choices .btn[aria-pressed="true"] { background: var(--panel); color: var(--accent); box-shadow: 0 1px 3px #0002; }
3325
+ .workspace-header svg { flex-shrink: 0; }
3326
+ .workspace-header .sidebar-toggle { display: none; }
3327
+ .sidebar-toggle > span:not([hidden]) { display: flex; align-items: center; }
3328
+ .workspace-brand-row svg { display: block; }
3329
+ @media (min-width: 1100px) {
3330
+ .sidebar-toggle:not([hidden]) { display: inline-flex; position: static; width: 36px; height: 36px; padding: 0; flex-shrink: 0; }
3331
+ .workspace-desktop { padding-top: 0; }
3332
+ :root[data-sidebar="collapsed"] body:has(> .workspace-header) { padding-left: 68px; }
3333
+ :root[data-sidebar="collapsed"] .workspace-header { width: 68px; padding: 24px 10px; }
3334
+ :root[data-sidebar="collapsed"] .workspace-brand { display: none; }
3335
+ :root[data-sidebar="collapsed"] .workspace-brand-row { justify-content: center; }
3336
+
3337
+ :root[data-sidebar="collapsed"] .workspace-nav-label, :root[data-sidebar="collapsed"] .workspace-brand-name, :root[data-sidebar="collapsed"] .workspace-chevron, :root[data-sidebar="collapsed"] .workspace-version, :root[data-sidebar="collapsed"] .workspace-identity, :root[data-sidebar="collapsed"] .language-switcher, :root[data-sidebar="collapsed"] .theme-controls { display: none; }
3338
+ :root[data-sidebar="collapsed"] .workspace-dropdown-content { padding-left: 0; border: 0; margin-left: 0; }
3339
+ :root[data-sidebar="collapsed"] .workspace-header nav a, :root[data-sidebar="collapsed"] .workspace-dropdown summary, :root[data-sidebar="collapsed"] .nav-form button { justify-content: center; padding: 10px; }
3340
+ :root[data-sidebar="collapsed"] .workspace-header [aria-current="page"] { box-shadow: inset 3px 0 var(--accent); }
3341
+ :root[data-sidebar="collapsed"] .workspace-header nav a:is(:hover, :focus-visible) .workspace-nav-label, :root[data-sidebar="collapsed"] .workspace-dropdown > summary:is(:hover, :focus-visible) .workspace-nav-label, :root[data-sidebar="collapsed"] .nav-form button:is(:hover, :focus-visible) .workspace-nav-label { display: block; position: fixed; left: 76px; z-index: 50; padding: 6px 10px; max-width: 240px; background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 6px; box-shadow: 0 3px 12px #0002; pointer-events: none; }
3342
+ }
3343
+ .workspace-header .language-switcher { margin: 12px 0 0; padding: 0 10px; font-size: 12px; }
3344
+
3345
+ .workspace-skip { position: absolute; left: 16px; top: -100px; z-index: 100; background: var(--panel); padding: 8px 16px; }
3346
+ .workspace-skip:focus { top: 8px; }
3347
+ :focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; }
3348
+ .dashboard-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
3349
+ .dashboard-heading h1 { font-size: 25px; font-weight: 650; line-height: 1.3; margin: 0; letter-spacing: -.025em; }
3350
+ .dashboard-heading nav { display: flex; align-items: center; gap: 16px; }
3351
+ .dashboard-heading .button-link { padding: 8px 14px; background: var(--accent); color: var(--accent-ink); border-radius: 6px; }
3352
+ .dashboard .filter-form { display: block; width: 100%; margin-bottom: 0; }
3353
+ .dashboard-search-row { display: flex; align-items: center; gap: 12px; }
3354
+ .dashboard-search { flex: 1; min-width: 0; }
3355
+ .dashboard-search input { width: 100%; min-width: 0; background: var(--panel); }
3356
+ .dashboard-clear { white-space: nowrap; padding: 8px 0; font-size: 13px; }
3357
+ .dashboard-sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
3358
+ .dashboard-advanced, .dashboard-saved-views { margin: 0; border-bottom: 1px solid var(--line); }
3359
+ .dashboard-advanced > summary, .dashboard-saved-views > summary { padding: 10px 0; cursor: pointer; color: var(--muted); font-size: 13px; }
3360
+ .dashboard-filter-count { color: var(--faint); margin-left: 8px; font-size: 12px; }
3361
+ .dashboard-filter-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; padding: 12px 0 20px; }
3362
+ .dashboard-field { display: grid; gap: 6px; min-width: 0; color: var(--muted); font-size: 12px; }
3363
+ .dashboard-field input, .dashboard-field select { min-width: 0; width: 100%; font-size: 14px; }
3364
+ .dashboard-filter-actions { grid-column: 1 / -1; }
3365
+ .dashboard-active-filters { display: flex; gap: 4px 16px; flex-wrap: wrap; font-size: 12px; color: var(--muted); margin: 10px 0; overflow-wrap: anywhere; }
3366
+ .dashboard-saved-content { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; padding: 8px 0 20px; }
3367
+ .dashboard-saved-content .empty { padding: 0; }
3368
+ .dashboard .saved-view-form { align-content: start; }
3369
+ .dashboard .saved-view-list { margin: 0; }
3370
+ .dashboard .saved-view-item { min-width: 0; }
3371
+ .artifact-overview { align-items: flex-start; }
3372
+ .artifact-details { min-width: 0; }
3373
+ .artifact-details > summary { cursor: pointer; color: var(--muted); font-size: 13px; }
3374
+ .artifact-details > .meta-strip { padding: 12px 0 0; max-width: 100%; overflow-wrap: anywhere; }
3375
+ .dashboard-results { margin: 18px 0 10px; display: flex; justify-content: space-between; }
3376
+ .dashboard-results > span + span { color: var(--faint); font-size: 12px; }
3377
+ .dashboard .row-main strong { white-space: normal; overflow-wrap: anywhere; font-size: 15px; line-height: 1.45; font-weight: 600; }
3378
+ .dashboard .row-agent, .dashboard .row-formats { display: grid; gap: 2px; min-width: 0; font-size: 12px; color: var(--muted); }
3379
+ .dashboard .row-formats { display: flex; flex-wrap: wrap; gap: 4px 8px; }
3380
+ .dashboard .row-formats .badge { border: 0; padding: 0; background: transparent; color: var(--muted); font: inherit; border-radius: 0; }
3381
+ .dashboard .artifact-row .tag { border: 0; background: transparent; padding: 0; border-radius: 0; font-family: var(--sans); font-size: 12px; }
3382
+ .dashboard .artifact-row .tag + .tag::before { content: "·"; margin-right: 6px; color: var(--faint); }
3383
+ .dashboard .tags { min-width: 0; overflow-wrap: anywhere; }
3384
+ .dashboard .row-agent .publisher-cell { font-size: 12px; color: var(--faint); }
3385
+ .dashboard .artifact-row { grid-template-columns: minmax(180px, 1fr) minmax(100px, 150px) minmax(100px, 145px) minmax(80px, 150px); min-height: 76px; padding: 14px 16px; gap: 16px; }
3386
+ @media (min-width: 1100px) {
3387
+ body:has(> .workspace-header) { padding-left: 196px; }
3388
+ .workspace-header { position: fixed; inset: 0 auto 0 0; width: 196px; padding: 24px 12px; border-bottom: 0; border-right: 1px solid var(--line); display: block; overflow-y: auto; z-index: 20; }
3389
+ .workspace-brand-row { min-height: 36px; margin-bottom: 18px; }
3390
+ .workspace-brand { padding: 0 0 0 10px; }
3391
+ .workspace-desktop { display: block; }
3392
+ .workspace-mobile { display: none; }
3393
+ }
3394
+ @media (max-width: 1099px) {
3395
+ .dashboard-filter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
3396
+ }
3397
+ @media (max-width: 899px) {
3398
+ .dashboard .artifact-row { grid-template-columns: auto minmax(0, 1fr); gap: 8px 12px; }
3399
+ .dashboard .row-main, .dashboard .tags { grid-column: 1 / -1; }
3400
+ .dashboard .row-agent { max-width: 42vw; }
3401
+ .dashboard .row-agent, .dashboard .row-formats { display: flex; gap: 8px; flex-wrap: wrap; }
3402
+ .dashboard .row-agent .publisher-cell { max-width: 100%; }
3403
+ .dashboard .tags { gap: 6px; }
3404
+ }
3405
+ @media (max-width: 640px) {
3406
+ .workspace-header { padding: 12px 16px; }
3407
+ .workspace-header nav a, .workspace-dropdown summary, .workspace-dropdown .nav-form button { min-height: 44px; }
3408
+ .dashboard-filter-grid, .dashboard-saved-content { grid-template-columns: 1fr; }
3409
+ .dashboard-heading h1 { font-size: 22px; }
3410
+ .dashboard-heading nav { gap: 12px; font-size: 13px; }
3411
+ .dashboard-search-row { gap: 8px; }
3412
+ .dashboard-search-row button { padding: 8px 12px; }
3413
+ .dashboard-advanced > summary, .dashboard-saved-views > summary { min-height: 44px; }
3414
+ .dashboard .artifact-row { padding: 14px; }
3415
+ .dashboard .row-main strong { font-size: 14px; }
3416
+ }
3189
3417
  </style>
3418
+ <link rel="stylesheet" href="/assets/ui-controls.css">
3190
3419
  </head>
3191
3420
  <body>
3192
3421
  ${body}
@@ -3196,7 +3425,7 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
3196
3425
  }
3197
3426
 
3198
3427
  function formatSelect(selected) {
3199
- return `<select name="format">
3428
+ return `<select name="format" class="form-select">
3200
3429
  ${ARTIFACT_FORMATS.map((format) => {
3201
3430
  const label = format[0].toUpperCase() + format.slice(1);
3202
3431
  return `<option value="${format}"${format === selected ? " selected" : ""}>${label}</option>`;
@@ -3205,13 +3434,13 @@ function formatSelect(selected) {
3205
3434
  }
3206
3435
 
3207
3436
  function artifactTypeSelect(selected) {
3208
- return `<select name="artifactType">
3437
+ return `<select name="artifactType" class="form-select">
3209
3438
  ${artifactTypeOptions(selected)}
3210
3439
  </select>`;
3211
3440
  }
3212
3441
 
3213
3442
  function visibilitySelect(selected, view) {
3214
- return `<select name="visibility">
3443
+ return `<select name="visibility" class="form-select">
3215
3444
  ${VISIBILITY_VALUES.map((value) => `<option value="${value}"${value === selected ? " selected" : ""}>${view.text(`artifact.visibility.${value}`)}</option>`).join("")}
3216
3445
  </select>`;
3217
3446
  }
@@ -3239,6 +3468,13 @@ function editorScript(locale) {
3239
3468
  return `<script>window.ARTIFACTY_I18N=${config};</script><script type="module" src="${EDITOR_CLIENT_PATH}"></script>`;
3240
3469
  }
3241
3470
 
3471
+ function renderDateTime(value, view) {
3472
+ const date = new Date(value);
3473
+ if (!Number.isFinite(date.getTime())) return escapeHtml(value);
3474
+ const label = new Intl.DateTimeFormat(view.locale, { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" }).format(date);
3475
+ return `<time datetime="${escapeAttribute(value)}" title="${escapeAttribute(value)}">${escapeHtml(label)} UTC</time>`;
3476
+ }
3477
+
3242
3478
  function viewContext(locale, currentPath) {
3243
3479
  const i18n = createI18n(locale);
3244
3480
  return {
@@ -3300,13 +3536,11 @@ function statusBadge(label) {
3300
3536
  return `<span class="badge s-archived">${label}</span>`;
3301
3537
  }
3302
3538
 
3303
- // Only rendered for 'private' artifacts (the non-default visibility) so the
3304
- // common case doesn't add visual noise to every row.
3539
+ // Highlight explicit public/private visibility while keeping default team rows compact.
3305
3540
  function visibilityBadge(artifact, view) {
3306
- if ((artifact.visibility || "team") !== "private") {
3307
- return "";
3308
- }
3309
- return `<span class="badge v-private">${view.text("artifact.visibility.private")}</span>`;
3541
+ const visibility = artifact.visibility || "team";
3542
+ if (!["private", "public"].includes(visibility)) return "";
3543
+ return `<span class="badge v-${visibility}">${view.text(`artifact.visibility.${visibility}`)}</span>`;
3310
3544
  }
3311
3545
 
3312
3546
  function htmlFrameContent(content) {
@@ -3400,31 +3634,56 @@ function frameResizeScript() {
3400
3634
  return `<script>(function(){window.addEventListener("message",function(event){var data=event.data;if(!data||typeof data.__artifactyHeight!=="number"){return;}var frames=document.querySelectorAll(".artifact-frame");for(var i=0;i<frames.length;i+=1){if(frames[i].contentWindow===event.source){var height=Math.min(Math.max(Math.ceil(data.__artifactyHeight),200),200000);frames[i].style.height=height+"px";break;}}});})();</script>`;
3401
3635
  }
3402
3636
 
3403
- function viewerScript() {
3404
- return `<script type="module" src="${VIEWER_CLIENT_PATH}"></script>`;
3637
+ function viewerScript(locale = DEFAULT_LOCALE) {
3638
+ const view = viewContext(locale, "/");
3639
+ const messages = Object.fromEntries(["filter", "filterColumn", "ruleFilter", "rowsShown", "resultsShown"].map((key) => [key, view.raw(`viewer.${key}`, { column: "{column}", visible: "{visible}", total: "{total}" })]));
3640
+ const config = JSON.stringify(messages).replaceAll("<", "\\u003c");
3641
+ return `<script type="application/json" id="artifacty-viewer-i18n">${config}</script><script type="module" src="${VIEWER_CLIENT_PATH}"></script>`;
3405
3642
  }
3406
3643
 
3407
3644
  function reactRendererEnabled() {
3408
3645
  return process.env.ARTIFACTY_ENABLE_REACT_RENDERER === "true";
3409
3646
  }
3410
3647
 
3648
+ function themeControls(view) {
3649
+ return `<fieldset class="theme-controls" data-theme-controls hidden><legend>${view.text("theme.label")}</legend>
3650
+ <div class="theme-choices">${["system", "light", "dark"].map((theme) => `<button type="button" data-theme-choice="${theme}" aria-pressed="${theme === "system"}" aria-label="${view.attr(`theme.${theme}`)}" title="${view.attr(`theme.${theme}`)}" class="btn btn-ghost">${uiIcon({ system: "device-desktop", light: "sun", dark: "moon" }[theme])}<span class="dashboard-sr-only">${view.text(`theme.${theme}`)}</span></button>`).join("")}</div>
3651
+ </fieldset>`;
3652
+ }
3653
+
3411
3654
  function languageSwitcher(view) {
3412
- const english = view.locale === "en"
3413
- ? `<span class="active">${view.text("language.english")}</span>`
3414
- : `<a href="${switchLocaleHref(view.currentPath, "en")}">${view.text("language.english")}</a>`;
3415
- const korean = view.locale === "ko"
3416
- ? `<span class="active">${view.text("language.korean")}</span>`
3417
- : `<a href="${switchLocaleHref(view.currentPath, "ko")}">${view.text("language.korean")}</a>`;
3418
- return `<span class="language-switcher">${english}${korean}</span>`;
3655
+ const current = new URL(view.currentPath || "/", "http://artifacty.local");
3656
+ const preserved = [...current.searchParams.entries()].filter(([name]) => name !== "lang")
3657
+ .map(([name, value]) => `<input type="hidden" name="${escapeAttribute(name)}" value="${escapeAttribute(value)}">`).join("");
3658
+ const options = availableLanguages().filter((language) => language.enabled).map((language) =>
3659
+ `<option value="${escapeAttribute(language.locale)}"${language.locale === view.locale ? " selected" : ""}>${escapeHtml(language.nativeName)}</option>`
3660
+ ).join("");
3661
+ return `<form class="language-switcher" data-auto-apply="all" method="get" action="${escapeAttribute(`${current.pathname}${current.hash}`)}">
3662
+ ${preserved}<label><span>${view.text("language.label")}</span><select name="lang" class="form-select">${options}</select></label>
3663
+ <button type="submit" class="btn btn-outline-secondary">${view.text("language.apply")}</button>
3664
+ </form>`;
3419
3665
  }
3420
3666
 
3421
- function authNav(user, view) {
3422
- if (!user) {
3423
- return `<a href="/login">Sign in</a>`;
3424
- }
3425
- return `${user.role === "admin" ? `<a href="/admin/users">Users</a><a href="/admin/backup">Backup</a><a href="${view.href("/admin/access")}">${view.text("access.title")}</a>` : ""}<a href="/account">Account</a><a href="${view.href("/inbox")}">${view.text("inbox.title")}</a>`;
3667
+ function workspaceHeader(user, view) {
3668
+ const pathname = new URL(view.currentPath, "http://artifacty.local").pathname;
3669
+ const icons = { "/": "files", "/inbox": "inbox", "/new": "plus", "/import": "upload", "/admin/access": "settings", "/admin/users": "users", "/admin/backup": "database-export", "/admin/retention": "archive", "/admin/webhooks": "webhook", "/admin/update": "refresh", "/admin/languages": "language", "/admin/email": "mail", "/account": "user-circle", "/account/password": "key", "/login": "login", "/docs/api": "code" };
3670
+ const link = (href, key, active = pathname === href, className = "") => `<a href="${view.href(href)}" title="${view.attr(key)}" aria-label="${view.attr(key)}"${active ? ' aria-current="page"' : ""}${className ? ` class="${className}"` : ""}>${uiIcon(icons[href] || "files")}<span class="workspace-nav-label">${view.text(key)}</span></a>`;
3671
+ const artifactActive = pathname === "/" || pathname.startsWith("/artifacts/");
3672
+ const adminLinks = user?.role === "admin" ? `<details class="workspace-dropdown"${pathname.startsWith("/admin/") ? " open" : ""}><summary${pathname.startsWith("/admin/") ? ' class="is-current"' : ""} title="${view.attr("nav.administration")}" aria-label="${view.attr("nav.administration")}">${uiIcon("settings")}<span class="workspace-nav-label">${view.text("nav.administration")}</span><span class="workspace-chevron">${uiIcon("chevron-down", { size: 14 })}</span></summary><div class="workspace-dropdown-content">
3673
+ ${link("/admin/access", "access.title")}${link("/admin/users", "nav.users")}${link("/admin/backup", "nav.backup")}${link("/admin/retention", "retention.navLabel")}${link("/admin/webhooks", "nav.webhooks")}${link("/admin/update", "update.title")}${link("/admin/languages", "languageAdmin.title")}${link("/admin/email", "smtp.title")}<span class="workspace-version">Artifacty v${escapeHtml(APP_VERSION)}</span>
3674
+ </div></details>` : "";
3675
+ const account = user ? `<details class="workspace-dropdown"${pathname.startsWith("/account") ? " open" : ""}><summary${pathname.startsWith("/account") ? ' class="is-current"' : ""} title="${view.attr("nav.account")}" aria-label="${view.attr("nav.account")}">${uiIcon("user-circle")}<span class="workspace-nav-label">${view.text("nav.account")}</span><span class="workspace-chevron">${uiIcon("chevron-down", { size: 14 })}</span></summary><div class="workspace-dropdown-content">
3676
+ <span class="workspace-identity">${escapeHtml(user.name || user.email || "")}</span>${link("/account", "nav.accountTokens")}${link("/account/password", "nav.password")}
3677
+ <form method="post" action="/logout" class="nav-form">${localeInput(view.locale)}<button type="submit" title="${view.attr("nav.signOut")}" aria-label="${view.attr("nav.signOut")}" class="btn btn-ghost">${uiIcon("logout")}<span class="workspace-nav-label">${view.text("nav.signOut")}</span></button></form>
3678
+ </div></details>` : user === null ? link("/login", "nav.signIn") : "";
3679
+ const links = `<div class="workspace-primary">${link("/", "nav.artifacts", artifactActive)}${user ? link("/inbox", "inbox.title") : ""}</div><div class="workspace-actions">${link("/new", "nav.new", pathname === "/new", "workspace-create")}${link("/import", "nav.import")}${adminLinks}${account}${link("/docs/api", "nav.apiDocs")}${languageSwitcher(view)}${themeControls(view)}</div>`;
3680
+ return `<a class="workspace-skip" href="#main-content">${view.text("nav.skip")}</a><header class="workspace-header"><div class="workspace-brand-row"><a class="workspace-brand" aria-label="Artifacty" href="${view.href("/")}">${brandMark()}<span class="workspace-brand-name">artifacty</span></a><button type="button" class="sidebar-toggle btn btn-ghost" data-sidebar-toggle hidden aria-expanded="true" aria-label="${view.attr("nav.collapseSidebar")}" title="${view.attr("nav.collapseSidebar")}" data-collapse-label="${view.attr("nav.collapseSidebar")}" data-expand-label="${view.attr("nav.expandSidebar")}"><span data-sidebar-collapse-icon>${uiIcon("layout-sidebar-left-collapse")}</span><span data-sidebar-expand-icon hidden>${uiIcon("layout-sidebar-left-expand")}</span></button></div>
3681
+ <nav class="workspace-desktop" aria-label="${view.attr("dashboard.navigation")}">${links}</nav>
3682
+ <details class="workspace-mobile"><summary>${view.text("dashboard.more")}</summary><nav aria-label="${view.attr("dashboard.navigation")}">${links}</nav></details>
3683
+ </header>`;
3426
3684
  }
3427
3685
 
3686
+
3428
3687
  export function escapeHtml(value) {
3429
3688
  return String(value)
3430
3689
  .replaceAll("&", "&amp;")
@@ -3528,7 +3787,7 @@ function markdownToHtml(markdown, diagramState = createMermaidDiagramState()) {
3528
3787
  inList = true;
3529
3788
  }
3530
3789
  const checked = taskItem[1].toLowerCase() === "x";
3531
- html.push(`<li class="task-list-item"><label><input type="checkbox" disabled${checked ? " checked" : ""}> ${inlineMarkdown(taskItem[2])}</label></li>`);
3790
+ html.push(`<li class="task-list-item"><label><input type="checkbox" disabled${checked ? " checked" : ""} class="form-check-input"> ${inlineMarkdown(taskItem[2])}</label></li>`);
3532
3791
  continue;
3533
3792
  }
3534
3793
 
@@ -3663,19 +3922,20 @@ const CSV_RENDER_COLUMN_LIMIT = 80;
3663
3922
  const SARIF_RENDER_RESULT_LIMIT = 500;
3664
3923
 
3665
3924
  function renderMediaArtifact(format, content, metadata = {}, options = {}) {
3925
+ const view = options.view || viewContext(options.locale || DEFAULT_LOCALE, "/");
3666
3926
  const mimeType = mediaMimeType(metadata, format);
3667
3927
  const source = options.rawUrl || mediaDataUrl(content, mimeType);
3668
3928
  if (!source) {
3669
3929
  return `<section class="artifact-media-note">
3670
- Media preview is unavailable because this artifact does not contain valid base64 media content.
3930
+ ${view.text("media.unavailable")}
3671
3931
  </section>
3672
3932
  <pre class="artifact-code"><code>${escapeHtml(content)}</code></pre>`;
3673
3933
  }
3674
3934
 
3675
3935
  const label = [
3676
3936
  mimeType || format,
3677
- metadata.encoding ? `encoding: ${metadata.encoding}` : "",
3678
- metadata.originalEncoding ? `source: ${metadata.originalEncoding}` : ""
3937
+ metadata.encoding ? view.raw("media.encoding", { encoding: metadata.encoding }) : "",
3938
+ metadata.originalEncoding ? view.raw("media.source", { encoding: metadata.originalEncoding }) : ""
3679
3939
  ].filter(Boolean).join(" · ");
3680
3940
 
3681
3941
  if (format === "video") {
@@ -3686,7 +3946,7 @@ function renderMediaArtifact(format, content, metadata = {}, options = {}) {
3686
3946
  }
3687
3947
 
3688
3948
  return `<figure class="artifact-media artifact-image">
3689
- <img src="${escapeAttribute(source)}" alt="Image artifact">
3949
+ <img src="${escapeAttribute(source)}" alt="${view.text("media.image")}">
3690
3950
  <figcaption>${escapeHtml(label || "image artifact")}</figcaption>
3691
3951
  </figure>`;
3692
3952
  }
@@ -3711,17 +3971,17 @@ function mediaDataUrl(content, mimeType) {
3711
3971
  return `data:${mimeType};base64,${base64}`;
3712
3972
  }
3713
3973
 
3714
- function renderCsv(content) {
3974
+ function renderCsv(content, view) {
3715
3975
  const parsed = parseCsv(content);
3716
3976
  if (!parsed.ok) {
3717
3977
  return `<section class="artifact-csv">
3718
- <p class="artifact-csv-note">CSV parsing failed: ${escapeHtml(parsed.error)}. Showing escaped source.</p>
3978
+ <p class="artifact-csv-note">${view.text("csv.error", { error: parsed.error })}</p>
3719
3979
  <pre class="artifact-code"><code>${escapeHtml(content)}</code></pre>
3720
3980
  </section>`;
3721
3981
  }
3722
3982
 
3723
3983
  if (parsed.rows.length === 0) {
3724
- return `<section class="artifact-csv"><p class="artifact-csv-note">Empty CSV artifact.</p></section>`;
3984
+ return `<section class="artifact-csv"><p class="artifact-csv-note">${view.text("csv.empty")}</p></section>`;
3725
3985
  }
3726
3986
 
3727
3987
  const columnCount = Math.max(...parsed.rows.map((row) => row.length));
@@ -3731,7 +3991,7 @@ function renderCsv(content) {
3731
3991
  const rowTruncated = parsed.rows.length - 1 > CSV_RENDER_ROW_LIMIT;
3732
3992
  const columnTruncated = columnCount > CSV_RENDER_COLUMN_LIMIT;
3733
3993
  const headerHtml = Array.from({ length: visibleColumns }, (_, index) =>
3734
- `<th data-csv-col="${index}">${escapeHtml(header[index] || `Column ${index + 1}`)}</th>`
3994
+ `<th data-csv-col="${index}">${escapeHtml(header[index] || view.raw("csv.column", { column: index + 1 }))}</th>`
3735
3995
  ).join("");
3736
3996
  const bodyHtml = bodyRows.map((row) =>
3737
3997
  `<tr data-csv-row>${Array.from({ length: visibleColumns }, (_, index) =>
@@ -3739,10 +3999,10 @@ function renderCsv(content) {
3739
3999
  ).join("")}</tr>`
3740
4000
  ).join("\n");
3741
4001
  const notes = [
3742
- `${parsed.rows.length - 1} data rows`,
3743
- `${columnCount} columns`,
3744
- rowTruncated ? `showing first ${CSV_RENDER_ROW_LIMIT} rows` : "",
3745
- columnTruncated ? `showing first ${CSV_RENDER_COLUMN_LIMIT} columns` : ""
4002
+ view.raw("csv.rows", { count: parsed.rows.length - 1 }),
4003
+ view.raw("csv.columns", { count: columnCount }),
4004
+ rowTruncated ? view.raw("csv.firstRows", { count: CSV_RENDER_ROW_LIMIT }) : "",
4005
+ columnTruncated ? view.raw("csv.firstColumns", { count: CSV_RENDER_COLUMN_LIMIT }) : ""
3746
4006
  ].filter(Boolean).join(" · ");
3747
4007
 
3748
4008
  return `<section class="artifact-csv" data-artifact-csv>
@@ -3756,20 +4016,20 @@ function renderCsv(content) {
3756
4016
  </section>`;
3757
4017
  }
3758
4018
 
3759
- function renderSarif(content) {
4019
+ function renderSarif(content, view) {
3760
4020
  let sarif;
3761
4021
  try {
3762
4022
  sarif = JSON.parse(content);
3763
4023
  } catch {
3764
4024
  return `<section class="artifact-sarif">
3765
- <p class="artifact-sarif-note">Invalid SARIF JSON. Showing escaped source.</p>
4025
+ <p class="artifact-sarif-note">${view.text("sarif.invalid")}</p>
3766
4026
  <pre class="artifact-code"><code>${escapeHtml(content)}</code></pre>
3767
4027
  </section>`;
3768
4028
  }
3769
4029
 
3770
4030
  if (!isSarifDocument(sarif)) {
3771
4031
  return `<section class="artifact-sarif">
3772
- <p class="artifact-sarif-note">This JSON does not match the expected SARIF top-level shape. Showing formatted JSON.</p>
4032
+ <p class="artifact-sarif-note">${view.text("sarif.shape")}</p>
3773
4033
  <pre class="artifact-code"><code>${escapeHtml(formatJson(content))}</code></pre>
3774
4034
  </section>`;
3775
4035
  }
@@ -3785,26 +4045,26 @@ function renderSarif(content) {
3785
4045
  <td>${escapeHtml(result.tool)}</td>
3786
4046
  </tr>`).join("\n");
3787
4047
  const note = results.length > SARIF_RENDER_RESULT_LIMIT
3788
- ? `Showing first ${SARIF_RENDER_RESULT_LIMIT} of ${results.length} results.`
3789
- : `${results.length} results.`;
4048
+ ? view.raw("sarif.first", { count: SARIF_RENDER_RESULT_LIMIT, total: results.length })
4049
+ : view.raw("sarif.results", { count: results.length });
3790
4050
 
3791
4051
  return `<section class="artifact-sarif" data-artifact-sarif>
3792
4052
  <div class="artifact-summary-grid">
3793
- ${summaryCard("Runs", sarif.runs.length)}
3794
- ${summaryCard("Results", results.length)}
3795
- ${summaryCard("Errors", counts.error || 0)}
3796
- ${summaryCard("Warnings", counts.warning || 0)}
3797
- ${summaryCard("Notes", counts.note || 0)}
4053
+ ${summaryCard(view.raw("sarif.runs"), sarif.runs.length)}
4054
+ ${summaryCard(view.raw("sarif.resultsTitle"), results.length)}
4055
+ ${summaryCard(view.raw("sarif.errors"), counts.error || 0)}
4056
+ ${summaryCard(view.raw("sarif.warnings"), counts.warning || 0)}
4057
+ ${summaryCard(view.raw("sarif.notes"), counts.note || 0)}
3798
4058
  </div>
3799
4059
  <p class="artifact-sarif-note">${escapeHtml(note)}</p>
3800
4060
  <div class="artifact-table-scroll">
3801
4061
  <table class="artifact-table" data-sarif-table>
3802
- <thead><tr><th data-sarif-sort="level">Level</th><th data-sarif-sort="rule">Rule</th><th>Message</th><th data-sarif-sort="location">Location</th><th>Tool</th></tr></thead>
4062
+ <thead><tr><th data-sarif-sort="level">${view.text("sarif.level")}</th><th data-sarif-sort="rule">${view.text("sarif.rule")}</th><th>${view.text("sarif.message")}</th><th data-sarif-sort="location">${view.text("sarif.location")}</th><th>${view.text("sarif.tool")}</th></tr></thead>
3803
4063
  <tbody>${rowHtml}</tbody>
3804
4064
  </table>
3805
4065
  </div>
3806
4066
  <details class="artifact-raw-details">
3807
- <summary>Raw SARIF JSON</summary>
4067
+ <summary>${view.text("sarif.raw")}</summary>
3808
4068
  <pre class="artifact-code"><code>${escapeHtml(formatJson(content))}</code></pre>
3809
4069
  </details>
3810
4070
  </section>`;
@@ -3885,7 +4145,7 @@ const NOTEBOOK_OUTPUT_MIME_PRIORITY = [
3885
4145
  "text/plain"
3886
4146
  ];
3887
4147
 
3888
- function renderNotebook(content) {
4148
+ function renderNotebook(content, view) {
3889
4149
  let notebook;
3890
4150
  try {
3891
4151
  notebook = JSON.parse(content);
@@ -3900,16 +4160,16 @@ function renderNotebook(content) {
3900
4160
  const diagramState = createMermaidDiagramState();
3901
4161
  const cells = notebook.cells;
3902
4162
  const rendered = cells.slice(0, NOTEBOOK_MAX_RENDERED_CELLS)
3903
- .map((cell) => renderNotebookCell(cell, language, diagramState))
4163
+ .map((cell) => renderNotebookCell(cell, language, diagramState, view))
3904
4164
  .join("");
3905
4165
  const notice = cells.length > NOTEBOOK_MAX_RENDERED_CELLS
3906
- ? `<p class="artifact-notebook-notice">Showing the first ${NOTEBOOK_MAX_RENDERED_CELLS} of ${cells.length} cells.</p>`
4166
+ ? `<p class="artifact-notebook-notice">${view.text("notebook.cells", { count: NOTEBOOK_MAX_RENDERED_CELLS, total: cells.length })}</p>`
3907
4167
  : "";
3908
4168
 
3909
4169
  return `<article class="artifact-doc artifact-notebook">${notice}${rendered}</article>`;
3910
4170
  }
3911
4171
 
3912
- function renderNotebookCell(cell, language, diagramState) {
4172
+ function renderNotebookCell(cell, language, diagramState, view) {
3913
4173
  const cellType = cell?.cell_type;
3914
4174
  const source = notebookText(cell?.source);
3915
4175
 
@@ -3928,9 +4188,9 @@ function renderNotebookCell(cell, language, diagramState) {
3928
4188
  const cellOutputs = Array.isArray(cell?.outputs) ? cell.outputs : [];
3929
4189
  const visibleOutputs = cellOutputs.slice(0, NOTEBOOK_MAX_OUTPUTS_PER_CELL);
3930
4190
  const outputsNotice = cellOutputs.length > NOTEBOOK_MAX_OUTPUTS_PER_CELL
3931
- ? `<p class="artifact-notebook-notice">Showing the first ${NOTEBOOK_MAX_OUTPUTS_PER_CELL} of ${cellOutputs.length} outputs.</p>`
4191
+ ? `<p class="artifact-notebook-notice">${view.text("notebook.outputs", { count: NOTEBOOK_MAX_OUTPUTS_PER_CELL, total: cellOutputs.length })}</p>`
3932
4192
  : "";
3933
- const outputs = visibleOutputs.map((output) => renderNotebookOutput(output, diagramState)).join("") + outputsNotice;
4193
+ const outputs = visibleOutputs.map((output) => renderNotebookOutput(output, diagramState, view)).join("") + outputsNotice;
3934
4194
  return `<section class="artifact-notebook-cell artifact-notebook-cell-code">
3935
4195
  <div class="artifact-notebook-execcount">${escapeHtml(executionLabel)}</div>
3936
4196
  <pre><code${classAttribute}>${escapeHtml(source)}</code></pre>
@@ -3949,11 +4209,11 @@ function stripAnsiEscapes(value) {
3949
4209
  return String(value ?? "").replace(ANSI_ESCAPE_PATTERN, "");
3950
4210
  }
3951
4211
 
3952
- function renderNotebookOutput(output, diagramState) {
4212
+ function renderNotebookOutput(output, diagramState, view) {
3953
4213
  const outputType = output?.output_type;
3954
4214
 
3955
4215
  if (outputType === "stream") {
3956
- return renderNotebookTextOutput(notebookText(output.text));
4216
+ return renderNotebookTextOutput(notebookText(output.text), "", view);
3957
4217
  }
3958
4218
 
3959
4219
  if (outputType === "error") {
@@ -3967,18 +4227,18 @@ function renderNotebookOutput(output, diagramState) {
3967
4227
  : notebookText(output.traceback);
3968
4228
  const traceback = stripAnsiEscapes(rawTraceback) ||
3969
4229
  `${notebookText(output.ename)}: ${notebookText(output.evalue)}`;
3970
- return renderNotebookTextOutput(traceback, "artifact-notebook-error");
4230
+ return renderNotebookTextOutput(traceback, "artifact-notebook-error", view);
3971
4231
  }
3972
4232
 
3973
4233
  if (outputType === "execute_result" || outputType === "display_data") {
3974
- return renderNotebookMimeBundle(output.data, diagramState);
4234
+ return renderNotebookMimeBundle(output.data, diagramState, view);
3975
4235
  }
3976
4236
 
3977
4237
  return "";
3978
4238
  }
3979
4239
 
3980
- function renderNotebookTextOutput(text, extraClass = "") {
3981
- const truncated = notebookTruncateIfOversized(text);
4240
+ function renderNotebookTextOutput(text, extraClass = "", view) {
4241
+ const truncated = notebookTruncateIfOversized(text, view);
3982
4242
  if (truncated) {
3983
4243
  return truncated;
3984
4244
  }
@@ -3986,7 +4246,7 @@ function renderNotebookTextOutput(text, extraClass = "") {
3986
4246
  return `<pre class="artifact-notebook-output artifact-code${classAttribute}"><code>${escapeHtml(text)}</code></pre>`;
3987
4247
  }
3988
4248
 
3989
- function renderNotebookMimeBundle(data, diagramState) {
4249
+ function renderNotebookMimeBundle(data, diagramState, view) {
3990
4250
  const bundle = data && typeof data === "object" && !Array.isArray(data) ? data : {};
3991
4251
  const keys = Object.keys(bundle);
3992
4252
  if (keys.length === 0) {
@@ -3995,11 +4255,11 @@ function renderNotebookMimeBundle(data, diagramState) {
3995
4255
 
3996
4256
  const mime = NOTEBOOK_OUTPUT_MIME_PRIORITY.find((candidate) => keys.includes(candidate));
3997
4257
  if (!mime) {
3998
- return `<p class="artifact-notebook-output-placeholder">Unsupported output type: ${escapeHtml(keys[0])}</p>`;
4258
+ return `<p class="artifact-notebook-output-placeholder">${view.text("notebook.unsupported", { type: keys[0] })}</p>`;
3999
4259
  }
4000
4260
 
4001
4261
  const value = notebookText(bundle[mime]);
4002
- const truncated = notebookTruncateIfOversized(value);
4262
+ const truncated = notebookTruncateIfOversized(value, view);
4003
4263
  if (truncated) {
4004
4264
  return truncated;
4005
4265
  }
@@ -4013,9 +4273,9 @@ function renderNotebookMimeBundle(data, diagramState) {
4013
4273
  if (mime === "image/png" || mime === "image/jpeg") {
4014
4274
  const dataUrl = mediaDataUrl(value, mime);
4015
4275
  if (!dataUrl) {
4016
- return `<p class="artifact-notebook-output-placeholder">Unsupported output type: ${escapeHtml(mime)}</p>`;
4276
+ return `<p class="artifact-notebook-output-placeholder">${view.text("notebook.unsupported", { type: mime })}</p>`;
4017
4277
  }
4018
- return `<figure class="artifact-media artifact-image artifact-notebook-output"><img src="${escapeAttribute(dataUrl)}" alt="Notebook output image"></figure>`;
4278
+ return `<figure class="artifact-media artifact-image artifact-notebook-output"><img src="${escapeAttribute(dataUrl)}" alt="${view.text("notebook.image")}"></figure>`;
4019
4279
  }
4020
4280
  if (mime === "image/svg+xml") {
4021
4281
  return `<iframe class="artifact-frame artifact-svg-frame artifact-notebook-output" sandbox srcdoc="${escapeAttribute(svgFrameContent(value))}"></iframe>`;
@@ -4026,12 +4286,12 @@ function renderNotebookMimeBundle(data, diagramState) {
4026
4286
  return "";
4027
4287
  }
4028
4288
 
4029
- function notebookTruncateIfOversized(value) {
4289
+ function notebookTruncateIfOversized(value, view) {
4030
4290
  const size = Buffer.byteLength(String(value ?? ""), "utf8");
4031
4291
  if (size <= NOTEBOOK_MAX_OUTPUT_BYTES) {
4032
4292
  return null;
4033
4293
  }
4034
- return `<p class="artifact-notebook-output-truncated">Output truncated: ${escapeHtml(String(size))} bytes exceeds the 2 MB limit.</p>`;
4294
+ return `<p class="artifact-notebook-output-truncated">${view.text("notebook.truncated", { size })}</p>`;
4035
4295
  }
4036
4296
 
4037
4297
  function notebookText(value) {