artifacty 0.15.0 → 0.16.1

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,6 +1,7 @@
1
+ import { uiIcon } from "./ui-icons.js";
1
2
  import { readFileSync } from "node:fs";
2
3
  import { EDITOR_CLIENT_PATH, VIEWER_CLIENT_PATH, editorImportMapJson } from "./editor-assets.js";
3
- import { createI18n, DEFAULT_LOCALE, editorMessages, localizedHref, switchLocaleHref } from "./i18n.js";
4
+ import { createI18n, DEFAULT_LOCALE, editorMessages, localizedHref, availableLanguages } from "./i18n.js";
4
5
  import { ARTIFACT_FORMATS, ARTIFACT_TYPES, REVIEW_STATUSES, SAVED_VIEW_FILTER_KEYS, VISIBILITY_VALUES } from "./storage.js";
5
6
  import { normalizeMaxEntries as resolveMaxDiffEntries } from "./diff.js";
6
7
  import { parseCsv } from "./csv.js";
@@ -20,7 +21,7 @@ export function renderDashboard({ artifacts, baseUrl, filters = {}, pagination,
20
21
  const total = pagination?.total ?? artifacts.length;
21
22
  const start = artifacts.length ? (pagination?.offset ?? 0) + 1 : 0;
22
23
  const end = artifacts.length ? (pagination?.offset ?? 0) + artifacts.length : 0;
23
- const pager = renderDashboardPager({ pagination, filters, view });
24
+ const pager = renderDashboardPager({ pagination: pagination || { total, limit: Number(filters.limit) || 50, offset: 0 }, filters, view });
24
25
  const rowsHtml = renderArtifactRows(artifacts, filters.groupBy, view);
25
26
  const activeFilters = [
26
27
  ["tag", "filter.tag"], ["sourceAgent", "filter.source"], ["artifactType", "filter.artifactType"],
@@ -28,7 +29,7 @@ export function renderDashboard({ artifacts, baseUrl, filters = {}, pagination,
28
29
  ["reviewStatus", "filter.reviewStatus"], ["mode", "filter.mode"], ["groupBy", "filter.groupBy"],
29
30
  ["relatedTo", "filter.relatedTo"], ["relation", "filter.relation"], ["includeArchived", "filter.archived"]
30
31
  ].filter(([key]) => filters[key]);
31
- const field = (key, label, type = "text") => `<label class="dashboard-field"><span>${view.text(label)}</span><input type="${type}" name="${key}" value="${escapeAttribute(filters[key] || "")}"></label>`;
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>`;
32
33
  const selectField = (label, select) => `<label class="dashboard-field"><span>${view.text(label)}</span>${select}</label>`;
33
34
 
34
35
  return pageShell({
@@ -37,14 +38,14 @@ export function renderDashboard({ artifacts, baseUrl, filters = {}, pagination,
37
38
  ${workspaceHeader(user, view)}
38
39
  <div class="dashboard-layout">
39
40
  <main class="dashboard" id="main-content">
40
- <div class="dashboard-heading"><h1>${view.text("nav.artifacts")}</h1><nav><a class="button-link" href="${view.href("/new")}">${view.text("nav.new")}</a><a href="${view.href("/import")}">${view.text("nav.import")}</a></nav></div>
41
- <form class="filter-form" method="get" action="/">
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="/">
42
43
  ${localeInput(view.locale)}
43
44
  ${filters.limit ? `<input type="hidden" name="limit" value="${escapeAttribute(filters.limit)}">` : ""}
44
45
  ${!embeddingsAvailable && filters.mode ? `<input type="hidden" name="mode" value="${escapeAttribute(filters.mode)}">` : ""}
45
46
  <div class="dashboard-search-row">
46
- <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")}"></label>
47
- <button type="submit">${view.text("filter.search")}</button>
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>
48
49
  <a class="dashboard-clear" href="${view.href("/")}">${view.text("filter.clear")}</a>
49
50
  </div>
50
51
  <details class="dashboard-advanced">
@@ -58,8 +59,8 @@ export function renderDashboard({ artifacts, baseUrl, filters = {}, pagination,
58
59
  ${embeddingsAvailable ? selectField("filter.mode", filterModeSelect(filters.mode, view)) : ""}
59
60
  ${selectField("filter.groupBy", filterGroupBySelect(filters.groupBy, view))}
60
61
  ${field("relatedTo", "filter.relatedTo")}${field("relation", "filter.relation")}
61
- <label class="checkbox-field"><input type="checkbox" name="includeArchived" value="true"${filters.includeArchived ? " checked" : ""}> ${view.text("filter.archived")}</label>
62
- <div class="dashboard-filter-actions"><button type="submit">${view.text("filter.submit")}</button></div>
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>
63
64
  </div>
64
65
  </details>
65
66
  ${activeFilters.length ? `<p class="dashboard-active-filters">${activeFilters.map(([key, label]) => `<span>${view.text(label)}${key === "includeArchived" ? "" : `: ${escapeHtml(filters[key])}`}</span>`).join("")}</p>` : ""}
@@ -77,7 +78,7 @@ export function renderDashboard({ artifacts, baseUrl, filters = {}, pagination,
77
78
  });
78
79
  }
79
80
 
80
- function renderArtifactRow(artifact, view) {
81
+ function renderArtifactRow(artifact, view, readOnly = false) {
81
82
  const tags = artifact.tags.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`).join("");
82
83
  const status = artifact.archivedAt ? statusBadge("archived") : "";
83
84
  const visibility = visibilityBadge(artifact, view);
@@ -86,7 +87,7 @@ function renderArtifactRow(artifact, view) {
86
87
  ? `<span class="row-snippet">${escapeHtml(artifact.searchSnippet)}</span>`
87
88
  : "";
88
89
  return `
89
- <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)}`)}"`}>
90
91
  <span class="row-main">
91
92
  <strong>${escapeHtml(artifact.title)}</strong>
92
93
  <span class="dashboard-sr-only">${escapeHtml(artifact.id)}</span>
@@ -95,15 +96,15 @@ function renderArtifactRow(artifact, view) {
95
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>
96
97
  <span class="row-formats">${typeBadge(artifact.artifactType || "document")}${formatBadge(artifact.format || "text")}<span>v${artifact.latestVersion}</span></span>
97
98
  <span class="tags">${status}${visibility}${tags}</span>
98
- </a>
99
+ </${readOnly ? "div" : "a"}>
99
100
  `;
100
101
  }
101
102
 
102
103
  // groupBy is purely presentational: it groups the already-fetched, already-
103
104
  // sorted page of artifacts into labeled sections without changing the query.
104
- function renderArtifactRows(artifacts, groupBy, view) {
105
+ function renderArtifactRows(artifacts, groupBy, view, readOnly = false) {
105
106
  if (!GROUP_BY_VALUES.includes(groupBy)) {
106
- return artifacts.map((artifact) => renderArtifactRow(artifact, view)).join("");
107
+ return artifacts.map((artifact) => renderArtifactRow(artifact, view, readOnly)).join("");
107
108
  }
108
109
 
109
110
  const groups = new Map();
@@ -119,7 +120,7 @@ function renderArtifactRows(artifacts, groupBy, view) {
119
120
  .map(([key, group]) => `
120
121
  <section class="artifact-group">
121
122
  <h2 class="artifact-group-heading">${escapeHtml(groupHeadingLabel(key, groupBy, view))}</h2>
122
- ${group.map((artifact) => renderArtifactRow(artifact, view)).join("")}
123
+ ${group.map((artifact) => renderArtifactRow(artifact, view, readOnly)).join("")}
123
124
  </section>
124
125
  `)
125
126
  .join("");
@@ -132,21 +133,21 @@ function groupHeadingLabel(key, groupBy, view) {
132
133
  return key || view.raw("dashboard.groupUnknown");
133
134
  }
134
135
 
135
- // 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
136
137
  // as their own value and (escaped) label.
137
138
  function artifactTypeOptions(selected) {
138
139
  return ARTIFACT_TYPES.map((type) => `<option value="${type}"${type === selected ? " selected" : ""}>${escapeHtml(type)}</option>`).join("");
139
140
  }
140
141
 
141
142
  function filterArtifactTypeSelect(selected, view) {
142
- return `<select name="artifactType">
143
+ return `<select name="artifactType" class="form-select">
143
144
  <option value="">${view.text("filter.artifactTypeAny")}</option>
144
145
  ${artifactTypeOptions(selected)}
145
146
  </select>`;
146
147
  }
147
148
 
148
149
  function filterReviewStatusSelect(selected, view) {
149
- return `<select name="reviewStatus">
150
+ return `<select name="reviewStatus" class="form-select">
150
151
  <option value="">${view.text("filter.reviewStatusAny")}</option>
151
152
  ${REVIEW_STATUSES.map((status) => `<option value="${status}"${status === selected ? " selected" : ""}>${view.text(`reviewStatus.${status}`)}</option>`).join("")}
152
153
  </select>`;
@@ -158,14 +159,14 @@ const SEARCH_MODE_VALUES = ["keyword", "semantic", "hybrid"];
158
159
  // configured (roadmap section 6): semantic/hybrid search silently falls
159
160
  // back to keyword without one, so the toggle would otherwise mislead.
160
161
  function filterModeSelect(selected, view) {
161
- return `<select name="mode">
162
+ return `<select name="mode" class="form-select">
162
163
  <option value="">${view.text("filter.modeDefault")}</option>
163
164
  ${SEARCH_MODE_VALUES.map((value) => `<option value="${value}"${value === selected ? " selected" : ""}>${view.text(`filter.mode.${value}`)}</option>`).join("")}
164
165
  </select>`;
165
166
  }
166
167
 
167
168
  function filterGroupBySelect(selected, view) {
168
- return `<select name="groupBy">
169
+ return `<select name="groupBy" class="form-select">
169
170
  <option value="">${view.text("filter.groupByNone")}</option>
170
171
  ${GROUP_BY_VALUES.map((value) => `<option value="${value}"${value === selected ? " selected" : ""}>${view.text(`filter.groupBy.${value}`)}</option>`).join("")}
171
172
  </select>`;
@@ -179,7 +180,7 @@ function renderSavedViewsSidebar({ savedViews, filters, view }) {
179
180
  ${savedView.shared ? `<span class="badge saved-view-shared">${view.text("views.shared")}</span>` : ""}
180
181
  <form method="post" action="${view.href(`/views/${encodeURIComponent(savedView.id)}/delete`)}" class="saved-view-delete">
181
182
  ${localeInput(view.locale)}
182
- <button type="submit">${view.text("views.delete")}</button>
183
+ <button type="submit" class="btn btn-outline-secondary">${view.text("views.delete")}</button>
183
184
  </form>
184
185
  </li>
185
186
  `).join("")
@@ -193,9 +194,9 @@ function renderSavedViewsSidebar({ savedViews, filters, view }) {
193
194
  <form method="post" action="${view.href("/views")}" class="saved-view-form">
194
195
  ${localeInput(view.locale)}
195
196
  ${savedViewFilterHiddenInputs(filters)}
196
- <label class="dashboard-field"><span>${view.text("views.namePlaceholder")}</span><input name="name" placeholder="${view.attr("views.namePlaceholder")}" required></label>
197
- <label class="checkbox-field"><input type="checkbox" name="shared" value="true"> ${view.text("views.shared")}</label>
198
- <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>
199
200
  </form>
200
201
  </div>
201
202
  </details>
@@ -204,7 +205,7 @@ function renderSavedViewsSidebar({ savedViews, filters, view }) {
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) {
@@ -301,7 +326,7 @@ export function renderArtifactFormPage({ user, mode, baseUrl, artifact, version,
301
326
  <section class="editor-fields">
302
327
  <label class="field">
303
328
  <span>${view.text("form.title")}</span>
304
- <input name="title" value="${escapeAttribute(artifact?.title || "")}" autocomplete="off" required>
329
+ <input name="title" value="${escapeAttribute(artifact?.title || "")}" autocomplete="off" required class="form-control">
305
330
  </label>
306
331
  <label class="field">
307
332
  <span>${view.text("form.format")}</span>
@@ -309,7 +334,7 @@ export function renderArtifactFormPage({ user, mode, baseUrl, artifact, version,
309
334
  </label>
310
335
  <label class="field">
311
336
  <span>${view.text("form.source")}</span>
312
- <input name="sourceAgent" value="${escapeAttribute(sourceAgent)}" autocomplete="off">
337
+ <input name="sourceAgent" value="${escapeAttribute(sourceAgent)}" autocomplete="off" class="form-control">
313
338
  </label>
314
339
  <label class="field">
315
340
  <span>${view.text("form.type")}</span>
@@ -317,20 +342,21 @@ export function renderArtifactFormPage({ user, mode, baseUrl, artifact, version,
317
342
  </label>
318
343
  <label class="field">
319
344
  <span>${view.text("form.tags")}</span>
320
- <input name="tags" value="${escapeAttribute(tags)}" autocomplete="off">
345
+ <input name="tags" value="${escapeAttribute(tags)}" autocomplete="off" class="form-control">
321
346
  </label>
322
347
  ${isEdit ? "" : `<label class="field">
323
348
  <span>${view.text("artifact.visibility.label")}</span>
324
349
  ${visibilitySelect(artifact?.visibility || "team", view)}
325
350
  </label>`}
326
351
  </section>
352
+ <p class="muted">${view.text("artifact.visibility.help")}</p>
327
353
  <section class="field content-field">
328
354
  <label for="artifact-content">${view.text("form.content")}</label>
329
- <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>
330
356
  </section>
331
357
  <footer class="editor-actions">
332
358
  <a href="${view.href(isEdit ? `/artifacts/${encodeURIComponent(artifact.id)}` : "/")}">${view.text("nav.cancel")}</a>
333
- <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>
334
360
  </footer>
335
361
  </form>
336
362
  </main>
@@ -355,14 +381,14 @@ export function renderAdminArtifactVersionsPage({ artifact, selectedVersion, con
355
381
  <tr>
356
382
  <td><a class="version${active}" href="${view.href(`/admin/artifacts/${encodeURIComponent(artifact.id)}/versions?version=${version.version}`)}">v${version.version}</a></td>
357
383
  <td>${formatBadge(version.format)}</td>
358
- <td>${escapeHtml(version.createdAt)}</td>
384
+ <td>${renderDateTime(version.createdAt, view)}</td>
359
385
  <td><code>${escapeHtml(version.sha256.slice(0, 12))}</code></td>
360
386
  <td>${escapeHtml(String(version.sizeBytes))}</td>
361
387
  <td>
362
388
  <form class="inline-action" method="post" action="/admin/artifacts/${encodeURIComponent(artifact.id)}/versions/${version.version}/delete">
363
389
  ${localeInput(view.locale)}
364
- <input name="reason" placeholder="Reason">
365
- <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>
366
392
  </form>
367
393
  </td>
368
394
  </tr>
@@ -370,12 +396,12 @@ export function renderAdminArtifactVersionsPage({ artifact, selectedVersion, con
370
396
  }).join("");
371
397
 
372
398
  return pageShell({
373
- title: `Manage versions · ${artifact.title}`,
399
+ title: `${view.raw("versions.manage")} · ${artifact.title}`,
374
400
  body: `
375
401
  ${workspaceHeader(user, view)}
376
402
  <header class="topbar">
377
403
  <div>
378
- <h1>Manage versions</h1>
404
+ <h1>${view.text("versions.manage")}</h1>
379
405
  <p>${escapeHtml(artifact.title)} · ${escapeHtml(artifact.id)} · ${escapeHtml(baseUrl)}</p>
380
406
  </div>
381
407
  <nav>
@@ -388,9 +414,9 @@ export function renderAdminArtifactVersionsPage({ artifact, selectedVersion, con
388
414
  <main id="main-content" class="artifact-editor">
389
415
  ${error ? `<p class="auth-error">${escapeHtml(error)}</p>` : ""}
390
416
  <section class="meta-card">
391
- <h2>Versions</h2>
417
+ <h2>${view.text("nav.versions")}</h2>
392
418
  <table class="data-table">
393
- <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>
394
420
  <tbody>${versionRows}</tbody>
395
421
  </table>
396
422
  </section>
@@ -402,17 +428,17 @@ export function renderAdminArtifactVersionsPage({ artifact, selectedVersion, con
402
428
  ${formatSelect(selectedVersion.format)}
403
429
  </label>
404
430
  <label class="field">
405
- <span>Reason</span>
406
- <input name="reason" value="" autocomplete="off">
431
+ <span>${view.text("ui.reason")}</span>
432
+ <input name="reason" value="" autocomplete="off" class="form-control">
407
433
  </label>
408
434
  </section>
409
435
  <section class="field content-field">
410
- <label for="artifact-content">v${selectedVersion.version} content</label>
411
- <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>
412
438
  </section>
413
439
  <footer class="editor-actions">
414
- <a href="${view.href(`${artifactPath}?version=${selectedVersion.version}`)}">View version</a>
415
- <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>
416
442
  </footer>
417
443
  </form>
418
444
  </main>
@@ -444,36 +470,36 @@ export function renderImportArtifactPage({ user, baseUrl, authToken = "", 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,7 +512,7 @@ export function renderImportArtifactPage({ user, baseUrl, authToken = "", locale
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: `
@@ -500,24 +526,24 @@ export function renderLoginPage({ baseUrl, setup = false, error = "", locale = D
500
526
  </header>
501
527
  <main id="main-content" class="artifact-editor auth-panel">
502
528
  ${error ? `<p class="auth-error">${escapeHtml(error)}</p>` : ""}
503
- ${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>` : ""}
504
530
  <form class="editor-form auth-form" method="post" action="/login">
505
531
  <section class="editor-fields">
506
532
  <label class="field">
507
- <span>Email</span>
508
- <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">
509
535
  </label>
510
536
  ${setup ? `<label class="field">
511
- <span>Name</span>
512
- <input name="name" autocomplete="name">
537
+ <span>${view.text("ui.name")}</span>
538
+ <input name="name" autocomplete="name" class="form-control">
513
539
  </label>` : ""}
514
540
  <label class="field">
515
- <span>Password</span>
516
- <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">
517
543
  </label>
518
544
  </section>
519
545
  <footer class="editor-actions">
520
- <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>
521
547
  </footer>
522
548
  </form>
523
549
  </main>
@@ -532,7 +558,7 @@ export function renderAccountPage({ baseUrl, user, tokens = [], createdToken = "
532
558
  <tr>
533
559
  <td><a href="${view.href(`/artifacts/${encodeURIComponent(artifact.id)}`)}">${escapeHtml(artifact.title)}</a></td>
534
560
  <td>${escapeHtml(artifact.id)}</td>
535
- <td>${escapeHtml(artifact.updatedAt)}</td>
561
+ <td>${renderDateTime(artifact.updatedAt, view)}</td>
536
562
  <td>${artifact.archivedAt ? statusBadge(view.text("artifact.archived", { date: artifact.archivedAt })) : ""}</td>
537
563
  </tr>
538
564
  `).join("");
@@ -540,65 +566,65 @@ export function renderAccountPage({ baseUrl, user, tokens = [], createdToken = "
540
566
  <tr>
541
567
  <td>${escapeHtml(token.name)}</td>
542
568
  <td>${escapeHtml((token.scopes || ["read", "write"]).join(", "))}</td>
543
- <td>${escapeHtml(token.createdAt)}</td>
544
- <td>${token.lastUsedAt ? escapeHtml(token.lastUsedAt) : "Never"}</td>
545
- <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>
546
572
  <td>
547
573
  ${token.revokedAt ? "" : `<form method="post" action="/account/tokens/${encodeURIComponent(token.id)}/revoke">
548
- <button type="submit">Revoke</button>
574
+ <button type="submit" class="btn btn-outline-danger">${view.text("ui.revoke")}</button>
549
575
  </form>`}
550
576
  </td>
551
577
  </tr>
552
578
  `).join("");
553
579
 
554
580
  return pageShell({
555
- title: "Account",
581
+ title: view.raw("nav.account"),
556
582
  body: `
557
583
  ${workspaceHeader(user, view)}
558
584
  <header class="topbar">
559
585
  <div>
560
- <h1>Account</h1>
561
- <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>
562
588
  </div>
563
589
 
564
590
  </header>
565
591
  <main id="main-content" class="settings-page artifact-view">
566
592
  ${createdToken ? `<section class="token-once">
567
- <h2>New API token</h2>
568
- <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>
569
595
  <pre class="artifact-code"><code>${escapeHtml(createdToken)}</code></pre>
570
596
  </section>` : ""}
571
597
  <section class="meta-card">
572
- <h2>Profile</h2>
598
+ <h2>${view.text("account.profile")}</h2>
573
599
  <p><strong>${escapeHtml(user.name)}</strong></p>
574
600
  <p>${escapeHtml(user.email)}</p>
575
- <p>Role: ${escapeHtml(user.role)}</p>
601
+ <p>${view.text("ui.role")}: ${view.text(`ui.role.${user.role}`)}</p>
576
602
  </section>
577
603
  <section class="meta-card">
578
604
  <h2>${view.text("tokens.createTitle")}</h2>
579
605
  <form class="inline-action token-create-form" method="post" action="/account/tokens">
580
- <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">
581
607
  <fieldset class="token-scopes">
582
608
  <legend>${view.text("tokens.scopes")}</legend>
583
- <label><input type="checkbox" name="scopeRead" checked> ${view.text("tokens.scope.read")}</label>
584
- <label><input type="checkbox" name="scopeWrite" checked> ${view.text("tokens.scope.write")}</label>
585
- ${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>` : ""}
586
612
  </fieldset>
587
- <button type="submit">Create token</button>
613
+ <button type="submit" class="btn btn-primary">${view.text("account.createToken")}</button>
588
614
  </form>
589
615
  </section>
590
616
  <section class="meta-card">
591
- <h2>API tokens</h2>
617
+ <h2>${view.text("account.tokens")}</h2>
592
618
  <table class="data-table">
593
- <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>
594
- <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>
595
621
  </table>
596
622
  </section>
597
623
  <section class="meta-card">
598
624
  <h2>${view.text("account.privateArtifacts")}</h2>
599
625
  <table class="data-table">
600
- <thead><tr><th>Title</th><th>ID</th><th>Updated</th><th>Status</th></tr></thead>
601
- <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>
602
628
  </table>
603
629
  </section>
604
630
  </main>
@@ -610,37 +636,37 @@ export function renderAccountPage({ baseUrl, user, tokens = [], createdToken = "
610
636
  export function renderPasswordPage({ baseUrl, user, required = false, error = "", success = "", locale = DEFAULT_LOCALE, currentPath = "/account/password" }) {
611
637
  const view = viewContext(locale, currentPath);
612
638
  return pageShell({
613
- title: "Change Password",
639
+ title: view.raw("password.title"),
614
640
  body: `
615
641
  ${workspaceHeader(user, view)}
616
642
  <header class="topbar">
617
643
  <div>
618
- <h1>Change Password</h1>
644
+ <h1>${view.text("password.title")}</h1>
619
645
  <p>${escapeHtml(user.email)} · ${escapeHtml(baseUrl)}</p>
620
646
  </div>
621
647
 
622
648
  </header>
623
649
  <main id="main-content" class="settings-page artifact-editor auth-panel">
624
- ${required ? `<p class="auth-warning">Password change is required before continuing.</p>` : ""}
650
+ ${required ? `<p class="auth-warning">${view.text("password.required")}</p>` : ""}
625
651
  ${error ? `<p class="auth-error">${escapeHtml(error)}</p>` : ""}
626
652
  ${success ? `<p class="auth-success">${escapeHtml(success)}</p>` : ""}
627
653
  <form class="editor-form auth-form" method="post" action="/account/password">
628
654
  <section class="editor-fields">
629
655
  <label class="field">
630
- <span>Current password</span>
631
- <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">
632
658
  </label>
633
659
  <label class="field">
634
- <span>New password</span>
635
- <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">
636
662
  </label>
637
663
  <label class="field">
638
- <span>Confirm password</span>
639
- <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">
640
666
  </label>
641
667
  </section>
642
668
  <footer class="editor-actions">
643
- <button type="submit">Change password</button>
669
+ <button type="submit" class="btn btn-primary">${view.text("password.submit")}</button>
644
670
  </footer>
645
671
  </form>
646
672
  </main>
@@ -662,57 +688,57 @@ function renderFullImportSummary(tableCounts) {
662
688
  export function renderAdminBackupPage({ user, baseUrl, integrity, importResult = null, importError = "", locale = DEFAULT_LOCALE, currentPath = "/admin/backup" }) {
663
689
  const view = viewContext(locale, currentPath);
664
690
  return pageShell({
665
- title: "Backup",
691
+ title: view.raw("nav.backup"),
666
692
  body: `
667
693
  ${workspaceHeader(user, view)}
668
694
  <header class="topbar">
669
695
  <div>
670
- <h1>Backup</h1>
696
+ <h1>${view.text("nav.backup")}</h1>
671
697
  <p>${escapeHtml(baseUrl)}</p>
672
698
  </div>
673
699
 
674
700
  </header>
675
701
  <main id="main-content" class="settings-page artifact-view">
676
- ${importResult ? `<p class="auth-success">Restore complete. ${escapeHtml(importResult.artifactCount)} artifacts imported${importResult.scope === "full" ? renderFullImportSummary(importResult.tableCounts) : ""}.</p>` : ""}
702
+ ${importResult ? `<p class="auth-success">${view.text("backup.complete", { count: importResult.artifactCount })}${importResult.scope === "full" ? renderFullImportSummary(importResult.tableCounts) : ""}.</p>` : ""}
677
703
  ${importError ? `<p class="auth-error">${escapeHtml(importError)}</p>` : ""}
678
704
  <section class="meta-card">
679
- <h2>Store status</h2>
705
+ <h2>${view.text("backup.status")}</h2>
680
706
  <table class="data-table">
681
707
  <tbody>
682
- <tr><th>Home</th><td>${escapeHtml(integrity.store)}</td></tr>
683
- <tr><th>Artifacts</th><td>${escapeHtml(integrity.artifactCount)}</td></tr>
684
- <tr><th>Versions</th><td>${escapeHtml(integrity.versionCount)}</td></tr>
685
- <tr><th>Content bytes</th><td>${escapeHtml(integrity.totalBytes)}</td></tr>
686
- <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>
687
713
  </tbody>
688
714
  </table>
689
- ${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>`}
690
716
  </section>
691
717
  <section class="meta-card">
692
- <h2>Download backup</h2>
693
- <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>
694
720
  <form class="editor-form" method="get" action="/admin/backup/export">
695
721
  <label class="field">
696
722
  <span>${view.text("backup.scopeLabel")}</span>
697
- <select name="scope">
723
+ <select name="scope" class="form-select">
698
724
  <option value="artifacts">${view.text("backup.scopeArtifacts")}</option>
699
725
  <option value="full">${view.text("backup.scopeFull")}</option>
700
726
  </select>
701
727
  </label>
702
- <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>
703
729
  </form>
704
730
  </section>
705
731
  <section class="meta-card">
706
- <h2>Restore backup</h2>
707
- <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>
708
734
  <form class="editor-form" method="post" action="/admin/backup/import">
709
735
  <label class="field content-field">
710
- <span>Backup JSON</span>
711
- <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>
712
738
  </label>
713
- <label class="checkbox-field"><input type="checkbox" name="confirm" value="replace-all"> ${view.text("backup.confirmLabel")}</label>
714
- <label class="checkbox-field"><input type="checkbox" name="forceUsers" value="1"> ${view.text("backup.forceUsersLabel")}</label>
715
- <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>
716
742
  </form>
717
743
  </section>
718
744
  </main>
@@ -721,11 +747,77 @@ export function renderAdminBackupPage({ user, baseUrl, integrity, importResult =
721
747
  });
722
748
  }
723
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
+
724
815
  export function renderAdminUpdatePage({ baseUrl, user, status = {}, locale = DEFAULT_LOCALE, currentPath = "/admin/update" }) {
725
816
  const view = viewContext(locale, currentPath);
726
817
  const check = status.check;
727
818
  const run = status.run;
728
819
  const active = ["queued", "running"].includes(run?.state);
820
+ const hasUpdate = check?.updateAvailable && check.targetVersion && status.currentVersion !== check.targetVersion;
729
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;
730
822
  const states = Object.fromEntries(["queued", "running", "succeeded", "failed", "interrupted"].map((state) => [state, view.raw(`update.${state}`)]));
731
823
  const messages = { ...states, reconnect: view.raw("update.reconnect"), signIn: view.raw("update.signIn") };
@@ -736,20 +828,19 @@ export function renderAdminUpdatePage({ baseUrl, user, status = {}, locale = DEF
736
828
  <main id="main-content" class="settings-page artifact-view">
737
829
  <section class="meta-card"><h2>${view.text("update.installed")}</h2><p>Artifacty <strong data-update-version>${escapeHtml(status.currentVersion || APP_VERSION)}</strong></p>
738
830
  <p>${view.text("update.help")}</p>
739
- <form method="post" action="/admin/update/check">${localeInput(view.locale)}<button type="submit"${active ? " disabled" : ""}>${view.text("update.check")}</button></form>
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>
740
832
  </section>
741
- ${check ? `<section class="meta-card"><h2>${view.text("update.latest")}</h2>
742
- <p>${escapeHtml(status.currentVersion || APP_VERSION)} → <strong>${escapeHtml(check.targetVersion || "—")}</strong></p>
743
- ${check.targetVersion ? `<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>` : ""}
744
- ${check.errorCode ? `<p role="alert">${view.text("update.checkFailed")}</p>` : !check.supported ? `<p>${view.text("update.unsupported")}</p><p>${view.text("update.manual")}</p>` : !check.updateAvailable && !check.errorCode && check.targetVersion ? `<p>${view.text("update.current")}</p>` : ""}
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>` : ""}
745
836
  ${check.supported && check.updateAvailable && !canStart && !active && !check.errorCode && status.currentVersion !== check.targetVersion ? `<p>${view.text("update.checkAgain")}</p>` : ""}
746
837
  ${canStart ? `<p>${view.text("update.available", { version: check.targetVersion })}</p><p>${view.text("update.backup")}</p>
747
838
  <form class="editor-form" method="post" action="/admin/update/start">${localeInput(view.locale)}
748
839
  <input type="hidden" name="confirmationId" value="${escapeAttribute(check.confirmationId)}">
749
840
  <input type="hidden" name="targetVersion" value="${escapeAttribute(check.targetVersion)}">
750
- <label><input type="checkbox" name="confirmUpdate" value="true" required> ${view.text("update.confirm")}</label>
841
+ <label><input type="checkbox" name="confirmUpdate" value="true" required class="form-check-input"> ${view.text("update.confirm")}</label>
751
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>` : ""}
752
- <button type="submit">${view.text("update.start")}</button>
843
+ <button type="submit" class="btn btn-primary">${view.text("update.start")}</button>
753
844
  </form>` : ""}
754
845
  </section>` : ""}
755
846
  ${run ? `<section class="meta-card" data-update-status data-active="${active}" data-messages="${escapeAttribute(JSON.stringify(messages))}"><h2>${view.text("update.progress")}</h2>
@@ -763,6 +854,23 @@ export function renderAdminUpdatePage({ baseUrl, user, status = {}, locale = DEF
763
854
  });
764
855
  }
765
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
+
766
874
  export function renderAdminAccessPage({ baseUrl, user, mode, teams = [], users = [], networkPolicy = { allowedCidrs: [] }, currentIp = "", preview = null, pendingNetworkPolicy = null, shareLinkPolicy = {}, locale = DEFAULT_LOCALE, currentPath = "/admin/access" }) {
767
875
  const view = viewContext(locale, currentPath);
768
876
  return pageShell({
@@ -774,40 +882,42 @@ export function renderAdminAccessPage({ baseUrl, user, mode, teams = [], users =
774
882
  <main id="main-content" class="settings-page artifact-view">
775
883
  <section class="meta-card"><h2>${view.text("access.mode")}</h2>
776
884
  <form class="editor-form" method="post" action="/admin/access/preview">${localeInput(view.locale)}
777
- <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>
778
886
  <p class="muted">${view.text("access.publicHelp")}</p>
779
- <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>
780
888
  <p class="muted">${view.text("access.privateHelp")}</p>
781
- <button type="submit">${view.text("access.preview")}</button>
889
+ <button type="submit" class="btn btn-outline-secondary">${view.text("access.preview")}</button>
782
890
  </form>
783
891
  </section>
784
892
  ${preview ? `<section class="meta-card"><h2>${view.text("access.preview")}</h2><p>${view.text("access.previewCount", { count: preview.anonymousArtifactCount, mode: preview.mode })}</p>
785
- <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>` : ""}
786
- <section class="meta-card"><h2>${view.text("sharing.links")}</h2><form method="post" action="/admin/access/share-links">${localeInput(view.locale)}
787
- <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>
788
896
  ${pendingNetworkPolicy ? `<section class="meta-card"><h2>${view.text("access.pendingNetwork")}</h2><p>${view.text("access.pendingHelp", { expiresAt: pendingNetworkPolicy.expiresAt })}</p>
789
- ${["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>` : ""}
790
898
  <section class="meta-card"><h2>${view.text("access.network")}</h2>
791
899
  <p class="muted">${view.text("access.networkHelp")}</p>
792
900
  <p>${view.text("access.currentIp")}: <code>${escapeHtml(currentIp)}</code></p>
793
901
  <form class="editor-form" method="post" action="/admin/access/network">${localeInput(view.locale)}
794
- <label class="field"><span>${view.text("access.allowedCidrs")}</span><textarea name="allowedCidrs" rows="5">${escapeHtml((networkPolicy.allowedCidrs || []).join("\n"))}</textarea></label>
795
- <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
+ <label><input type="checkbox" name="allowSharedLinks" value="true"${networkPolicy.allowSharedLinks ? " checked" : ""} class="form-check-input" aria-describedby="shared-network-help"> ${view.text("access.allowSharedLinks")}</label>
904
+ <p id="shared-network-help" class="muted">${view.text("access.sharedNetworkHelp")}</p>
905
+ <button type="submit" class="btn btn-primary">${view.text("access.saveNetwork")}</button>
796
906
  </form>
797
907
  </section>
798
908
  <section class="meta-card"><h2>${view.text("access.teams")}</h2>
799
909
  <form class="editor-form auth-form" method="post" action="/admin/teams">${localeInput(view.locale)}
800
- <label class="field"><span>${view.text("access.teamName")}</span><input name="name" required></label>
801
- <button type="submit">${view.text("access.createTeam")}</button>
910
+ <label class="field"><span>${view.text("access.teamName")}</span><input name="name" required class="form-control"></label>
911
+ <button type="submit" class="btn btn-primary">${view.text("access.createTeam")}</button>
802
912
  </form>
803
913
  ${teams.length ? teams.map((team) => `<section><h3>${escapeHtml(team.name)}</h3>
804
914
  <form class="editor-form" method="post" action="/admin/teams/${encodeURIComponent(team.id)}/members">${localeInput(view.locale)}
805
915
  <fieldset><legend>${view.text("access.members")}</legend>${accessCheckboxes(users, "userIds", team.memberIds || [], view)}</fieldset>
806
- <button type="submit">${view.text("access.saveMembers")}</button>
916
+ <button type="submit" class="btn btn-primary">${view.text("access.saveMembers")}</button>
807
917
  </form></section>`).join("") : `<p class="muted">${view.text("access.noTeams")}</p>`}
808
918
  </section>
809
919
  <section class="meta-card"><h2>${view.text("access.samples")}</h2><p>${view.text("access.samplesHelp")}</p>
810
- ${["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>
920
+ ${["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>
811
921
  </main>`
812
922
  });
813
923
  }
@@ -825,7 +935,7 @@ export function renderArtifactSharingPage({ baseUrl, user, artifact, sharing = {
825
935
  <form class="editor-form" method="post" action="/artifacts/${encodeURIComponent(artifact.id)}/sharing">${localeInput(view.locale)}
826
936
  <fieldset><legend>${view.text("sharing.users")}</legend>${accessCheckboxes(users, "userIds", sharing.userIds || [], view, sharing.userPermissions || {})}</fieldset>
827
937
  <fieldset><legend>${view.text("sharing.teams")}</legend>${accessCheckboxes(teams, "teamIds", sharing.teamIds || [], view, sharing.teamPermissions || {})}</fieldset>
828
- <button type="submit">${view.text("sharing.save")}</button>
938
+ <button type="submit" class="btn btn-outline-secondary">${view.text("sharing.save")}</button>
829
939
  </form>
830
940
  </section>
831
941
  ${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>
@@ -833,17 +943,17 @@ export function renderArtifactSharingPage({ baseUrl, user, artifact, sharing = {
833
943
  <section class="meta-card"><h2>${view.text("sharing.links")}</h2><p>${view.text("sharing.linksHelp")}</p>
834
944
  ${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>` : ""}
835
945
  <form class="editor-form" method="post" action="/artifacts/${encodeURIComponent(artifact.id)}/share-links">${localeInput(view.locale)}
836
- <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>
837
- <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>
838
- ${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>
839
- ${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("")}
946
+ <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>
947
+ <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>
948
+ ${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>
949
+ ${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("")}
840
950
  </section></main>`
841
951
  });
842
952
  }
843
953
 
844
954
  function accessCheckboxes(items, name, selectedIds, view, permissions = null) {
845
955
  if (!items.length) return `<p class="muted">${view.text(name === "userIds" ? "access.noUsers" : "access.noTeams")}</p>`;
846
- 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("");
956
+ 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("");
847
957
  }
848
958
 
849
959
  export function renderInboxPage({ baseUrl, user, notifications = [], unreadCount = 0, locale = DEFAULT_LOCALE, currentPath = "/inbox" }) {
@@ -852,9 +962,9 @@ export function renderInboxPage({ baseUrl, user, notifications = [], unreadCount
852
962
  body: `${workspaceHeader(user, view)}
853
963
  <header class="topbar"><div><h1>${view.text("inbox.title")}</h1><p>${escapeHtml(baseUrl)}</p></div></header>
854
964
  <main id="main-content" class="artifact-view"><section class="meta-card"><p>${view.text("inbox.unread", { count: unreadCount })}</p>
855
- <form method="post" action="/inbox/read-all">${localeInput(view.locale)}<button type="submit">${view.text("inbox.readAll")}</button></form>
856
- ${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>
857
- ${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>`}
965
+ <form method="post" action="/inbox/read-all">${localeInput(view.locale)}<button type="submit" class="btn btn-outline-secondary">${view.text("inbox.readAll")}</button></form>
966
+ ${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>
967
+ ${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>`}
858
968
  </section></main>` });
859
969
  }
860
970
 
@@ -873,31 +983,38 @@ export function renderSharedArtifactPage({ artifact, version, content, locale =
873
983
  return pageShell({ title: artifact.title, locale: view.locale,
874
984
  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>`,
875
985
  head: needsViewer ? editorHead() : "",
876
- afterBody: `${frameResizeScript()}${needsViewer ? viewerScript() : ""}` });
986
+ afterBody: `${frameResizeScript()}${needsViewer ? viewerScript(view.locale) : ""}` });
877
987
  }
878
988
 
879
- export function renderAdminUsersPage({ baseUrl, user, users = [], importResult = null, importError = "", locale = DEFAULT_LOCALE, currentPath = "/admin/users" }) {
989
+ export function renderAdminUsersPage({ baseUrl, user, users = [], importResult = null, importError = "", roleError = "", resetResult = null, resetError = "", locale = DEFAULT_LOCALE, currentPath = "/admin/users" }) {
880
990
  const view = viewContext(locale, currentPath);
881
991
  const rows = users.map((item) => `
882
992
  <tr>
883
993
  <td>${escapeHtml(item.email)}</td>
884
994
  <td>${escapeHtml(item.name)}</td>
885
- <td>${escapeHtml(item.role)}</td>
886
- <td>${item.active ? "Active" : "Disabled"}${item.passwordResetRequired ? " · Reset required" : ""}</td>
887
- <td>${escapeHtml(item.createdAt)}</td>
888
- <td>
889
- ${item.id === user.id ? "" : `<form method="post" action="/admin/users/${encodeURIComponent(item.id)}/${item.active ? "disable" : "enable"}">
890
- <button type="submit">${item.active ? "Disable" : "Enable"}</button>
995
+ <td><form class="user-role-form" method="post" action="/admin/users/${encodeURIComponent(item.id)}/role">${localeInput(view.locale)}
996
+ <select name="role" aria-label="${view.text("users.roleFor", { email: item.email })}" class="form-select">
997
+ ${["user", "admin"].map((role) => `<option value="${role}"${item.role === role ? " selected" : ""}>${view.text(`ui.role.${role}`)}</option>`).join("")}
998
+ </select><button type="submit" aria-label="${view.text("users.saveRoleFor", { email: item.email })}" class="btn btn-outline-secondary">${view.text("users.saveRole")}</button>
999
+ </form></td>
1000
+ <td>${item.active ? view.text("ui.active") : view.text("ui.disabled")}${item.passwordResetRequired ? ` · ${view.text("users.resetRequired")}` : ""}</td>
1001
+ <td>${renderDateTime(item.createdAt, view)}</td>
1002
+ <td><div class="user-account-actions">
1003
+ <form method="post" action="/admin/users/${encodeURIComponent(item.id)}/reset-password">${localeInput(view.locale)}
1004
+ <button type="submit" aria-label="${view.text("users.resetFor", { email: item.email })}" class="btn btn-ghost">${view.text("users.resetPassword")}</button>
1005
+ </form>
1006
+ ${item.id === user.id ? "" : `<form method="post" action="/admin/users/${encodeURIComponent(item.id)}/${item.active ? "disable" : "enable"}">${localeInput(view.locale)}
1007
+ <button type="submit" class="btn btn-outline-danger">${item.active ? view.text("ui.disable") : view.text("ui.enable")}</button>
891
1008
  </form>`}
892
- </td>
1009
+ </div></td>
893
1010
  </tr>
894
1011
  `).join("");
895
1012
  const createdRows = (importResult?.created || []).map((item) => `
896
1013
  <tr>
897
1014
  <td>${escapeHtml(item.user.email)}</td>
898
- <td>${escapeHtml(item.user.role)}</td>
899
- <td>${item.user.passwordResetRequired ? "Yes" : "No"}</td>
900
- <td>${item.passwordGenerated ? `<code>${escapeHtml(item.temporaryPassword)}</code>` : "Provided in CSV"}</td>
1015
+ <td>${view.text(`ui.role.${item.user.role}`)}</td>
1016
+ <td>${item.user.passwordResetRequired ? view.text("ui.yes") : view.text("ui.no")}</td>
1017
+ <td>${item.passwordGenerated ? `<code>${escapeHtml(item.temporaryPassword)}</code>` : view.text("users.provided")}</td>
901
1018
  </tr>
902
1019
  `).join("");
903
1020
  const skippedRows = (importResult?.skipped || []).map((item) => `
@@ -908,67 +1025,76 @@ export function renderAdminUsersPage({ baseUrl, user, users = [], importResult =
908
1025
  `).join("");
909
1026
  const importSummary = importResult ? `
910
1027
  <section class="meta-card">
911
- <h2>Import results</h2>
912
- <p class="muted">${importResult.created.length} created, ${importResult.skipped.length} skipped, ${importResult.failed.length} failed.</p>
1028
+ <h2>${view.text("users.importResults")}</h2>
1029
+ <p class="muted">${view.text("users.summary", { created: importResult.created.length, skipped: importResult.skipped.length, failed: importResult.failed.length })}</p>
913
1030
  ${createdRows ? `<table class="data-table">
914
- <thead><tr><th>Email</th><th>Role</th><th>Reset required</th><th>Temporary password</th></tr></thead>
1031
+ <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>
915
1032
  <tbody>${createdRows}</tbody>
916
1033
  </table>` : ""}
917
- ${skippedRows ? `<h3>Skipped</h3><table class="data-table">
918
- <thead><tr><th>Row</th><th>Email</th><th>Reason</th></tr></thead>
1034
+ ${skippedRows ? `<h3>${view.text("users.skipped")}</h3><table class="data-table">
1035
+ <thead><tr><th>${view.text("ui.row")}</th><th>${view.text("ui.email")}</th><th>${view.text("ui.reason")}</th></tr></thead>
919
1036
  <tbody>${skippedRows}</tbody>
920
1037
  </table>` : ""}
921
- ${failedRows ? `<h3>Failed</h3><table class="data-table">
922
- <thead><tr><th>Row</th><th>Email</th><th>Error</th></tr></thead>
1038
+ ${failedRows ? `<h3>${view.text("users.failed")}</h3><table class="data-table">
1039
+ <thead><tr><th>${view.text("ui.row")}</th><th>${view.text("ui.email")}</th><th>${view.text("ui.error")}</th></tr></thead>
923
1040
  <tbody>${failedRows}</tbody>
924
1041
  </table>` : ""}
925
1042
  </section>
926
1043
  ` : "";
927
1044
 
928
1045
  return pageShell({
929
- title: "Users",
1046
+ title: view.raw("nav.users"),
930
1047
  body: `
931
1048
  ${workspaceHeader(user, view)}
932
1049
  <header class="topbar">
933
1050
  <div>
934
- <h1>Users</h1>
1051
+ <h1>${view.text("nav.users")}</h1>
935
1052
  <p>${escapeHtml(baseUrl)}</p>
936
1053
  </div>
937
1054
 
938
1055
  </header>
939
1056
  <main id="main-content" class="settings-page artifact-view">
1057
+ ${resetError ? `<p class="auth-error" role="alert">${escapeHtml(resetError)}</p>` : ""}
1058
+ ${resetResult ? `<section class="token-once" role="status"><h2>${view.text("users.resetReady")}</h2>
1059
+ <p>${view.text("users.resetAccount", { email: resetResult.user.email })}</p>
1060
+ <pre class="artifact-code"><code>${escapeHtml(resetResult.temporaryPassword)}</code></pre>
1061
+ <p>${view.text("users.resetHelp")}</p>
1062
+ <p>${view.text(`smtp.mailStatus.${["sent", "failed", "not-configured"].includes(resetResult.mailStatus) ? resetResult.mailStatus : "not-configured"}`)}</p>
1063
+ </section>` : ""}
940
1064
  <section class="meta-card">
941
- <h2>Create user</h2>
1065
+ <h2>${view.text("users.create")}</h2>
942
1066
  <form class="editor-form auth-form" method="post" action="/admin/users">
943
1067
  <section class="editor-fields">
944
- <label class="field"><span>Email</span><input type="email" name="email" required></label>
945
- <label class="field"><span>Name</span><input name="name"></label>
946
- <label class="field"><span>Role</span><select name="role"><option value="user">user</option><option value="admin">admin</option></select></label>
947
- <label class="field"><span>Password</span><input type="password" name="password" minlength="8" required></label>
948
- <label class="check-field"><input type="checkbox" name="passwordResetRequired"> Require password change</label>
1068
+ <label class="field"><span>${view.text("ui.email")}</span><input type="email" name="email" required class="form-control"></label>
1069
+ <label class="field"><span>${view.text("ui.name")}</span><input name="name" class="form-control"></label>
1070
+ <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>
1071
+ <label class="field"><span>${view.text("ui.password")}</span><input type="password" name="password" minlength="8" required class="form-control"></label>
1072
+ <label class="check-field"><input type="checkbox" name="passwordResetRequired" class="form-check-input"> ${view.text("users.requirePassword")}</label>
949
1073
  </section>
950
- <footer class="editor-actions"><button type="submit">Create user</button></footer>
1074
+ <footer class="editor-actions"><button type="submit" class="btn btn-primary">${view.text("users.create")}</button></footer>
951
1075
  </form>
952
1076
  </section>
953
1077
  <section class="meta-card">
954
- <h2>Import users from CSV</h2>
955
- <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>
1078
+ <h2>${view.text("users.importTitle")}</h2>
1079
+ <p class="muted">${view.text("users.csvHelp")}</p>
956
1080
  ${importError ? `<p class="auth-error">${escapeHtml(importError)}</p>` : ""}
957
1081
  <form class="editor-form" method="post" action="/admin/users/import">
958
1082
  <label class="field content-field">
959
1083
  <span>CSV</span>
960
- <textarea class="compact-textarea" name="csv" spellcheck="false" placeholder="email,name,role&#10;user@example.com,User,user" required></textarea>
1084
+ <textarea class="compact-textarea form-control" name="csv" spellcheck="false" placeholder="email,name,role&#10;user@example.com,User,user" required></textarea>
961
1085
  </label>
962
- <footer class="editor-actions"><button type="submit">Import users</button></footer>
1086
+ <footer class="editor-actions"><button type="submit" class="btn btn-outline-secondary">${view.text("users.import")}</button></footer>
963
1087
  </form>
964
1088
  </section>
965
1089
  ${importSummary}
966
1090
  <section class="meta-card">
967
- <h2>Existing users</h2>
968
- <table class="data-table">
969
- <thead><tr><th>Email</th><th>Name</th><th>Role</th><th>Status</th><th>Created</th><th></th></tr></thead>
1091
+ <h2>${view.text("users.existing")}</h2>
1092
+ ${roleError ? `<p class="auth-error" role="alert">${escapeHtml(roleError)}</p>` : ""}
1093
+ <p class="muted">${view.text("users.lastAdmin")}</p>
1094
+ <div class="artifact-table-scroll"><table class="data-table">
1095
+ <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>
970
1096
  <tbody>${rows}</tbody>
971
- </table>
1097
+ </table></div>
972
1098
  </section>
973
1099
  </main>
974
1100
  `,
@@ -987,7 +1113,7 @@ export function renderAdminWebhooksPage({ user, baseUrl, webhooks = [], createdS
987
1113
  <td>${webhook.failureCount}</td>
988
1114
  <td>
989
1115
  <form method="post" action="/admin/webhooks/${encodeURIComponent(webhook.id)}/delete" onsubmit="return confirm('${escapeAttribute(view.text("webhooks.confirmDelete"))}')">
990
- <button type="submit">${view.text("webhooks.delete")}</button>
1116
+ <button type="submit" class="btn btn-outline-danger">${view.text("webhooks.delete")}</button>
991
1117
  </form>
992
1118
  </td>
993
1119
  </tr>
@@ -1019,10 +1145,10 @@ export function renderAdminWebhooksPage({ user, baseUrl, webhooks = [], createdS
1019
1145
  <h2>${view.text("webhooks.createTitle")}</h2>
1020
1146
  <form class="editor-form" method="post" action="/admin/webhooks">
1021
1147
  <section class="editor-fields">
1022
- <label class="field"><span>${view.text("webhooks.url")}</span><input type="url" name="url" placeholder="https://example.com/hooks/artifacty" required></label>
1023
- <label class="field"><span>${view.text("webhooks.eventTypes")}</span><input name="eventTypes" placeholder="${view.text("webhooks.eventTypesPlaceholder")}"></label>
1148
+ <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>
1149
+ <label class="field"><span>${view.text("webhooks.eventTypes")}</span><input name="eventTypes" placeholder="${view.text("webhooks.eventTypesPlaceholder")}" class="form-control"></label>
1024
1150
  </section>
1025
- <footer class="editor-actions"><button type="submit">${view.text("webhooks.create")}</button></footer>
1151
+ <footer class="editor-actions"><button type="submit" class="btn btn-outline-secondary">${view.text("webhooks.create")}</button></footer>
1026
1152
  </form>
1027
1153
  </section>
1028
1154
  <section class="meta-card">
@@ -1049,7 +1175,7 @@ export function renderAdminRetentionPage({ user, baseUrl, policy, lastSweep = nu
1049
1175
  <h2>${view.text("retention.lastSweepTitle")}</h2>
1050
1176
  <table class="data-table">
1051
1177
  <tbody>
1052
- <tr><th>${view.text("retention.sweepAt")}</th><td>${escapeHtml(lastSweep.createdAt)}</td></tr>
1178
+ <tr><th>${view.text("retention.sweepAt")}</th><td>${renderDateTime(lastSweep.createdAt, view)}</td></tr>
1053
1179
  <tr><th>${view.text("retention.archived")}</th><td>${escapeHtml(lastSweep.metadata?.archived ?? 0)}</td></tr>
1054
1180
  <tr><th>${view.text("retention.purged")}</th><td>${escapeHtml(lastSweep.metadata?.purged ?? 0)}</td></tr>
1055
1181
  <tr><th>${view.text("retention.purgeSkipped")}</th><td>${lastSweep.metadata?.purgeSkipped ? view.text("retention.yes") : view.text("retention.no")}</td></tr>
@@ -1099,16 +1225,24 @@ export function renderAdminRetentionPage({ user, baseUrl, policy, lastSweep = nu
1099
1225
  <main id="main-content" class="settings-page artifact-view">
1100
1226
  <section class="meta-card">
1101
1227
  <h2>${view.text("retention.policyTitle")}</h2>
1102
- <form class="editor-form" method="post" action="/admin/retention">
1103
- <section class="editor-fields">
1104
- <label class="field"><span>${view.text("retention.archiveAfterDaysDefault")}</span><input type="number" min="1" name="archiveAfterDaysDefault" value="${policy.archiveAfterDays.default ?? ""}"></label>
1105
- <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>
1106
- <label class="field"><span>${view.text("retention.purgeArchivedAfterDays")}</span><input type="number" min="1" name="purgeArchivedAfterDays" value="${policy.purgeArchivedAfterDays ?? ""}"></label>
1107
- <label class="field"><span>${view.text("retention.auditRetentionDays")}</span><input type="number" min="1" name="auditRetentionDays" value="${policy.auditRetentionDays ?? ""}"></label>
1108
- <label class="field"><span>${view.text("retention.eventRetentionRows")}</span><input type="number" min="1" name="eventRetentionRows" value="${policy.eventRetentionRows ?? ""}"></label>
1109
- <label class="field"><span>${view.text("retention.keepTags")}</span><input name="keepTags" value="${escapeAttribute((policy.keepTags || []).join(", "))}" placeholder="pinned, release"></label>
1110
- </section>
1111
- <footer class="editor-actions"><button type="submit">${view.text("retention.saveButton")}</button></footer>
1228
+ <form class="editor-form retention-policy-form" method="post" action="/admin/retention">${localeInput(view.locale)}
1229
+ <fieldset class="retention-section"><legend>${view.text("retention.artifactsSection")}</legend>
1230
+ <div class="retention-field-grid">
1231
+ <label class="field"><span>${view.text("retention.archiveAfterDaysDefault")}</span><input type="number" min="1" name="archiveAfterDaysDefault" value="${policy.archiveAfterDays.default ?? ""}" class="form-control"></label>
1232
+ <label class="field"><span>${view.text("retention.purgeArchivedAfterDays")}</span><input type="number" min="1" name="purgeArchivedAfterDays" value="${policy.purgeArchivedAfterDays ?? ""}" class="form-control"></label>
1233
+ </div>
1234
+ <label class="field"><span>${view.text("retention.keepTags")}</span><input name="keepTags" value="${escapeAttribute((policy.keepTags || []).join(", "))}" placeholder="pinned, release" class="form-control"></label>
1235
+ </fieldset>
1236
+ <fieldset class="retention-section"><legend>${view.text("retention.typeRulesSection")}</legend>
1237
+ <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>
1238
+ </fieldset>
1239
+ <fieldset class="retention-section"><legend>${view.text("retention.historySection")}</legend>
1240
+ <div class="retention-field-grid">
1241
+ <label class="field"><span>${view.text("retention.auditRetentionDays")}</span><input type="number" min="1" name="auditRetentionDays" value="${policy.auditRetentionDays ?? ""}" class="form-control"></label>
1242
+ <label class="field"><span>${view.text("retention.eventRetentionRows")}</span><input type="number" min="1" name="eventRetentionRows" value="${policy.eventRetentionRows ?? ""}" class="form-control"></label>
1243
+ </div>
1244
+ </fieldset>
1245
+ <footer class="editor-actions"><button type="submit" class="btn btn-primary">${view.text("retention.saveButton")}</button></footer>
1112
1246
  </form>
1113
1247
  </section>
1114
1248
  ${lastSweepSection}
@@ -1116,9 +1250,9 @@ export function renderAdminRetentionPage({ user, baseUrl, policy, lastSweep = nu
1116
1250
  <h2>${view.text("retention.runTitle")}</h2>
1117
1251
  <p class="auth-warning">${view.text("retention.purgeGateNote")}</p>
1118
1252
  <form class="editor-form" method="post" action="/admin/retention/run">
1119
- <label class="checkbox-field"><input type="checkbox" name="dryRun" value="1" checked> ${view.text("retention.dryRunLabel")}</label>
1120
- <label class="checkbox-field"><input type="checkbox" name="allowPurge" value="1"> ${view.text("retention.allowPurgeLabel")}</label>
1121
- <footer class="editor-actions"><button type="submit">${view.text("retention.runButton")}</button></footer>
1253
+ <label class="checkbox-field"><input type="checkbox" name="dryRun" value="1" checked class="form-check-input"> ${view.text("retention.dryRunLabel")}</label>
1254
+ <label class="checkbox-field"><input type="checkbox" name="allowPurge" value="1" class="form-check-input"> ${view.text("retention.allowPurgeLabel")}</label>
1255
+ <footer class="editor-actions"><button type="submit" class="btn btn-outline-secondary">${view.text("retention.runButton")}</button></footer>
1122
1256
  </form>
1123
1257
  </section>
1124
1258
  ${reportSection}
@@ -1174,8 +1308,8 @@ export function renderArtifactPage({ artifact, version, content, comments = [],
1174
1308
  <span>${view.text("artifact.visibility.label")}</span>
1175
1309
  ${visibilitySelect(artifact.visibility || "team", view)}
1176
1310
  </label>
1177
- <button type="submit">${view.text("artifact.visibility.change")}</button>
1178
- </form>`
1311
+ <button type="submit" class="btn btn-outline-secondary">${view.text("artifact.visibility.change")}</button>
1312
+ </form><p class="muted">${view.text("artifact.visibility.help")}</p>`
1179
1313
  : "";
1180
1314
 
1181
1315
  return pageShell({
@@ -1208,14 +1342,14 @@ export function renderArtifactPage({ artifact, version, content, comments = [],
1208
1342
  <span>${view.text("artifact.schema", { version: artifact.schemaVersion || 1 })}</span>
1209
1343
  <span title="${escapeAttribute(publisherTitle(artifact, view))}">${view.text("artifact.publisher", { publisher })}</span>
1210
1344
  <span>${view.text("artifact.bytes", { size: version.sizeBytes })}</span>
1211
- <span>${escapeHtml(version.createdAt)}</span>
1345
+ <span>${renderDateTime(version.createdAt, view)}</span>
1212
1346
  <span>${escapeHtml(version.sha256.slice(0, 12))}</span>
1213
1347
  </div></details>
1214
1348
  </section>
1215
1349
  ${canArchive ? `<form class="inline-action" method="post" action="/artifacts/${encodeURIComponent(artifact.id)}/${archiveAction}">
1216
1350
  ${hiddenToken(authToken)}
1217
1351
  ${localeInput(view.locale)}
1218
- <button type="submit">${archiveLabel}</button>
1352
+ <button type="submit" class="btn btn-outline-secondary">${archiveLabel}</button>
1219
1353
  </form>` : ""}
1220
1354
  ${visibilityForm}
1221
1355
  <section class="version-strip">${versionLinks}</section>
@@ -1225,7 +1359,7 @@ export function renderArtifactPage({ artifact, version, content, comments = [],
1225
1359
  </main>
1226
1360
  `,
1227
1361
  head: needsViewerScript ? editorHead() : "",
1228
- afterBody: `${frameResizeScript()}${needsViewerScript ? viewerScript() : ""}`,
1362
+ afterBody: `${frameResizeScript()}${needsViewerScript ? viewerScript(view.locale) : ""}`,
1229
1363
  locale: view.locale
1230
1364
  });
1231
1365
  }
@@ -1314,14 +1448,14 @@ function renderCommentEntry(artifact, comment, view, { authToken, canModerate, c
1314
1448
  ? `<form class="inline-action" method="post" action="/artifacts/${encodeURIComponent(artifact.id)}/comments/${encodeURIComponent(comment.id)}/resolve">
1315
1449
  ${hiddenToken(authToken)}
1316
1450
  ${localeInput(view.locale)}
1317
- <button type="submit">${view.text("comments.resolve")}</button>
1451
+ <button type="submit" class="btn btn-outline-secondary">${view.text("comments.resolve")}</button>
1318
1452
  </form>`
1319
1453
  : "";
1320
1454
  const deleteForm = canModerate
1321
1455
  ? `<form class="inline-action" method="post" action="/artifacts/${encodeURIComponent(artifact.id)}/comments/${encodeURIComponent(comment.id)}/delete">
1322
1456
  ${hiddenToken(authToken)}
1323
1457
  ${localeInput(view.locale)}
1324
- <button type="submit">${view.text("comments.delete")}</button>
1458
+ <button type="submit" class="btn btn-outline-secondary">${view.text("comments.delete")}</button>
1325
1459
  </form>`
1326
1460
  : "";
1327
1461
 
@@ -1329,7 +1463,7 @@ function renderCommentEntry(artifact, comment, view, { authToken, canModerate, c
1329
1463
  <li class="comment-item${isReply ? " comment-reply" : ""}${resolved ? " comment-resolved" : ""}">
1330
1464
  <div class="comment-header">
1331
1465
  <span class="comment-author">${escapeHtml(comment.authorLabel)}</span>
1332
- <span class="comment-meta">${escapeHtml(comment.createdAt)}</span>
1466
+ <span class="comment-meta">${renderDateTime(comment.createdAt, view)}</span>
1333
1467
  ${anchorLabel ? `<span class="comment-anchor">${anchorLabel}</span>` : ""}
1334
1468
  ${resolved ? `<span class="badge s-archived">${view.text("comments.resolved")}</span>` : ""}
1335
1469
  </div>
@@ -1367,13 +1501,13 @@ function renderCommentForm(artifact, view, { version, authToken, parentId, compa
1367
1501
  ${parentId ? `<input type="hidden" name="parentId" value="${escapeAttribute(parentId)}">` : ""}
1368
1502
  <label>
1369
1503
  <span>${view.text(compact ? "comments.replyLabel" : "comments.bodyLabel")}</span>
1370
- <textarea name="body" required maxlength="16384"></textarea>
1504
+ <textarea name="body" required maxlength="16384" class="form-control"></textarea>
1371
1505
  </label>
1372
1506
  ${compact ? "" : `<label class="comment-line-label">
1373
1507
  <span>${view.text("comments.lineLabel")}</span>
1374
- <input type="number" name="line" min="1">
1508
+ <input type="number" name="line" min="1" class="form-control">
1375
1509
  </label>`}
1376
- <button type="submit">${view.text(compact ? "comments.reply" : "comments.submit")}</button>
1510
+ <button type="submit" class="btn btn-primary">${view.text(compact ? "comments.reply" : "comments.submit")}</button>
1377
1511
  </form>
1378
1512
  `;
1379
1513
  }
@@ -1386,11 +1520,11 @@ function renderReviewStatusForm(artifact, view, authToken) {
1386
1520
  ${localeInput(view.locale)}
1387
1521
  <label>
1388
1522
  <span>${view.text("comments.reviewStatus.label")}</span>
1389
- <select name="status">
1523
+ <select name="status" class="form-select">
1390
1524
  ${REVIEW_STATUSES.map((status) => `<option value="${status}"${status === current ? " selected" : ""}>${view.text(`comments.reviewStatus.${status}`)}</option>`).join("")}
1391
1525
  </select>
1392
1526
  </label>
1393
- <button type="submit">${view.text("comments.reviewStatus.change")}</button>
1527
+ <button type="submit" class="btn btn-outline-secondary">${view.text("comments.reviewStatus.change")}</button>
1394
1528
  </form>
1395
1529
  `;
1396
1530
  }
@@ -1497,8 +1631,8 @@ export function renderDiffPage({ user, artifact, fromVersion, toVersion, fromCon
1497
1631
 
1498
1632
  const preview = toVersion.format === "html"
1499
1633
  ? `<section class="split-preview">
1500
- <div>${renderContent(fromVersion.format, fromContent)}</div>
1501
- <div>${renderContent(toVersion.format, toContent)}</div>
1634
+ <div>${renderContent(fromVersion.format, fromContent, {}, { view })}</div>
1635
+ <div>${renderContent(toVersion.format, toContent, {}, { view })}</div>
1502
1636
  </section>`
1503
1637
  : "";
1504
1638
 
@@ -1537,13 +1671,13 @@ export function renderDiffPage({ user, artifact, fromVersion, toVersion, fromCon
1537
1671
  <input type="hidden" name="view" value="${escapeAttribute(resolvedView)}">
1538
1672
  <label class="field">
1539
1673
  <span>${view.text("diff.from")}</span>
1540
- <select name="from">${versionOptions}</select>
1674
+ <select name="from" class="form-select">${versionOptions}</select>
1541
1675
  </label>
1542
1676
  <label class="field">
1543
1677
  <span>${view.text("diff.to")}</span>
1544
- <select name="to">${versionOptions}</select>
1678
+ <select name="to" class="form-select">${versionOptions}</select>
1545
1679
  </label>
1546
- <button type="submit">${view.text("diff.compare")}</button>
1680
+ <button type="submit" class="btn btn-outline-secondary">${view.text("diff.compare")}</button>
1547
1681
  </form>
1548
1682
  <script>
1549
1683
  document.querySelector('select[name="from"]').value = ${JSON.stringify(String(fromVersion.version))};
@@ -1772,6 +1906,7 @@ function renderBundleFiles(content, options = {}) {
1772
1906
  }
1773
1907
 
1774
1908
  export function renderContent(format, content, metadata = {}, options = {}) {
1909
+ const view = options.view || viewContext(options.locale || DEFAULT_LOCALE, "/");
1775
1910
  if (format === "html") {
1776
1911
  return `<iframe class="artifact-frame" sandbox="allow-scripts allow-forms allow-popups" srcdoc="${escapeAttribute(htmlFrameContent(content))}"></iframe>`;
1777
1912
  }
@@ -1799,15 +1934,15 @@ export function renderContent(format, content, metadata = {}, options = {}) {
1799
1934
  }
1800
1935
 
1801
1936
  if (format === "notebook") {
1802
- return renderNotebook(content);
1937
+ return renderNotebook(content, view);
1803
1938
  }
1804
1939
 
1805
1940
  if (format === "sarif") {
1806
- return renderSarif(content);
1941
+ return renderSarif(content, view);
1807
1942
  }
1808
1943
 
1809
1944
  if (format === "csv") {
1810
- return renderCsv(content);
1945
+ return renderCsv(content, view);
1811
1946
  }
1812
1947
 
1813
1948
  if (format === "image") {
@@ -1821,7 +1956,7 @@ export function renderContent(format, content, metadata = {}, options = {}) {
1821
1956
  if (format === "code") {
1822
1957
  const language = metadata.language || metadata.artifactyImport?.language || "";
1823
1958
  return `<section class="artifact-code-viewer" data-artifacty-code-viewer data-language="${escapeAttribute(language)}">
1824
- <textarea hidden>${escapeHtml(content)}</textarea>
1959
+ <textarea hidden class="form-control">${escapeHtml(content)}</textarea>
1825
1960
  <pre class="artifact-code artifact-code-fallback"><code>${escapeHtml(content)}</code></pre>
1826
1961
  </section>`;
1827
1962
  }
@@ -1831,7 +1966,7 @@ export function renderContent(format, content, metadata = {}, options = {}) {
1831
1966
  return `<iframe class="artifact-frame artifact-react-frame" sandbox="allow-scripts" src="${escapeAttribute(options.reactFrameUrl)}"></iframe>`;
1832
1967
  }
1833
1968
  return `<section class="artifact-react-disabled">
1834
- <p>React rendering is disabled. Set <code>ARTIFACTY_ENABLE_REACT_RENDERER=true</code> to run this component in a sandboxed frame.</p>
1969
+ <p>${view.text("react.disabled")}</p>
1835
1970
  <pre class="artifact-code"><code>${escapeHtml(content)}</code></pre>
1836
1971
  </section>`;
1837
1972
  }
@@ -1922,16 +2057,35 @@ export function renderReactFramePage({ title, content }) {
1922
2057
  }
1923
2058
 
1924
2059
  export function pageShell({ title, body, head = "", afterBody = "", locale = DEFAULT_LOCALE }) {
2060
+ const darkThemeVariables = ` --bg: #15171b;
2061
+ --grid: rgba(120, 160, 200, 0.06);
2062
+ --panel: #1c1f24;
2063
+ --panel-2: #25292f;
2064
+ --text: #e6edf3;
2065
+ --muted: #adb5c0;
2066
+ --faint: #9aa5b2;
2067
+ --line: #343b44;
2068
+ --line-2: #596575;
2069
+ --accent: #9cbcf5;
2070
+ --accent-2: #bbd1f8;
2071
+ --accent-ink: #18253d;
2072
+ --accent-soft: #29374e;
2073
+ --ring: #9cbcf5;
2074
+ --code: #080c12;
2075
+ --code-text: #e6edf3;
2076
+ `;
1925
2077
  return `<!doctype html>
1926
2078
  <html lang="${escapeAttribute(locale)}">
1927
2079
  <head>
1928
2080
  <meta charset="utf-8">
2081
+ <script src="/assets/theme.js"></script><script type="module" src="/assets/auto-apply.js"></script>
2082
+ <script src="/assets/sidebar.js"></script>
1929
2083
  <meta name="viewport" content="width=device-width, initial-scale=1">
1930
2084
  <title>${escapeHtml(title)}</title>
1931
2085
  ${head}
1932
2086
  <style>
1933
2087
  :root {
1934
- color-scheme: light dark;
2088
+ color-scheme: light;
1935
2089
  --bg: #f5f6f8;
1936
2090
  --grid: rgba(31, 58, 99, 0.05);
1937
2091
  --panel: #ffffff;
@@ -1951,24 +2105,14 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
1951
2105
  --sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans KR", sans-serif;
1952
2106
  --mono: ui-monospace, "SFMono-Regular", "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
1953
2107
  }
2108
+ :root[data-theme="dark"] {
2109
+ color-scheme: dark;
2110
+ ${darkThemeVariables}
2111
+ }
1954
2112
  @media (prefers-color-scheme: dark) {
1955
- :root {
1956
- --bg: #15171b;
1957
- --grid: rgba(120, 160, 200, 0.06);
1958
- --panel: #1c1f24;
1959
- --panel-2: #25292f;
1960
- --text: #e6edf3;
1961
- --muted: #adb5c0;
1962
- --faint: #9aa5b2;
1963
- --line: #343b44;
1964
- --line-2: #596575;
1965
- --accent: #9cbcf5;
1966
- --accent-2: #bbd1f8;
1967
- --accent-ink: #18253d;
1968
- --accent-soft: #29374e;
1969
- --ring: #9cbcf5;
1970
- --code: #080c12;
1971
- --code-text: #e6edf3;
2113
+ :root:not([data-theme="light"]) {
2114
+ color-scheme: dark;
2115
+ ${darkThemeVariables}
1972
2116
  }
1973
2117
  }
1974
2118
  * { box-sizing: border-box; }
@@ -2051,24 +2195,10 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2051
2195
  color: var(--text);
2052
2196
  text-decoration: none;
2053
2197
  }
2054
- .language-switcher {
2055
- display: inline-flex;
2056
- gap: 2px;
2057
- margin-left: 5px;
2058
- padding-left: 9px;
2059
- border-left: 1px solid var(--line);
2060
- }
2061
- .language-switcher a,
2062
- .language-switcher .active {
2063
- padding: 5px 8px;
2064
- border-radius: 7px;
2065
- }
2066
- .language-switcher .active {
2067
- color: var(--text);
2068
- font-weight: 700;
2069
- text-decoration: none;
2070
- cursor: default;
2071
- }
2198
+ .language-switcher { display: grid; gap: 8px; min-width: 0; }
2199
+ .language-switcher label { display: grid; gap: 6px; min-width: 0; }
2200
+ .language-switcher select { width: 100%; min-width: 0; max-width: 100%; }
2201
+ .language-switcher button { justify-self: start; }
2072
2202
  .dashboard,
2073
2203
  .artifact-view,
2074
2204
  .artifact-editor {
@@ -2219,12 +2349,19 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2219
2349
  background: var(--panel);
2220
2350
  box-shadow: none;
2221
2351
  }
2352
+ .dashboard-pagination { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; margin-top: 16px; }
2353
+ .page-size-form, .page-size-form label { display: flex; align-items: center; gap: 8px; }
2354
+ .page-size-form { margin: 0; }
2355
+ .page-size-form select { min-width: 76px; width: auto; min-height: 44px; }
2356
+ .page-size-form button { min-height: 44px; }
2357
+ .pager-position { align-self: center; white-space: nowrap; }
2222
2358
  .pager {
2223
2359
  display: flex;
2224
- justify-content: flex-end;
2360
+ flex-wrap: wrap;
2361
+ justify-content: flex-start;
2225
2362
  gap: 8px;
2226
- margin-top: 14px;
2227
- font-family: var(--mono);
2363
+ margin-top: 0;
2364
+ font-family: var(--sans);
2228
2365
  font-size: 12.5px;
2229
2366
  }
2230
2367
  .pager a,
@@ -2349,11 +2486,12 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2349
2486
  font-size: 13px;
2350
2487
  }
2351
2488
  .field > span {
2352
- font-family: var(--mono);
2353
- font-size: 11.5px;
2489
+ font-family: var(--sans);
2490
+ font-size: 12px;
2491
+ font-weight: 500;
2354
2492
  letter-spacing: 0;
2355
2493
  text-transform: none;
2356
- color: var(--faint);
2494
+ color: var(--muted);
2357
2495
  }
2358
2496
  .check-field {
2359
2497
  display: flex;
@@ -2368,8 +2506,6 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2368
2506
  min-height: 0;
2369
2507
  padding: 0;
2370
2508
  }
2371
- input:not([type="checkbox"]):not([type="radio"]),
2372
- select,
2373
2509
  textarea {
2374
2510
  width: 100%;
2375
2511
  border: 1px solid var(--line-2);
@@ -2455,60 +2591,13 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2455
2591
  border: 0;
2456
2592
  background: var(--panel);
2457
2593
  }
2458
- input:focus,
2459
- select:focus,
2460
- textarea:focus {
2461
- outline: 3px solid var(--ring);
2462
- outline-offset: 0;
2463
- border-color: var(--accent);
2464
- }
2594
+ 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; }
2465
2595
  .editor-actions {
2466
2596
  display: flex;
2467
2597
  align-items: center;
2468
2598
  justify-content: flex-end;
2469
2599
  gap: 12px;
2470
2600
  }
2471
- button {
2472
- min-height: 38px;
2473
- border: 1px solid var(--accent);
2474
- border-radius: 8px;
2475
- padding: 7px 15px;
2476
- background: var(--accent);
2477
- color: var(--accent-ink);
2478
- font: inherit;
2479
- font-weight: 600;
2480
- cursor: pointer;
2481
- transition: background 0.15s ease, border-color 0.15s ease;
2482
- }
2483
- button:hover { background: var(--accent-2); border-color: var(--accent-2); }
2484
- .button-link {
2485
- display: inline-flex;
2486
- min-height: 38px;
2487
- align-items: center;
2488
- justify-content: center;
2489
- padding: 7px 15px;
2490
- border: 1px solid var(--accent);
2491
- border-radius: 8px;
2492
- background: var(--accent);
2493
- color: var(--accent-ink);
2494
- font-weight: 600;
2495
- }
2496
- .button-link:hover {
2497
- border-color: var(--accent-2);
2498
- background: var(--accent-2);
2499
- color: var(--accent-ink);
2500
- text-decoration: none;
2501
- }
2502
- .inline-action button {
2503
- min-height: 32px;
2504
- padding: 5px 12px;
2505
- font-family: var(--mono);
2506
- font-size: 12px;
2507
- font-weight: 500;
2508
- border-color: var(--line-2);
2509
- background: var(--panel);
2510
- color: var(--muted);
2511
- }
2512
2601
  .inline-action button:hover {
2513
2602
  background: var(--panel-2);
2514
2603
  border-color: var(--accent);
@@ -3179,6 +3268,7 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
3179
3268
  .meta-card table { display: block; max-width: 100%; overflow-x: auto; }
3180
3269
  .artifact-doc { font-size: 15px; line-height: 1.7; }
3181
3270
  .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; }
3271
+ .workspace-brand-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
3182
3272
  .workspace-brand { display: flex; align-items: center; gap: 10px; color: var(--text); font-size: 19px; font-weight: 700; letter-spacing: -.04em; }
3183
3273
  .workspace-brand svg { width: 27px; height: 27px; color: var(--accent); }
3184
3274
  .workspace-desktop { display: none; }
@@ -3188,13 +3278,13 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
3188
3278
  .workspace-mobile nav { padding-top: 16px; }
3189
3279
  .workspace-primary, .workspace-actions { display: grid; gap: 4px; }
3190
3280
  .workspace-primary { padding-bottom: 16px; border-bottom: 1px solid var(--line); margin-bottom: 16px; }
3191
- .workspace-header nav a, .workspace-dropdown summary, .workspace-dropdown .nav-form button { display: block; min-height: 38px; padding: 8px 10px; color: var(--muted); border-radius: 6px; font-size: 14px; text-align: left; }
3281
+ .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; }
3192
3282
  .workspace-header nav a:hover, .workspace-dropdown summary:hover { background: var(--panel-2); color: var(--text); text-decoration: none; }
3193
3283
  .workspace-header [aria-current="page"], .workspace-header .is-current { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
3194
3284
  .workspace-dropdown summary { cursor: pointer; list-style: none; }
3195
3285
  .workspace-dropdown summary::-webkit-details-marker { display: none; }
3196
- .workspace-dropdown summary::after { content: "⌄"; float: right; margin-left: 8px; }
3197
- .workspace-dropdown[open] > summary::after { content: "⌃"; }
3286
+ .workspace-chevron { margin-left: auto; display: inline-flex; }
3287
+ .workspace-dropdown[open] > summary .workspace-chevron { transform: rotate(180deg); }
3198
3288
  .workspace-version { color: var(--faint); font-size: 12px; padding: 10px; }
3199
3289
  .settings-page { max-width: 1100px; }
3200
3290
  .settings-page .meta-card { gap: 16px; }
@@ -3204,11 +3294,56 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
3204
3294
  .settings-page .field { min-width: 0; }
3205
3295
  .settings-page textarea[name="allowedCidrs"] { min-height: 120px; height: 140px; }
3206
3296
  .settings-page .editor-form { gap: 12px; }
3297
+ .settings-page .user-role-form { display: flex; flex-wrap: nowrap; gap: 8px; align-items: center; }
3298
+ .user-account-actions { display: grid; gap: 8px; justify-items: start; }
3299
+
3300
+ .user-role-form select { width: auto; min-width: 105px; }
3301
+ .user-role-form select, .user-role-form button { margin: 0; }
3302
+
3303
+ .meta-card:has(> form[action="/admin/retention/run"]) { min-width: 0; overflow-wrap: anywhere; }
3304
+ form[action="/admin/retention/run"], form[action="/admin/retention/run"] > * { min-width: 0; }
3305
+ form[action="/admin/retention/run"] .checkbox-field { display: block; white-space: normal; overflow-wrap: anywhere; line-height: 1.6; }
3306
+ form[action="/admin/retention/run"] .checkbox-field input { margin-right: 6px; vertical-align: middle; }
3307
+ .retention-policy-form .retention-section { display: grid; gap: 16px; min-width: 0; margin: 0; padding: 16px; border: 1px solid var(--line); border-radius: 6px; }
3308
+ .retention-policy-form legend { padding: 0 6px; font-weight: 600; }
3309
+ .smtp-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; align-items: start; }
3310
+ .smtp-settings-form input, .smtp-settings-form select { min-width: 0; }
3311
+ .smtp-settings-form .field { align-content: start; }
3312
+ @media (max-width: 640px) { .smtp-field-grid { grid-template-columns: minmax(0, 1fr); } }
3313
+ .retention-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; gap: 16px; }
3314
+ .retention-policy-form .field { display: grid; gap: 8px; align-content: start; }
3315
+ .retention-policy-form input, .retention-policy-form textarea { width: 100%; min-width: 0; box-sizing: border-box; }
3316
+ .retention-policy-form textarea { min-height: 112px; height: 112px; }
3317
+ @media (max-width: 640px) { .retention-field-grid { grid-template-columns: minmax(0, 1fr); } }
3318
+
3207
3319
  .workspace-dropdown-content { display: grid; padding-left: 10px; border-left: 1px solid var(--line); margin: 4px 0 8px 10px; }
3208
3320
  .workspace-dropdown-content .nav-form { display: block; }
3209
3321
  .workspace-identity { overflow-wrap: anywhere; font-size: 12px; color: var(--faint); padding: 8px 10px; }
3210
- .workspace-header .language-switcher { border: 0; margin: 12px 0 0; padding: 0; display: flex; flex-wrap: wrap; font-size: 12px; }
3211
- .workspace-header .language-switcher a, .workspace-header .language-switcher .active { min-height: 38px; padding: 8px 10px; }
3322
+ .theme-controls { border: 0; min-width: 0; margin: 16px 10px 0; padding: 0; }
3323
+ .theme-controls legend { color: var(--muted); font-size: 12px; margin-bottom: 6px; }
3324
+ .theme-choices { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); padding: 3px; gap: 2px; background: var(--panel-2); border-radius: 8px; }
3325
+ .theme-choices .btn { padding: 6px; min-width: 0; border: 0; display: inline-flex; justify-content: center; align-items: center; background: transparent; color: var(--muted); }
3326
+ .theme-choices .btn[aria-pressed="true"] { background: var(--panel); color: var(--accent); box-shadow: 0 1px 3px #0002; }
3327
+ .workspace-header svg { flex-shrink: 0; }
3328
+ .workspace-header .sidebar-toggle { display: none; }
3329
+ .sidebar-toggle > span:not([hidden]) { display: flex; align-items: center; }
3330
+ .workspace-brand-row svg { display: block; }
3331
+ @media (min-width: 1100px) {
3332
+ .sidebar-toggle:not([hidden]) { display: inline-flex; position: static; width: 36px; height: 36px; padding: 0; flex-shrink: 0; }
3333
+ .workspace-desktop { padding-top: 0; }
3334
+ :root[data-sidebar="collapsed"] body:has(> .workspace-header) { padding-left: 68px; }
3335
+ :root[data-sidebar="collapsed"] .workspace-header { width: 68px; padding: 24px 10px; }
3336
+ :root[data-sidebar="collapsed"] .workspace-brand { display: none; }
3337
+ :root[data-sidebar="collapsed"] .workspace-brand-row { justify-content: center; }
3338
+
3339
+ :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; }
3340
+ :root[data-sidebar="collapsed"] .workspace-dropdown-content { padding-left: 0; border: 0; margin-left: 0; }
3341
+ :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; }
3342
+ :root[data-sidebar="collapsed"] .workspace-header [aria-current="page"] { box-shadow: inset 3px 0 var(--accent); }
3343
+ :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; }
3344
+ }
3345
+ .workspace-header .language-switcher { margin: 12px 0 0; padding: 0 10px; font-size: 12px; }
3346
+
3212
3347
  .workspace-skip { position: absolute; left: 16px; top: -100px; z-index: 100; background: var(--panel); padding: 8px 16px; }
3213
3348
  .workspace-skip:focus { top: 8px; }
3214
3349
  :focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; }
@@ -3253,7 +3388,8 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
3253
3388
  @media (min-width: 1100px) {
3254
3389
  body:has(> .workspace-header) { padding-left: 196px; }
3255
3390
  .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; }
3256
- .workspace-brand { padding: 0 10px 26px; }
3391
+ .workspace-brand-row { min-height: 36px; margin-bottom: 18px; }
3392
+ .workspace-brand { padding: 0 0 0 10px; }
3257
3393
  .workspace-desktop { display: block; }
3258
3394
  .workspace-mobile { display: none; }
3259
3395
  }
@@ -3281,6 +3417,7 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
3281
3417
  .dashboard .row-main strong { font-size: 14px; }
3282
3418
  }
3283
3419
  </style>
3420
+ <link rel="stylesheet" href="/assets/ui-controls.css">
3284
3421
  </head>
3285
3422
  <body>
3286
3423
  ${body}
@@ -3290,7 +3427,7 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
3290
3427
  }
3291
3428
 
3292
3429
  function formatSelect(selected) {
3293
- return `<select name="format">
3430
+ return `<select name="format" class="form-select">
3294
3431
  ${ARTIFACT_FORMATS.map((format) => {
3295
3432
  const label = format[0].toUpperCase() + format.slice(1);
3296
3433
  return `<option value="${format}"${format === selected ? " selected" : ""}>${label}</option>`;
@@ -3299,13 +3436,13 @@ function formatSelect(selected) {
3299
3436
  }
3300
3437
 
3301
3438
  function artifactTypeSelect(selected) {
3302
- return `<select name="artifactType">
3439
+ return `<select name="artifactType" class="form-select">
3303
3440
  ${artifactTypeOptions(selected)}
3304
3441
  </select>`;
3305
3442
  }
3306
3443
 
3307
3444
  function visibilitySelect(selected, view) {
3308
- return `<select name="visibility">
3445
+ return `<select name="visibility" class="form-select">
3309
3446
  ${VISIBILITY_VALUES.map((value) => `<option value="${value}"${value === selected ? " selected" : ""}>${view.text(`artifact.visibility.${value}`)}</option>`).join("")}
3310
3447
  </select>`;
3311
3448
  }
@@ -3333,6 +3470,13 @@ function editorScript(locale) {
3333
3470
  return `<script>window.ARTIFACTY_I18N=${config};</script><script type="module" src="${EDITOR_CLIENT_PATH}"></script>`;
3334
3471
  }
3335
3472
 
3473
+ function renderDateTime(value, view) {
3474
+ const date = new Date(value);
3475
+ if (!Number.isFinite(date.getTime())) return escapeHtml(value);
3476
+ const label = new Intl.DateTimeFormat(view.locale, { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" }).format(date);
3477
+ return `<time datetime="${escapeAttribute(value)}" title="${escapeAttribute(value)}">${escapeHtml(label)} UTC</time>`;
3478
+ }
3479
+
3336
3480
  function viewContext(locale, currentPath) {
3337
3481
  const i18n = createI18n(locale);
3338
3482
  return {
@@ -3394,13 +3538,11 @@ function statusBadge(label) {
3394
3538
  return `<span class="badge s-archived">${label}</span>`;
3395
3539
  }
3396
3540
 
3397
- // Only rendered for 'private' artifacts (the non-default visibility) so the
3398
- // common case doesn't add visual noise to every row.
3541
+ // Highlight explicit public/private visibility while keeping default team rows compact.
3399
3542
  function visibilityBadge(artifact, view) {
3400
- if ((artifact.visibility || "team") !== "private") {
3401
- return "";
3402
- }
3403
- return `<span class="badge v-private">${view.text("artifact.visibility.private")}</span>`;
3543
+ const visibility = artifact.visibility || "team";
3544
+ if (!["private", "public"].includes(visibility)) return "";
3545
+ return `<span class="badge v-${visibility}">${view.text(`artifact.visibility.${visibility}`)}</span>`;
3404
3546
  }
3405
3547
 
3406
3548
  function htmlFrameContent(content) {
@@ -3494,37 +3636,50 @@ function frameResizeScript() {
3494
3636
  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>`;
3495
3637
  }
3496
3638
 
3497
- function viewerScript() {
3498
- return `<script type="module" src="${VIEWER_CLIENT_PATH}"></script>`;
3639
+ function viewerScript(locale = DEFAULT_LOCALE) {
3640
+ const view = viewContext(locale, "/");
3641
+ const messages = Object.fromEntries(["filter", "filterColumn", "ruleFilter", "rowsShown", "resultsShown"].map((key) => [key, view.raw(`viewer.${key}`, { column: "{column}", visible: "{visible}", total: "{total}" })]));
3642
+ const config = JSON.stringify(messages).replaceAll("<", "\\u003c");
3643
+ return `<script type="application/json" id="artifacty-viewer-i18n">${config}</script><script type="module" src="${VIEWER_CLIENT_PATH}"></script>`;
3499
3644
  }
3500
3645
 
3501
3646
  function reactRendererEnabled() {
3502
3647
  return process.env.ARTIFACTY_ENABLE_REACT_RENDERER === "true";
3503
3648
  }
3504
3649
 
3650
+ function themeControls(view) {
3651
+ return `<fieldset class="theme-controls" data-theme-controls hidden><legend>${view.text("theme.label")}</legend>
3652
+ <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>
3653
+ </fieldset>`;
3654
+ }
3655
+
3505
3656
  function languageSwitcher(view) {
3506
- const english = view.locale === "en"
3507
- ? `<span class="active">${view.text("language.english")}</span>`
3508
- : `<a href="${switchLocaleHref(view.currentPath, "en")}">${view.text("language.english")}</a>`;
3509
- const korean = view.locale === "ko"
3510
- ? `<span class="active">${view.text("language.korean")}</span>`
3511
- : `<a href="${switchLocaleHref(view.currentPath, "ko")}">${view.text("language.korean")}</a>`;
3512
- return `<span class="language-switcher">${english}${korean}</span>`;
3657
+ const current = new URL(view.currentPath || "/", "http://artifacty.local");
3658
+ const preserved = [...current.searchParams.entries()].filter(([name]) => name !== "lang")
3659
+ .map(([name, value]) => `<input type="hidden" name="${escapeAttribute(name)}" value="${escapeAttribute(value)}">`).join("");
3660
+ const options = availableLanguages().filter((language) => language.enabled).map((language) =>
3661
+ `<option value="${escapeAttribute(language.locale)}"${language.locale === view.locale ? " selected" : ""}>${escapeHtml(language.nativeName)}</option>`
3662
+ ).join("");
3663
+ return `<form class="language-switcher" data-auto-apply="all" method="get" action="${escapeAttribute(`${current.pathname}${current.hash}`)}">
3664
+ ${preserved}<label><span>${view.text("language.label")}</span><select name="lang" class="form-select">${options}</select></label>
3665
+ <button type="submit" class="btn btn-outline-secondary">${view.text("language.apply")}</button>
3666
+ </form>`;
3513
3667
  }
3514
3668
 
3515
3669
  function workspaceHeader(user, view) {
3516
3670
  const pathname = new URL(view.currentPath, "http://artifacty.local").pathname;
3517
- const link = (href, key, active = pathname === href, className = "") => `<a href="${view.href(href)}"${active ? ' aria-current="page"' : ""}${className ? ` class="${className}"` : ""}>${view.text(key)}</a>`;
3671
+ 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" };
3672
+ 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>`;
3518
3673
  const artifactActive = pathname === "/" || pathname.startsWith("/artifacts/");
3519
- const adminLinks = user?.role === "admin" ? `<details class="workspace-dropdown"${pathname.startsWith("/admin/") ? " open" : ""}><summary${pathname.startsWith("/admin/") ? ' class="is-current"' : ""}>${view.text("nav.administration")}</summary><div class="workspace-dropdown-content">
3520
- ${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")}<span class="workspace-version">Artifacty v${escapeHtml(APP_VERSION)}</span>
3674
+ 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">
3675
+ ${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>
3521
3676
  </div></details>` : "";
3522
- const account = user ? `<details class="workspace-dropdown"${pathname.startsWith("/account") ? " open" : ""}><summary${pathname.startsWith("/account") ? ' class="is-current"' : ""}>${view.text("nav.account")}</summary><div class="workspace-dropdown-content">
3677
+ 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">
3523
3678
  <span class="workspace-identity">${escapeHtml(user.name || user.email || "")}</span>${link("/account", "nav.accountTokens")}${link("/account/password", "nav.password")}
3524
- <form method="post" action="/logout" class="nav-form">${localeInput(view.locale)}<button type="submit">${view.text("nav.signOut")}</button></form>
3679
+ <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>
3525
3680
  </div></details>` : user === null ? link("/login", "nav.signIn") : "";
3526
- 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)}</div>`;
3527
- return `<a class="workspace-skip" href="#main-content">${view.text("nav.skip")}</a><header class="workspace-header"><a class="workspace-brand" href="${view.href("/")}">${brandMark()}<span>artifacty</span></a>
3681
+ 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>`;
3682
+ 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>
3528
3683
  <nav class="workspace-desktop" aria-label="${view.attr("dashboard.navigation")}">${links}</nav>
3529
3684
  <details class="workspace-mobile"><summary>${view.text("dashboard.more")}</summary><nav aria-label="${view.attr("dashboard.navigation")}">${links}</nav></details>
3530
3685
  </header>`;
@@ -3634,7 +3789,7 @@ function markdownToHtml(markdown, diagramState = createMermaidDiagramState()) {
3634
3789
  inList = true;
3635
3790
  }
3636
3791
  const checked = taskItem[1].toLowerCase() === "x";
3637
- html.push(`<li class="task-list-item"><label><input type="checkbox" disabled${checked ? " checked" : ""}> ${inlineMarkdown(taskItem[2])}</label></li>`);
3792
+ html.push(`<li class="task-list-item"><label><input type="checkbox" disabled${checked ? " checked" : ""} class="form-check-input"> ${inlineMarkdown(taskItem[2])}</label></li>`);
3638
3793
  continue;
3639
3794
  }
3640
3795
 
@@ -3769,19 +3924,20 @@ const CSV_RENDER_COLUMN_LIMIT = 80;
3769
3924
  const SARIF_RENDER_RESULT_LIMIT = 500;
3770
3925
 
3771
3926
  function renderMediaArtifact(format, content, metadata = {}, options = {}) {
3927
+ const view = options.view || viewContext(options.locale || DEFAULT_LOCALE, "/");
3772
3928
  const mimeType = mediaMimeType(metadata, format);
3773
3929
  const source = options.rawUrl || mediaDataUrl(content, mimeType);
3774
3930
  if (!source) {
3775
3931
  return `<section class="artifact-media-note">
3776
- Media preview is unavailable because this artifact does not contain valid base64 media content.
3932
+ ${view.text("media.unavailable")}
3777
3933
  </section>
3778
3934
  <pre class="artifact-code"><code>${escapeHtml(content)}</code></pre>`;
3779
3935
  }
3780
3936
 
3781
3937
  const label = [
3782
3938
  mimeType || format,
3783
- metadata.encoding ? `encoding: ${metadata.encoding}` : "",
3784
- metadata.originalEncoding ? `source: ${metadata.originalEncoding}` : ""
3939
+ metadata.encoding ? view.raw("media.encoding", { encoding: metadata.encoding }) : "",
3940
+ metadata.originalEncoding ? view.raw("media.source", { encoding: metadata.originalEncoding }) : ""
3785
3941
  ].filter(Boolean).join(" · ");
3786
3942
 
3787
3943
  if (format === "video") {
@@ -3792,7 +3948,7 @@ function renderMediaArtifact(format, content, metadata = {}, options = {}) {
3792
3948
  }
3793
3949
 
3794
3950
  return `<figure class="artifact-media artifact-image">
3795
- <img src="${escapeAttribute(source)}" alt="Image artifact">
3951
+ <img src="${escapeAttribute(source)}" alt="${view.text("media.image")}">
3796
3952
  <figcaption>${escapeHtml(label || "image artifact")}</figcaption>
3797
3953
  </figure>`;
3798
3954
  }
@@ -3817,17 +3973,17 @@ function mediaDataUrl(content, mimeType) {
3817
3973
  return `data:${mimeType};base64,${base64}`;
3818
3974
  }
3819
3975
 
3820
- function renderCsv(content) {
3976
+ function renderCsv(content, view) {
3821
3977
  const parsed = parseCsv(content);
3822
3978
  if (!parsed.ok) {
3823
3979
  return `<section class="artifact-csv">
3824
- <p class="artifact-csv-note">CSV parsing failed: ${escapeHtml(parsed.error)}. Showing escaped source.</p>
3980
+ <p class="artifact-csv-note">${view.text("csv.error", { error: parsed.error })}</p>
3825
3981
  <pre class="artifact-code"><code>${escapeHtml(content)}</code></pre>
3826
3982
  </section>`;
3827
3983
  }
3828
3984
 
3829
3985
  if (parsed.rows.length === 0) {
3830
- return `<section class="artifact-csv"><p class="artifact-csv-note">Empty CSV artifact.</p></section>`;
3986
+ return `<section class="artifact-csv"><p class="artifact-csv-note">${view.text("csv.empty")}</p></section>`;
3831
3987
  }
3832
3988
 
3833
3989
  const columnCount = Math.max(...parsed.rows.map((row) => row.length));
@@ -3837,7 +3993,7 @@ function renderCsv(content) {
3837
3993
  const rowTruncated = parsed.rows.length - 1 > CSV_RENDER_ROW_LIMIT;
3838
3994
  const columnTruncated = columnCount > CSV_RENDER_COLUMN_LIMIT;
3839
3995
  const headerHtml = Array.from({ length: visibleColumns }, (_, index) =>
3840
- `<th data-csv-col="${index}">${escapeHtml(header[index] || `Column ${index + 1}`)}</th>`
3996
+ `<th data-csv-col="${index}">${escapeHtml(header[index] || view.raw("csv.column", { column: index + 1 }))}</th>`
3841
3997
  ).join("");
3842
3998
  const bodyHtml = bodyRows.map((row) =>
3843
3999
  `<tr data-csv-row>${Array.from({ length: visibleColumns }, (_, index) =>
@@ -3845,10 +4001,10 @@ function renderCsv(content) {
3845
4001
  ).join("")}</tr>`
3846
4002
  ).join("\n");
3847
4003
  const notes = [
3848
- `${parsed.rows.length - 1} data rows`,
3849
- `${columnCount} columns`,
3850
- rowTruncated ? `showing first ${CSV_RENDER_ROW_LIMIT} rows` : "",
3851
- columnTruncated ? `showing first ${CSV_RENDER_COLUMN_LIMIT} columns` : ""
4004
+ view.raw("csv.rows", { count: parsed.rows.length - 1 }),
4005
+ view.raw("csv.columns", { count: columnCount }),
4006
+ rowTruncated ? view.raw("csv.firstRows", { count: CSV_RENDER_ROW_LIMIT }) : "",
4007
+ columnTruncated ? view.raw("csv.firstColumns", { count: CSV_RENDER_COLUMN_LIMIT }) : ""
3852
4008
  ].filter(Boolean).join(" · ");
3853
4009
 
3854
4010
  return `<section class="artifact-csv" data-artifact-csv>
@@ -3862,20 +4018,20 @@ function renderCsv(content) {
3862
4018
  </section>`;
3863
4019
  }
3864
4020
 
3865
- function renderSarif(content) {
4021
+ function renderSarif(content, view) {
3866
4022
  let sarif;
3867
4023
  try {
3868
4024
  sarif = JSON.parse(content);
3869
4025
  } catch {
3870
4026
  return `<section class="artifact-sarif">
3871
- <p class="artifact-sarif-note">Invalid SARIF JSON. Showing escaped source.</p>
4027
+ <p class="artifact-sarif-note">${view.text("sarif.invalid")}</p>
3872
4028
  <pre class="artifact-code"><code>${escapeHtml(content)}</code></pre>
3873
4029
  </section>`;
3874
4030
  }
3875
4031
 
3876
4032
  if (!isSarifDocument(sarif)) {
3877
4033
  return `<section class="artifact-sarif">
3878
- <p class="artifact-sarif-note">This JSON does not match the expected SARIF top-level shape. Showing formatted JSON.</p>
4034
+ <p class="artifact-sarif-note">${view.text("sarif.shape")}</p>
3879
4035
  <pre class="artifact-code"><code>${escapeHtml(formatJson(content))}</code></pre>
3880
4036
  </section>`;
3881
4037
  }
@@ -3891,26 +4047,26 @@ function renderSarif(content) {
3891
4047
  <td>${escapeHtml(result.tool)}</td>
3892
4048
  </tr>`).join("\n");
3893
4049
  const note = results.length > SARIF_RENDER_RESULT_LIMIT
3894
- ? `Showing first ${SARIF_RENDER_RESULT_LIMIT} of ${results.length} results.`
3895
- : `${results.length} results.`;
4050
+ ? view.raw("sarif.first", { count: SARIF_RENDER_RESULT_LIMIT, total: results.length })
4051
+ : view.raw("sarif.results", { count: results.length });
3896
4052
 
3897
4053
  return `<section class="artifact-sarif" data-artifact-sarif>
3898
4054
  <div class="artifact-summary-grid">
3899
- ${summaryCard("Runs", sarif.runs.length)}
3900
- ${summaryCard("Results", results.length)}
3901
- ${summaryCard("Errors", counts.error || 0)}
3902
- ${summaryCard("Warnings", counts.warning || 0)}
3903
- ${summaryCard("Notes", counts.note || 0)}
4055
+ ${summaryCard(view.raw("sarif.runs"), sarif.runs.length)}
4056
+ ${summaryCard(view.raw("sarif.resultsTitle"), results.length)}
4057
+ ${summaryCard(view.raw("sarif.errors"), counts.error || 0)}
4058
+ ${summaryCard(view.raw("sarif.warnings"), counts.warning || 0)}
4059
+ ${summaryCard(view.raw("sarif.notes"), counts.note || 0)}
3904
4060
  </div>
3905
4061
  <p class="artifact-sarif-note">${escapeHtml(note)}</p>
3906
4062
  <div class="artifact-table-scroll">
3907
4063
  <table class="artifact-table" data-sarif-table>
3908
- <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>
4064
+ <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>
3909
4065
  <tbody>${rowHtml}</tbody>
3910
4066
  </table>
3911
4067
  </div>
3912
4068
  <details class="artifact-raw-details">
3913
- <summary>Raw SARIF JSON</summary>
4069
+ <summary>${view.text("sarif.raw")}</summary>
3914
4070
  <pre class="artifact-code"><code>${escapeHtml(formatJson(content))}</code></pre>
3915
4071
  </details>
3916
4072
  </section>`;
@@ -3991,7 +4147,7 @@ const NOTEBOOK_OUTPUT_MIME_PRIORITY = [
3991
4147
  "text/plain"
3992
4148
  ];
3993
4149
 
3994
- function renderNotebook(content) {
4150
+ function renderNotebook(content, view) {
3995
4151
  let notebook;
3996
4152
  try {
3997
4153
  notebook = JSON.parse(content);
@@ -4006,16 +4162,16 @@ function renderNotebook(content) {
4006
4162
  const diagramState = createMermaidDiagramState();
4007
4163
  const cells = notebook.cells;
4008
4164
  const rendered = cells.slice(0, NOTEBOOK_MAX_RENDERED_CELLS)
4009
- .map((cell) => renderNotebookCell(cell, language, diagramState))
4165
+ .map((cell) => renderNotebookCell(cell, language, diagramState, view))
4010
4166
  .join("");
4011
4167
  const notice = cells.length > NOTEBOOK_MAX_RENDERED_CELLS
4012
- ? `<p class="artifact-notebook-notice">Showing the first ${NOTEBOOK_MAX_RENDERED_CELLS} of ${cells.length} cells.</p>`
4168
+ ? `<p class="artifact-notebook-notice">${view.text("notebook.cells", { count: NOTEBOOK_MAX_RENDERED_CELLS, total: cells.length })}</p>`
4013
4169
  : "";
4014
4170
 
4015
4171
  return `<article class="artifact-doc artifact-notebook">${notice}${rendered}</article>`;
4016
4172
  }
4017
4173
 
4018
- function renderNotebookCell(cell, language, diagramState) {
4174
+ function renderNotebookCell(cell, language, diagramState, view) {
4019
4175
  const cellType = cell?.cell_type;
4020
4176
  const source = notebookText(cell?.source);
4021
4177
 
@@ -4034,9 +4190,9 @@ function renderNotebookCell(cell, language, diagramState) {
4034
4190
  const cellOutputs = Array.isArray(cell?.outputs) ? cell.outputs : [];
4035
4191
  const visibleOutputs = cellOutputs.slice(0, NOTEBOOK_MAX_OUTPUTS_PER_CELL);
4036
4192
  const outputsNotice = cellOutputs.length > NOTEBOOK_MAX_OUTPUTS_PER_CELL
4037
- ? `<p class="artifact-notebook-notice">Showing the first ${NOTEBOOK_MAX_OUTPUTS_PER_CELL} of ${cellOutputs.length} outputs.</p>`
4193
+ ? `<p class="artifact-notebook-notice">${view.text("notebook.outputs", { count: NOTEBOOK_MAX_OUTPUTS_PER_CELL, total: cellOutputs.length })}</p>`
4038
4194
  : "";
4039
- const outputs = visibleOutputs.map((output) => renderNotebookOutput(output, diagramState)).join("") + outputsNotice;
4195
+ const outputs = visibleOutputs.map((output) => renderNotebookOutput(output, diagramState, view)).join("") + outputsNotice;
4040
4196
  return `<section class="artifact-notebook-cell artifact-notebook-cell-code">
4041
4197
  <div class="artifact-notebook-execcount">${escapeHtml(executionLabel)}</div>
4042
4198
  <pre><code${classAttribute}>${escapeHtml(source)}</code></pre>
@@ -4055,11 +4211,11 @@ function stripAnsiEscapes(value) {
4055
4211
  return String(value ?? "").replace(ANSI_ESCAPE_PATTERN, "");
4056
4212
  }
4057
4213
 
4058
- function renderNotebookOutput(output, diagramState) {
4214
+ function renderNotebookOutput(output, diagramState, view) {
4059
4215
  const outputType = output?.output_type;
4060
4216
 
4061
4217
  if (outputType === "stream") {
4062
- return renderNotebookTextOutput(notebookText(output.text));
4218
+ return renderNotebookTextOutput(notebookText(output.text), "", view);
4063
4219
  }
4064
4220
 
4065
4221
  if (outputType === "error") {
@@ -4073,18 +4229,18 @@ function renderNotebookOutput(output, diagramState) {
4073
4229
  : notebookText(output.traceback);
4074
4230
  const traceback = stripAnsiEscapes(rawTraceback) ||
4075
4231
  `${notebookText(output.ename)}: ${notebookText(output.evalue)}`;
4076
- return renderNotebookTextOutput(traceback, "artifact-notebook-error");
4232
+ return renderNotebookTextOutput(traceback, "artifact-notebook-error", view);
4077
4233
  }
4078
4234
 
4079
4235
  if (outputType === "execute_result" || outputType === "display_data") {
4080
- return renderNotebookMimeBundle(output.data, diagramState);
4236
+ return renderNotebookMimeBundle(output.data, diagramState, view);
4081
4237
  }
4082
4238
 
4083
4239
  return "";
4084
4240
  }
4085
4241
 
4086
- function renderNotebookTextOutput(text, extraClass = "") {
4087
- const truncated = notebookTruncateIfOversized(text);
4242
+ function renderNotebookTextOutput(text, extraClass = "", view) {
4243
+ const truncated = notebookTruncateIfOversized(text, view);
4088
4244
  if (truncated) {
4089
4245
  return truncated;
4090
4246
  }
@@ -4092,7 +4248,7 @@ function renderNotebookTextOutput(text, extraClass = "") {
4092
4248
  return `<pre class="artifact-notebook-output artifact-code${classAttribute}"><code>${escapeHtml(text)}</code></pre>`;
4093
4249
  }
4094
4250
 
4095
- function renderNotebookMimeBundle(data, diagramState) {
4251
+ function renderNotebookMimeBundle(data, diagramState, view) {
4096
4252
  const bundle = data && typeof data === "object" && !Array.isArray(data) ? data : {};
4097
4253
  const keys = Object.keys(bundle);
4098
4254
  if (keys.length === 0) {
@@ -4101,11 +4257,11 @@ function renderNotebookMimeBundle(data, diagramState) {
4101
4257
 
4102
4258
  const mime = NOTEBOOK_OUTPUT_MIME_PRIORITY.find((candidate) => keys.includes(candidate));
4103
4259
  if (!mime) {
4104
- return `<p class="artifact-notebook-output-placeholder">Unsupported output type: ${escapeHtml(keys[0])}</p>`;
4260
+ return `<p class="artifact-notebook-output-placeholder">${view.text("notebook.unsupported", { type: keys[0] })}</p>`;
4105
4261
  }
4106
4262
 
4107
4263
  const value = notebookText(bundle[mime]);
4108
- const truncated = notebookTruncateIfOversized(value);
4264
+ const truncated = notebookTruncateIfOversized(value, view);
4109
4265
  if (truncated) {
4110
4266
  return truncated;
4111
4267
  }
@@ -4119,9 +4275,9 @@ function renderNotebookMimeBundle(data, diagramState) {
4119
4275
  if (mime === "image/png" || mime === "image/jpeg") {
4120
4276
  const dataUrl = mediaDataUrl(value, mime);
4121
4277
  if (!dataUrl) {
4122
- return `<p class="artifact-notebook-output-placeholder">Unsupported output type: ${escapeHtml(mime)}</p>`;
4278
+ return `<p class="artifact-notebook-output-placeholder">${view.text("notebook.unsupported", { type: mime })}</p>`;
4123
4279
  }
4124
- return `<figure class="artifact-media artifact-image artifact-notebook-output"><img src="${escapeAttribute(dataUrl)}" alt="Notebook output image"></figure>`;
4280
+ return `<figure class="artifact-media artifact-image artifact-notebook-output"><img src="${escapeAttribute(dataUrl)}" alt="${view.text("notebook.image")}"></figure>`;
4125
4281
  }
4126
4282
  if (mime === "image/svg+xml") {
4127
4283
  return `<iframe class="artifact-frame artifact-svg-frame artifact-notebook-output" sandbox srcdoc="${escapeAttribute(svgFrameContent(value))}"></iframe>`;
@@ -4132,12 +4288,12 @@ function renderNotebookMimeBundle(data, diagramState) {
4132
4288
  return "";
4133
4289
  }
4134
4290
 
4135
- function notebookTruncateIfOversized(value) {
4291
+ function notebookTruncateIfOversized(value, view) {
4136
4292
  const size = Buffer.byteLength(String(value ?? ""), "utf8");
4137
4293
  if (size <= NOTEBOOK_MAX_OUTPUT_BYTES) {
4138
4294
  return null;
4139
4295
  }
4140
- return `<p class="artifact-notebook-output-truncated">Output truncated: ${escapeHtml(String(size))} bytes exceeds the 2 MB limit.</p>`;
4296
+ return `<p class="artifact-notebook-output-truncated">${view.text("notebook.truncated", { size })}</p>`;
4141
4297
  }
4142
4298
 
4143
4299
  function notebookText(value) {