artifacty 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/render.js CHANGED
@@ -1,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,40 @@ 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
+ <button type="submit" class="btn btn-primary">${view.text("access.saveNetwork")}</button>
796
904
  </form>
797
905
  </section>
798
906
  <section class="meta-card"><h2>${view.text("access.teams")}</h2>
799
907
  <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>
908
+ <label class="field"><span>${view.text("access.teamName")}</span><input name="name" required class="form-control"></label>
909
+ <button type="submit" class="btn btn-primary">${view.text("access.createTeam")}</button>
802
910
  </form>
803
911
  ${teams.length ? teams.map((team) => `<section><h3>${escapeHtml(team.name)}</h3>
804
912
  <form class="editor-form" method="post" action="/admin/teams/${encodeURIComponent(team.id)}/members">${localeInput(view.locale)}
805
913
  <fieldset><legend>${view.text("access.members")}</legend>${accessCheckboxes(users, "userIds", team.memberIds || [], view)}</fieldset>
806
- <button type="submit">${view.text("access.saveMembers")}</button>
914
+ <button type="submit" class="btn btn-primary">${view.text("access.saveMembers")}</button>
807
915
  </form></section>`).join("") : `<p class="muted">${view.text("access.noTeams")}</p>`}
808
916
  </section>
809
917
  <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>
918
+ ${["install", "remove"].map((action) => `<form method="post" action="/admin/samples/${action}">${localeInput(view.locale)}<button type="submit" class="btn btn-outline-secondary">${view.text(`access.samples.${action}`)}</button></form>`).join("")}</section>
811
919
  </main>`
812
920
  });
813
921
  }
@@ -825,7 +933,7 @@ export function renderArtifactSharingPage({ baseUrl, user, artifact, sharing = {
825
933
  <form class="editor-form" method="post" action="/artifacts/${encodeURIComponent(artifact.id)}/sharing">${localeInput(view.locale)}
826
934
  <fieldset><legend>${view.text("sharing.users")}</legend>${accessCheckboxes(users, "userIds", sharing.userIds || [], view, sharing.userPermissions || {})}</fieldset>
827
935
  <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>
936
+ <button type="submit" class="btn btn-outline-secondary">${view.text("sharing.save")}</button>
829
937
  </form>
830
938
  </section>
831
939
  ${accessReport && (user?.role === "admin" || user?.id === artifact.ownerUserId) ? `<section class="meta-card"><h2>${view.text("sharing.effective")}</h2><p>${view.text("sharing.networkCondition")}: ${escapeHtml(networkPolicy.allowedCidrs?.length ? networkPolicy.allowedCidrs.join(", ") : view.text("sharing.unrestricted"))}</p><p>${view.text("sharing.anonymous")}: ${view.text(accessReport.anonymousRead ? "sharing.allowed" : "sharing.denied")}</p>
@@ -833,17 +941,17 @@ export function renderArtifactSharingPage({ baseUrl, user, artifact, sharing = {
833
941
  <section class="meta-card"><h2>${view.text("sharing.links")}</h2><p>${view.text("sharing.linksHelp")}</p>
834
942
  ${createdShareLink?.token ? `<p>${view.text("sharing.copyNow")}</p><a href="${escapeAttribute(`${baseUrl.replace(/\/$/, "")}/s/${encodeURIComponent(createdShareLink.token)}`)}">${escapeHtml(`${baseUrl.replace(/\/$/, "")}/s/${encodeURIComponent(createdShareLink.token)}`)}</a>` : ""}
835
943
  <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("")}
944
+ <label class="field"><span>${view.text("sharing.version")}</span><select name="version" class="form-select"><option value="latest">${view.text("sharing.latest")}</option>${(artifact.versions || []).map((item) => `<option value="${Number(item.version)}">v${Number(item.version)}</option>`).join("")}</select></label>
945
+ <label class="field"><span>${view.text("sharing.days")}</span><input type="number" name="days" min="1" max="365" value="7" required class="form-control"></label><button type="submit" class="btn btn-outline-secondary">${view.text("sharing.createLink")}</button></form>
946
+ ${shareLinks.map((link) => `<section><p>${escapeHtml(link.id)} · ${link.version == null ? view.text("sharing.latest") : `v${Number(link.version)}`} · ${view.text("sharing.expires")}: ${renderDateTime(link.expiresAt, view)}</p>
947
+ ${link.revokedAt ? `<p>${view.text("sharing.revoked")}</p>` : `<form method="post" action="/artifacts/${encodeURIComponent(artifact.id)}/share-links/${encodeURIComponent(link.id)}/revoke">${localeInput(view.locale)}<button type="submit" class="btn btn-outline-danger">${view.text("sharing.revoke")}</button></form>`}</section>`).join("")}
840
948
  </section></main>`
841
949
  });
842
950
  }
843
951
 
844
952
  function accessCheckboxes(items, name, selectedIds, view, permissions = null) {
845
953
  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("");
954
+ return items.filter((item) => item.active !== false).map((item) => `<label><input type="checkbox" name="${name}" value="${escapeAttribute(item.id)}"${selectedIds.includes(item.id) ? " checked" : ""} class="form-check-input"> ${escapeHtml(item.name || item.email || item.id)}${item.email && item.name ? ` (${escapeHtml(item.email)})` : ""}</label>${permissions ? `<label>${view.text("sharing.permission")}<select name="${name === "userIds" ? "userPermission" : "teamPermission"}:${escapeAttribute(item.id)}" class="form-select">${["read", "comment", "edit"].map((permission) => `<option value="${permission}"${(permissions[item.id] || "read") === permission ? " selected" : ""}>${view.text(`sharing.permission.${permission}`)}</option>`).join("")}</select></label>` : ""}`).join("");
847
955
  }
848
956
 
849
957
  export function renderInboxPage({ baseUrl, user, notifications = [], unreadCount = 0, locale = DEFAULT_LOCALE, currentPath = "/inbox" }) {
@@ -852,9 +960,9 @@ export function renderInboxPage({ baseUrl, user, notifications = [], unreadCount
852
960
  body: `${workspaceHeader(user, view)}
853
961
  <header class="topbar"><div><h1>${view.text("inbox.title")}</h1><p>${escapeHtml(baseUrl)}</p></div></header>
854
962
  <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>`}
963
+ <form method="post" action="/inbox/read-all">${localeInput(view.locale)}<button type="submit" class="btn btn-outline-secondary">${view.text("inbox.readAll")}</button></form>
964
+ ${notifications.length ? notifications.map((item) => `<section><h2><a href="${view.href(`/artifacts/${encodeURIComponent(item.artifactId)}`)}">${escapeHtml(item.title)}</a></h2><p>${["shared-with-me", "comment", "new-version", "review-requested"].includes(item.type) ? view.text(`inbox.type.${item.type}`) : escapeHtml(item.type)} · ${renderDateTime(item.createdAt, view)}</p>
965
+ ${item.readAt ? `<p>${view.text("inbox.read")}</p>` : `<form method="post" action="/inbox/${encodeURIComponent(item.id)}/read">${localeInput(view.locale)}<button type="submit" class="btn btn-outline-secondary">${view.text("inbox.markRead")}</button></form>`}</section>`).join("") : `<p>${view.text("inbox.empty")}</p>`}
858
966
  </section></main>` });
859
967
  }
860
968
 
@@ -873,31 +981,38 @@ export function renderSharedArtifactPage({ artifact, version, content, locale =
873
981
  return pageShell({ title: artifact.title, locale: view.locale,
874
982
  body: `<header class="topbar"><div><h1>${escapeHtml(artifact.title)}</h1><p>${view.text("sharing.readOnly")} · v${Number(version.version)}</p></div></header><main id="main-content" class="artifact-view">${rendered}</main>`,
875
983
  head: needsViewer ? editorHead() : "",
876
- afterBody: `${frameResizeScript()}${needsViewer ? viewerScript() : ""}` });
984
+ afterBody: `${frameResizeScript()}${needsViewer ? viewerScript(view.locale) : ""}` });
877
985
  }
878
986
 
879
- export function renderAdminUsersPage({ baseUrl, user, users = [], importResult = null, importError = "", locale = DEFAULT_LOCALE, currentPath = "/admin/users" }) {
987
+ export function renderAdminUsersPage({ baseUrl, user, users = [], importResult = null, importError = "", roleError = "", resetResult = null, resetError = "", locale = DEFAULT_LOCALE, currentPath = "/admin/users" }) {
880
988
  const view = viewContext(locale, currentPath);
881
989
  const rows = users.map((item) => `
882
990
  <tr>
883
991
  <td>${escapeHtml(item.email)}</td>
884
992
  <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>
993
+ <td><form class="user-role-form" method="post" action="/admin/users/${encodeURIComponent(item.id)}/role">${localeInput(view.locale)}
994
+ <select name="role" aria-label="${view.text("users.roleFor", { email: item.email })}" class="form-select">
995
+ ${["user", "admin"].map((role) => `<option value="${role}"${item.role === role ? " selected" : ""}>${view.text(`ui.role.${role}`)}</option>`).join("")}
996
+ </select><button type="submit" aria-label="${view.text("users.saveRoleFor", { email: item.email })}" class="btn btn-outline-secondary">${view.text("users.saveRole")}</button>
997
+ </form></td>
998
+ <td>${item.active ? view.text("ui.active") : view.text("ui.disabled")}${item.passwordResetRequired ? ` · ${view.text("users.resetRequired")}` : ""}</td>
999
+ <td>${renderDateTime(item.createdAt, view)}</td>
1000
+ <td><div class="user-account-actions">
1001
+ <form method="post" action="/admin/users/${encodeURIComponent(item.id)}/reset-password">${localeInput(view.locale)}
1002
+ <button type="submit" aria-label="${view.text("users.resetFor", { email: item.email })}" class="btn btn-ghost">${view.text("users.resetPassword")}</button>
1003
+ </form>
1004
+ ${item.id === user.id ? "" : `<form method="post" action="/admin/users/${encodeURIComponent(item.id)}/${item.active ? "disable" : "enable"}">${localeInput(view.locale)}
1005
+ <button type="submit" class="btn btn-outline-danger">${item.active ? view.text("ui.disable") : view.text("ui.enable")}</button>
891
1006
  </form>`}
892
- </td>
1007
+ </div></td>
893
1008
  </tr>
894
1009
  `).join("");
895
1010
  const createdRows = (importResult?.created || []).map((item) => `
896
1011
  <tr>
897
1012
  <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>
1013
+ <td>${view.text(`ui.role.${item.user.role}`)}</td>
1014
+ <td>${item.user.passwordResetRequired ? view.text("ui.yes") : view.text("ui.no")}</td>
1015
+ <td>${item.passwordGenerated ? `<code>${escapeHtml(item.temporaryPassword)}</code>` : view.text("users.provided")}</td>
901
1016
  </tr>
902
1017
  `).join("");
903
1018
  const skippedRows = (importResult?.skipped || []).map((item) => `
@@ -908,67 +1023,76 @@ export function renderAdminUsersPage({ baseUrl, user, users = [], importResult =
908
1023
  `).join("");
909
1024
  const importSummary = importResult ? `
910
1025
  <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>
1026
+ <h2>${view.text("users.importResults")}</h2>
1027
+ <p class="muted">${view.text("users.summary", { created: importResult.created.length, skipped: importResult.skipped.length, failed: importResult.failed.length })}</p>
913
1028
  ${createdRows ? `<table class="data-table">
914
- <thead><tr><th>Email</th><th>Role</th><th>Reset required</th><th>Temporary password</th></tr></thead>
1029
+ <thead><tr><th>${view.text("ui.email")}</th><th>${view.text("ui.role")}</th><th>${view.text("users.resetRequired")}</th><th>${view.text("users.temporaryPassword")}</th></tr></thead>
915
1030
  <tbody>${createdRows}</tbody>
916
1031
  </table>` : ""}
917
- ${skippedRows ? `<h3>Skipped</h3><table class="data-table">
918
- <thead><tr><th>Row</th><th>Email</th><th>Reason</th></tr></thead>
1032
+ ${skippedRows ? `<h3>${view.text("users.skipped")}</h3><table class="data-table">
1033
+ <thead><tr><th>${view.text("ui.row")}</th><th>${view.text("ui.email")}</th><th>${view.text("ui.reason")}</th></tr></thead>
919
1034
  <tbody>${skippedRows}</tbody>
920
1035
  </table>` : ""}
921
- ${failedRows ? `<h3>Failed</h3><table class="data-table">
922
- <thead><tr><th>Row</th><th>Email</th><th>Error</th></tr></thead>
1036
+ ${failedRows ? `<h3>${view.text("users.failed")}</h3><table class="data-table">
1037
+ <thead><tr><th>${view.text("ui.row")}</th><th>${view.text("ui.email")}</th><th>${view.text("ui.error")}</th></tr></thead>
923
1038
  <tbody>${failedRows}</tbody>
924
1039
  </table>` : ""}
925
1040
  </section>
926
1041
  ` : "";
927
1042
 
928
1043
  return pageShell({
929
- title: "Users",
1044
+ title: view.raw("nav.users"),
930
1045
  body: `
931
1046
  ${workspaceHeader(user, view)}
932
1047
  <header class="topbar">
933
1048
  <div>
934
- <h1>Users</h1>
1049
+ <h1>${view.text("nav.users")}</h1>
935
1050
  <p>${escapeHtml(baseUrl)}</p>
936
1051
  </div>
937
1052
 
938
1053
  </header>
939
1054
  <main id="main-content" class="settings-page artifact-view">
1055
+ ${resetError ? `<p class="auth-error" role="alert">${escapeHtml(resetError)}</p>` : ""}
1056
+ ${resetResult ? `<section class="token-once" role="status"><h2>${view.text("users.resetReady")}</h2>
1057
+ <p>${view.text("users.resetAccount", { email: resetResult.user.email })}</p>
1058
+ <pre class="artifact-code"><code>${escapeHtml(resetResult.temporaryPassword)}</code></pre>
1059
+ <p>${view.text("users.resetHelp")}</p>
1060
+ <p>${view.text(`smtp.mailStatus.${["sent", "failed", "not-configured"].includes(resetResult.mailStatus) ? resetResult.mailStatus : "not-configured"}`)}</p>
1061
+ </section>` : ""}
940
1062
  <section class="meta-card">
941
- <h2>Create user</h2>
1063
+ <h2>${view.text("users.create")}</h2>
942
1064
  <form class="editor-form auth-form" method="post" action="/admin/users">
943
1065
  <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>
1066
+ <label class="field"><span>${view.text("ui.email")}</span><input type="email" name="email" required class="form-control"></label>
1067
+ <label class="field"><span>${view.text("ui.name")}</span><input name="name" class="form-control"></label>
1068
+ <label class="field"><span>${view.text("ui.role")}</span><select name="role" class="form-select"><option value="user">${view.text("ui.role.user")}</option><option value="admin">${view.text("ui.role.admin")}</option></select></label>
1069
+ <label class="field"><span>${view.text("ui.password")}</span><input type="password" name="password" minlength="8" required class="form-control"></label>
1070
+ <label class="check-field"><input type="checkbox" name="passwordResetRequired" class="form-check-input"> ${view.text("users.requirePassword")}</label>
949
1071
  </section>
950
- <footer class="editor-actions"><button type="submit">Create user</button></footer>
1072
+ <footer class="editor-actions"><button type="submit" class="btn btn-primary">${view.text("users.create")}</button></footer>
951
1073
  </form>
952
1074
  </section>
953
1075
  <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>
1076
+ <h2>${view.text("users.importTitle")}</h2>
1077
+ <p class="muted">${view.text("users.csvHelp")}</p>
956
1078
  ${importError ? `<p class="auth-error">${escapeHtml(importError)}</p>` : ""}
957
1079
  <form class="editor-form" method="post" action="/admin/users/import">
958
1080
  <label class="field content-field">
959
1081
  <span>CSV</span>
960
- <textarea class="compact-textarea" name="csv" spellcheck="false" placeholder="email,name,role&#10;user@example.com,User,user" required></textarea>
1082
+ <textarea class="compact-textarea form-control" name="csv" spellcheck="false" placeholder="email,name,role&#10;user@example.com,User,user" required></textarea>
961
1083
  </label>
962
- <footer class="editor-actions"><button type="submit">Import users</button></footer>
1084
+ <footer class="editor-actions"><button type="submit" class="btn btn-outline-secondary">${view.text("users.import")}</button></footer>
963
1085
  </form>
964
1086
  </section>
965
1087
  ${importSummary}
966
1088
  <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>
1089
+ <h2>${view.text("users.existing")}</h2>
1090
+ ${roleError ? `<p class="auth-error" role="alert">${escapeHtml(roleError)}</p>` : ""}
1091
+ <p class="muted">${view.text("users.lastAdmin")}</p>
1092
+ <div class="artifact-table-scroll"><table class="data-table">
1093
+ <thead><tr><th>${view.text("ui.email")}</th><th>${view.text("ui.name")}</th><th>${view.text("ui.role")}</th><th>${view.text("ui.status")}</th><th>${view.text("ui.created")}</th><th></th></tr></thead>
970
1094
  <tbody>${rows}</tbody>
971
- </table>
1095
+ </table></div>
972
1096
  </section>
973
1097
  </main>
974
1098
  `,
@@ -987,7 +1111,7 @@ export function renderAdminWebhooksPage({ user, baseUrl, webhooks = [], createdS
987
1111
  <td>${webhook.failureCount}</td>
988
1112
  <td>
989
1113
  <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>
1114
+ <button type="submit" class="btn btn-outline-danger">${view.text("webhooks.delete")}</button>
991
1115
  </form>
992
1116
  </td>
993
1117
  </tr>
@@ -1019,10 +1143,10 @@ export function renderAdminWebhooksPage({ user, baseUrl, webhooks = [], createdS
1019
1143
  <h2>${view.text("webhooks.createTitle")}</h2>
1020
1144
  <form class="editor-form" method="post" action="/admin/webhooks">
1021
1145
  <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>
1146
+ <label class="field"><span>${view.text("webhooks.url")}</span><input type="url" name="url" placeholder="https://example.com/hooks/artifacty" required class="form-control"></label>
1147
+ <label class="field"><span>${view.text("webhooks.eventTypes")}</span><input name="eventTypes" placeholder="${view.text("webhooks.eventTypesPlaceholder")}" class="form-control"></label>
1024
1148
  </section>
1025
- <footer class="editor-actions"><button type="submit">${view.text("webhooks.create")}</button></footer>
1149
+ <footer class="editor-actions"><button type="submit" class="btn btn-outline-secondary">${view.text("webhooks.create")}</button></footer>
1026
1150
  </form>
1027
1151
  </section>
1028
1152
  <section class="meta-card">
@@ -1049,7 +1173,7 @@ export function renderAdminRetentionPage({ user, baseUrl, policy, lastSweep = nu
1049
1173
  <h2>${view.text("retention.lastSweepTitle")}</h2>
1050
1174
  <table class="data-table">
1051
1175
  <tbody>
1052
- <tr><th>${view.text("retention.sweepAt")}</th><td>${escapeHtml(lastSweep.createdAt)}</td></tr>
1176
+ <tr><th>${view.text("retention.sweepAt")}</th><td>${renderDateTime(lastSweep.createdAt, view)}</td></tr>
1053
1177
  <tr><th>${view.text("retention.archived")}</th><td>${escapeHtml(lastSweep.metadata?.archived ?? 0)}</td></tr>
1054
1178
  <tr><th>${view.text("retention.purged")}</th><td>${escapeHtml(lastSweep.metadata?.purged ?? 0)}</td></tr>
1055
1179
  <tr><th>${view.text("retention.purgeSkipped")}</th><td>${lastSweep.metadata?.purgeSkipped ? view.text("retention.yes") : view.text("retention.no")}</td></tr>
@@ -1099,16 +1223,24 @@ export function renderAdminRetentionPage({ user, baseUrl, policy, lastSweep = nu
1099
1223
  <main id="main-content" class="settings-page artifact-view">
1100
1224
  <section class="meta-card">
1101
1225
  <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>
1226
+ <form class="editor-form retention-policy-form" method="post" action="/admin/retention">${localeInput(view.locale)}
1227
+ <fieldset class="retention-section"><legend>${view.text("retention.artifactsSection")}</legend>
1228
+ <div class="retention-field-grid">
1229
+ <label class="field"><span>${view.text("retention.archiveAfterDaysDefault")}</span><input type="number" min="1" name="archiveAfterDaysDefault" value="${policy.archiveAfterDays.default ?? ""}" class="form-control"></label>
1230
+ <label class="field"><span>${view.text("retention.purgeArchivedAfterDays")}</span><input type="number" min="1" name="purgeArchivedAfterDays" value="${policy.purgeArchivedAfterDays ?? ""}" class="form-control"></label>
1231
+ </div>
1232
+ <label class="field"><span>${view.text("retention.keepTags")}</span><input name="keepTags" value="${escapeAttribute((policy.keepTags || []).join(", "))}" placeholder="pinned, release" class="form-control"></label>
1233
+ </fieldset>
1234
+ <fieldset class="retention-section"><legend>${view.text("retention.typeRulesSection")}</legend>
1235
+ <label class="field"><span>${view.text("retention.archiveAfterDaysByType")}</span><textarea class="compact-textarea form-control" name="archiveAfterDaysByType" placeholder="test-report=30">${escapeHtml(byTypeText)}</textarea></label>
1236
+ </fieldset>
1237
+ <fieldset class="retention-section"><legend>${view.text("retention.historySection")}</legend>
1238
+ <div class="retention-field-grid">
1239
+ <label class="field"><span>${view.text("retention.auditRetentionDays")}</span><input type="number" min="1" name="auditRetentionDays" value="${policy.auditRetentionDays ?? ""}" class="form-control"></label>
1240
+ <label class="field"><span>${view.text("retention.eventRetentionRows")}</span><input type="number" min="1" name="eventRetentionRows" value="${policy.eventRetentionRows ?? ""}" class="form-control"></label>
1241
+ </div>
1242
+ </fieldset>
1243
+ <footer class="editor-actions"><button type="submit" class="btn btn-primary">${view.text("retention.saveButton")}</button></footer>
1112
1244
  </form>
1113
1245
  </section>
1114
1246
  ${lastSweepSection}
@@ -1116,9 +1248,9 @@ export function renderAdminRetentionPage({ user, baseUrl, policy, lastSweep = nu
1116
1248
  <h2>${view.text("retention.runTitle")}</h2>
1117
1249
  <p class="auth-warning">${view.text("retention.purgeGateNote")}</p>
1118
1250
  <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>
1251
+ <label class="checkbox-field"><input type="checkbox" name="dryRun" value="1" checked class="form-check-input"> ${view.text("retention.dryRunLabel")}</label>
1252
+ <label class="checkbox-field"><input type="checkbox" name="allowPurge" value="1" class="form-check-input"> ${view.text("retention.allowPurgeLabel")}</label>
1253
+ <footer class="editor-actions"><button type="submit" class="btn btn-outline-secondary">${view.text("retention.runButton")}</button></footer>
1122
1254
  </form>
1123
1255
  </section>
1124
1256
  ${reportSection}
@@ -1174,8 +1306,8 @@ export function renderArtifactPage({ artifact, version, content, comments = [],
1174
1306
  <span>${view.text("artifact.visibility.label")}</span>
1175
1307
  ${visibilitySelect(artifact.visibility || "team", view)}
1176
1308
  </label>
1177
- <button type="submit">${view.text("artifact.visibility.change")}</button>
1178
- </form>`
1309
+ <button type="submit" class="btn btn-outline-secondary">${view.text("artifact.visibility.change")}</button>
1310
+ </form><p class="muted">${view.text("artifact.visibility.help")}</p>`
1179
1311
  : "";
1180
1312
 
1181
1313
  return pageShell({
@@ -1208,14 +1340,14 @@ export function renderArtifactPage({ artifact, version, content, comments = [],
1208
1340
  <span>${view.text("artifact.schema", { version: artifact.schemaVersion || 1 })}</span>
1209
1341
  <span title="${escapeAttribute(publisherTitle(artifact, view))}">${view.text("artifact.publisher", { publisher })}</span>
1210
1342
  <span>${view.text("artifact.bytes", { size: version.sizeBytes })}</span>
1211
- <span>${escapeHtml(version.createdAt)}</span>
1343
+ <span>${renderDateTime(version.createdAt, view)}</span>
1212
1344
  <span>${escapeHtml(version.sha256.slice(0, 12))}</span>
1213
1345
  </div></details>
1214
1346
  </section>
1215
1347
  ${canArchive ? `<form class="inline-action" method="post" action="/artifacts/${encodeURIComponent(artifact.id)}/${archiveAction}">
1216
1348
  ${hiddenToken(authToken)}
1217
1349
  ${localeInput(view.locale)}
1218
- <button type="submit">${archiveLabel}</button>
1350
+ <button type="submit" class="btn btn-outline-secondary">${archiveLabel}</button>
1219
1351
  </form>` : ""}
1220
1352
  ${visibilityForm}
1221
1353
  <section class="version-strip">${versionLinks}</section>
@@ -1225,7 +1357,7 @@ export function renderArtifactPage({ artifact, version, content, comments = [],
1225
1357
  </main>
1226
1358
  `,
1227
1359
  head: needsViewerScript ? editorHead() : "",
1228
- afterBody: `${frameResizeScript()}${needsViewerScript ? viewerScript() : ""}`,
1360
+ afterBody: `${frameResizeScript()}${needsViewerScript ? viewerScript(view.locale) : ""}`,
1229
1361
  locale: view.locale
1230
1362
  });
1231
1363
  }
@@ -1314,14 +1446,14 @@ function renderCommentEntry(artifact, comment, view, { authToken, canModerate, c
1314
1446
  ? `<form class="inline-action" method="post" action="/artifacts/${encodeURIComponent(artifact.id)}/comments/${encodeURIComponent(comment.id)}/resolve">
1315
1447
  ${hiddenToken(authToken)}
1316
1448
  ${localeInput(view.locale)}
1317
- <button type="submit">${view.text("comments.resolve")}</button>
1449
+ <button type="submit" class="btn btn-outline-secondary">${view.text("comments.resolve")}</button>
1318
1450
  </form>`
1319
1451
  : "";
1320
1452
  const deleteForm = canModerate
1321
1453
  ? `<form class="inline-action" method="post" action="/artifacts/${encodeURIComponent(artifact.id)}/comments/${encodeURIComponent(comment.id)}/delete">
1322
1454
  ${hiddenToken(authToken)}
1323
1455
  ${localeInput(view.locale)}
1324
- <button type="submit">${view.text("comments.delete")}</button>
1456
+ <button type="submit" class="btn btn-outline-secondary">${view.text("comments.delete")}</button>
1325
1457
  </form>`
1326
1458
  : "";
1327
1459
 
@@ -1329,7 +1461,7 @@ function renderCommentEntry(artifact, comment, view, { authToken, canModerate, c
1329
1461
  <li class="comment-item${isReply ? " comment-reply" : ""}${resolved ? " comment-resolved" : ""}">
1330
1462
  <div class="comment-header">
1331
1463
  <span class="comment-author">${escapeHtml(comment.authorLabel)}</span>
1332
- <span class="comment-meta">${escapeHtml(comment.createdAt)}</span>
1464
+ <span class="comment-meta">${renderDateTime(comment.createdAt, view)}</span>
1333
1465
  ${anchorLabel ? `<span class="comment-anchor">${anchorLabel}</span>` : ""}
1334
1466
  ${resolved ? `<span class="badge s-archived">${view.text("comments.resolved")}</span>` : ""}
1335
1467
  </div>
@@ -1367,13 +1499,13 @@ function renderCommentForm(artifact, view, { version, authToken, parentId, compa
1367
1499
  ${parentId ? `<input type="hidden" name="parentId" value="${escapeAttribute(parentId)}">` : ""}
1368
1500
  <label>
1369
1501
  <span>${view.text(compact ? "comments.replyLabel" : "comments.bodyLabel")}</span>
1370
- <textarea name="body" required maxlength="16384"></textarea>
1502
+ <textarea name="body" required maxlength="16384" class="form-control"></textarea>
1371
1503
  </label>
1372
1504
  ${compact ? "" : `<label class="comment-line-label">
1373
1505
  <span>${view.text("comments.lineLabel")}</span>
1374
- <input type="number" name="line" min="1">
1506
+ <input type="number" name="line" min="1" class="form-control">
1375
1507
  </label>`}
1376
- <button type="submit">${view.text(compact ? "comments.reply" : "comments.submit")}</button>
1508
+ <button type="submit" class="btn btn-primary">${view.text(compact ? "comments.reply" : "comments.submit")}</button>
1377
1509
  </form>
1378
1510
  `;
1379
1511
  }
@@ -1386,11 +1518,11 @@ function renderReviewStatusForm(artifact, view, authToken) {
1386
1518
  ${localeInput(view.locale)}
1387
1519
  <label>
1388
1520
  <span>${view.text("comments.reviewStatus.label")}</span>
1389
- <select name="status">
1521
+ <select name="status" class="form-select">
1390
1522
  ${REVIEW_STATUSES.map((status) => `<option value="${status}"${status === current ? " selected" : ""}>${view.text(`comments.reviewStatus.${status}`)}</option>`).join("")}
1391
1523
  </select>
1392
1524
  </label>
1393
- <button type="submit">${view.text("comments.reviewStatus.change")}</button>
1525
+ <button type="submit" class="btn btn-outline-secondary">${view.text("comments.reviewStatus.change")}</button>
1394
1526
  </form>
1395
1527
  `;
1396
1528
  }
@@ -1497,8 +1629,8 @@ export function renderDiffPage({ user, artifact, fromVersion, toVersion, fromCon
1497
1629
 
1498
1630
  const preview = toVersion.format === "html"
1499
1631
  ? `<section class="split-preview">
1500
- <div>${renderContent(fromVersion.format, fromContent)}</div>
1501
- <div>${renderContent(toVersion.format, toContent)}</div>
1632
+ <div>${renderContent(fromVersion.format, fromContent, {}, { view })}</div>
1633
+ <div>${renderContent(toVersion.format, toContent, {}, { view })}</div>
1502
1634
  </section>`
1503
1635
  : "";
1504
1636
 
@@ -1537,13 +1669,13 @@ export function renderDiffPage({ user, artifact, fromVersion, toVersion, fromCon
1537
1669
  <input type="hidden" name="view" value="${escapeAttribute(resolvedView)}">
1538
1670
  <label class="field">
1539
1671
  <span>${view.text("diff.from")}</span>
1540
- <select name="from">${versionOptions}</select>
1672
+ <select name="from" class="form-select">${versionOptions}</select>
1541
1673
  </label>
1542
1674
  <label class="field">
1543
1675
  <span>${view.text("diff.to")}</span>
1544
- <select name="to">${versionOptions}</select>
1676
+ <select name="to" class="form-select">${versionOptions}</select>
1545
1677
  </label>
1546
- <button type="submit">${view.text("diff.compare")}</button>
1678
+ <button type="submit" class="btn btn-outline-secondary">${view.text("diff.compare")}</button>
1547
1679
  </form>
1548
1680
  <script>
1549
1681
  document.querySelector('select[name="from"]').value = ${JSON.stringify(String(fromVersion.version))};
@@ -1772,6 +1904,7 @@ function renderBundleFiles(content, options = {}) {
1772
1904
  }
1773
1905
 
1774
1906
  export function renderContent(format, content, metadata = {}, options = {}) {
1907
+ const view = options.view || viewContext(options.locale || DEFAULT_LOCALE, "/");
1775
1908
  if (format === "html") {
1776
1909
  return `<iframe class="artifact-frame" sandbox="allow-scripts allow-forms allow-popups" srcdoc="${escapeAttribute(htmlFrameContent(content))}"></iframe>`;
1777
1910
  }
@@ -1799,15 +1932,15 @@ export function renderContent(format, content, metadata = {}, options = {}) {
1799
1932
  }
1800
1933
 
1801
1934
  if (format === "notebook") {
1802
- return renderNotebook(content);
1935
+ return renderNotebook(content, view);
1803
1936
  }
1804
1937
 
1805
1938
  if (format === "sarif") {
1806
- return renderSarif(content);
1939
+ return renderSarif(content, view);
1807
1940
  }
1808
1941
 
1809
1942
  if (format === "csv") {
1810
- return renderCsv(content);
1943
+ return renderCsv(content, view);
1811
1944
  }
1812
1945
 
1813
1946
  if (format === "image") {
@@ -1821,7 +1954,7 @@ export function renderContent(format, content, metadata = {}, options = {}) {
1821
1954
  if (format === "code") {
1822
1955
  const language = metadata.language || metadata.artifactyImport?.language || "";
1823
1956
  return `<section class="artifact-code-viewer" data-artifacty-code-viewer data-language="${escapeAttribute(language)}">
1824
- <textarea hidden>${escapeHtml(content)}</textarea>
1957
+ <textarea hidden class="form-control">${escapeHtml(content)}</textarea>
1825
1958
  <pre class="artifact-code artifact-code-fallback"><code>${escapeHtml(content)}</code></pre>
1826
1959
  </section>`;
1827
1960
  }
@@ -1831,7 +1964,7 @@ export function renderContent(format, content, metadata = {}, options = {}) {
1831
1964
  return `<iframe class="artifact-frame artifact-react-frame" sandbox="allow-scripts" src="${escapeAttribute(options.reactFrameUrl)}"></iframe>`;
1832
1965
  }
1833
1966
  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>
1967
+ <p>${view.text("react.disabled")}</p>
1835
1968
  <pre class="artifact-code"><code>${escapeHtml(content)}</code></pre>
1836
1969
  </section>`;
1837
1970
  }
@@ -1922,16 +2055,35 @@ export function renderReactFramePage({ title, content }) {
1922
2055
  }
1923
2056
 
1924
2057
  export function pageShell({ title, body, head = "", afterBody = "", locale = DEFAULT_LOCALE }) {
2058
+ const darkThemeVariables = ` --bg: #15171b;
2059
+ --grid: rgba(120, 160, 200, 0.06);
2060
+ --panel: #1c1f24;
2061
+ --panel-2: #25292f;
2062
+ --text: #e6edf3;
2063
+ --muted: #adb5c0;
2064
+ --faint: #9aa5b2;
2065
+ --line: #343b44;
2066
+ --line-2: #596575;
2067
+ --accent: #9cbcf5;
2068
+ --accent-2: #bbd1f8;
2069
+ --accent-ink: #18253d;
2070
+ --accent-soft: #29374e;
2071
+ --ring: #9cbcf5;
2072
+ --code: #080c12;
2073
+ --code-text: #e6edf3;
2074
+ `;
1925
2075
  return `<!doctype html>
1926
2076
  <html lang="${escapeAttribute(locale)}">
1927
2077
  <head>
1928
2078
  <meta charset="utf-8">
2079
+ <script src="/assets/theme.js"></script><script type="module" src="/assets/auto-apply.js"></script>
2080
+ <script src="/assets/sidebar.js"></script>
1929
2081
  <meta name="viewport" content="width=device-width, initial-scale=1">
1930
2082
  <title>${escapeHtml(title)}</title>
1931
2083
  ${head}
1932
2084
  <style>
1933
2085
  :root {
1934
- color-scheme: light dark;
2086
+ color-scheme: light;
1935
2087
  --bg: #f5f6f8;
1936
2088
  --grid: rgba(31, 58, 99, 0.05);
1937
2089
  --panel: #ffffff;
@@ -1951,24 +2103,14 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
1951
2103
  --sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans KR", sans-serif;
1952
2104
  --mono: ui-monospace, "SFMono-Regular", "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
1953
2105
  }
2106
+ :root[data-theme="dark"] {
2107
+ color-scheme: dark;
2108
+ ${darkThemeVariables}
2109
+ }
1954
2110
  @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;
2111
+ :root:not([data-theme="light"]) {
2112
+ color-scheme: dark;
2113
+ ${darkThemeVariables}
1972
2114
  }
1973
2115
  }
1974
2116
  * { box-sizing: border-box; }
@@ -2051,24 +2193,10 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2051
2193
  color: var(--text);
2052
2194
  text-decoration: none;
2053
2195
  }
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
- }
2196
+ .language-switcher { display: grid; gap: 8px; min-width: 0; }
2197
+ .language-switcher label { display: grid; gap: 6px; min-width: 0; }
2198
+ .language-switcher select { width: 100%; min-width: 0; max-width: 100%; }
2199
+ .language-switcher button { justify-self: start; }
2072
2200
  .dashboard,
2073
2201
  .artifact-view,
2074
2202
  .artifact-editor {
@@ -2219,12 +2347,19 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2219
2347
  background: var(--panel);
2220
2348
  box-shadow: none;
2221
2349
  }
2350
+ .dashboard-pagination { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; margin-top: 16px; }
2351
+ .page-size-form, .page-size-form label { display: flex; align-items: center; gap: 8px; }
2352
+ .page-size-form { margin: 0; }
2353
+ .page-size-form select { min-width: 76px; width: auto; min-height: 44px; }
2354
+ .page-size-form button { min-height: 44px; }
2355
+ .pager-position { align-self: center; white-space: nowrap; }
2222
2356
  .pager {
2223
2357
  display: flex;
2224
- justify-content: flex-end;
2358
+ flex-wrap: wrap;
2359
+ justify-content: flex-start;
2225
2360
  gap: 8px;
2226
- margin-top: 14px;
2227
- font-family: var(--mono);
2361
+ margin-top: 0;
2362
+ font-family: var(--sans);
2228
2363
  font-size: 12.5px;
2229
2364
  }
2230
2365
  .pager a,
@@ -2349,11 +2484,12 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2349
2484
  font-size: 13px;
2350
2485
  }
2351
2486
  .field > span {
2352
- font-family: var(--mono);
2353
- font-size: 11.5px;
2487
+ font-family: var(--sans);
2488
+ font-size: 12px;
2489
+ font-weight: 500;
2354
2490
  letter-spacing: 0;
2355
2491
  text-transform: none;
2356
- color: var(--faint);
2492
+ color: var(--muted);
2357
2493
  }
2358
2494
  .check-field {
2359
2495
  display: flex;
@@ -2368,8 +2504,6 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2368
2504
  min-height: 0;
2369
2505
  padding: 0;
2370
2506
  }
2371
- input:not([type="checkbox"]):not([type="radio"]),
2372
- select,
2373
2507
  textarea {
2374
2508
  width: 100%;
2375
2509
  border: 1px solid var(--line-2);
@@ -2455,60 +2589,13 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
2455
2589
  border: 0;
2456
2590
  background: var(--panel);
2457
2591
  }
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
- }
2592
+ button:not(.btn) { font: inherit; color: var(--text); background: var(--panel); border: 1px solid var(--line-2); border-radius: 6px; padding: 6px 10px; cursor: pointer; }
2465
2593
  .editor-actions {
2466
2594
  display: flex;
2467
2595
  align-items: center;
2468
2596
  justify-content: flex-end;
2469
2597
  gap: 12px;
2470
2598
  }
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
2599
  .inline-action button:hover {
2513
2600
  background: var(--panel-2);
2514
2601
  border-color: var(--accent);
@@ -3179,6 +3266,7 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
3179
3266
  .meta-card table { display: block; max-width: 100%; overflow-x: auto; }
3180
3267
  .artifact-doc { font-size: 15px; line-height: 1.7; }
3181
3268
  .workspace-header { background: var(--panel); border-bottom: 1px solid var(--line); padding: 12px 24px; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
3269
+ .workspace-brand-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
3182
3270
  .workspace-brand { display: flex; align-items: center; gap: 10px; color: var(--text); font-size: 19px; font-weight: 700; letter-spacing: -.04em; }
3183
3271
  .workspace-brand svg { width: 27px; height: 27px; color: var(--accent); }
3184
3272
  .workspace-desktop { display: none; }
@@ -3188,13 +3276,13 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
3188
3276
  .workspace-mobile nav { padding-top: 16px; }
3189
3277
  .workspace-primary, .workspace-actions { display: grid; gap: 4px; }
3190
3278
  .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; }
3279
+ .workspace-header nav a, .workspace-dropdown summary, .workspace-dropdown .nav-form button { display: flex; align-items: center; gap: 10px; min-height: 38px; padding: 8px 10px; color: var(--muted); border-radius: 6px; font-size: 14px; text-align: left; }
3192
3280
  .workspace-header nav a:hover, .workspace-dropdown summary:hover { background: var(--panel-2); color: var(--text); text-decoration: none; }
3193
3281
  .workspace-header [aria-current="page"], .workspace-header .is-current { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
3194
3282
  .workspace-dropdown summary { cursor: pointer; list-style: none; }
3195
3283
  .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: "⌃"; }
3284
+ .workspace-chevron { margin-left: auto; display: inline-flex; }
3285
+ .workspace-dropdown[open] > summary .workspace-chevron { transform: rotate(180deg); }
3198
3286
  .workspace-version { color: var(--faint); font-size: 12px; padding: 10px; }
3199
3287
  .settings-page { max-width: 1100px; }
3200
3288
  .settings-page .meta-card { gap: 16px; }
@@ -3204,11 +3292,56 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
3204
3292
  .settings-page .field { min-width: 0; }
3205
3293
  .settings-page textarea[name="allowedCidrs"] { min-height: 120px; height: 140px; }
3206
3294
  .settings-page .editor-form { gap: 12px; }
3295
+ .settings-page .user-role-form { display: flex; flex-wrap: nowrap; gap: 8px; align-items: center; }
3296
+ .user-account-actions { display: grid; gap: 8px; justify-items: start; }
3297
+
3298
+ .user-role-form select { width: auto; min-width: 105px; }
3299
+ .user-role-form select, .user-role-form button { margin: 0; }
3300
+
3301
+ .meta-card:has(> form[action="/admin/retention/run"]) { min-width: 0; overflow-wrap: anywhere; }
3302
+ form[action="/admin/retention/run"], form[action="/admin/retention/run"] > * { min-width: 0; }
3303
+ form[action="/admin/retention/run"] .checkbox-field { display: block; white-space: normal; overflow-wrap: anywhere; line-height: 1.6; }
3304
+ form[action="/admin/retention/run"] .checkbox-field input { margin-right: 6px; vertical-align: middle; }
3305
+ .retention-policy-form .retention-section { display: grid; gap: 16px; min-width: 0; margin: 0; padding: 16px; border: 1px solid var(--line); border-radius: 6px; }
3306
+ .retention-policy-form legend { padding: 0 6px; font-weight: 600; }
3307
+ .smtp-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; align-items: start; }
3308
+ .smtp-settings-form input, .smtp-settings-form select { min-width: 0; }
3309
+ .smtp-settings-form .field { align-content: start; }
3310
+ @media (max-width: 640px) { .smtp-field-grid { grid-template-columns: minmax(0, 1fr); } }
3311
+ .retention-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; gap: 16px; }
3312
+ .retention-policy-form .field { display: grid; gap: 8px; align-content: start; }
3313
+ .retention-policy-form input, .retention-policy-form textarea { width: 100%; min-width: 0; box-sizing: border-box; }
3314
+ .retention-policy-form textarea { min-height: 112px; height: 112px; }
3315
+ @media (max-width: 640px) { .retention-field-grid { grid-template-columns: minmax(0, 1fr); } }
3316
+
3207
3317
  .workspace-dropdown-content { display: grid; padding-left: 10px; border-left: 1px solid var(--line); margin: 4px 0 8px 10px; }
3208
3318
  .workspace-dropdown-content .nav-form { display: block; }
3209
3319
  .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; }
3320
+ .theme-controls { border: 0; min-width: 0; margin: 16px 10px 0; padding: 0; }
3321
+ .theme-controls legend { color: var(--muted); font-size: 12px; margin-bottom: 6px; }
3322
+ .theme-choices { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); padding: 3px; gap: 2px; background: var(--panel-2); border-radius: 8px; }
3323
+ .theme-choices .btn { padding: 6px; min-width: 0; border: 0; display: inline-flex; justify-content: center; align-items: center; background: transparent; color: var(--muted); }
3324
+ .theme-choices .btn[aria-pressed="true"] { background: var(--panel); color: var(--accent); box-shadow: 0 1px 3px #0002; }
3325
+ .workspace-header svg { flex-shrink: 0; }
3326
+ .workspace-header .sidebar-toggle { display: none; }
3327
+ .sidebar-toggle > span:not([hidden]) { display: flex; align-items: center; }
3328
+ .workspace-brand-row svg { display: block; }
3329
+ @media (min-width: 1100px) {
3330
+ .sidebar-toggle:not([hidden]) { display: inline-flex; position: static; width: 36px; height: 36px; padding: 0; flex-shrink: 0; }
3331
+ .workspace-desktop { padding-top: 0; }
3332
+ :root[data-sidebar="collapsed"] body:has(> .workspace-header) { padding-left: 68px; }
3333
+ :root[data-sidebar="collapsed"] .workspace-header { width: 68px; padding: 24px 10px; }
3334
+ :root[data-sidebar="collapsed"] .workspace-brand { display: none; }
3335
+ :root[data-sidebar="collapsed"] .workspace-brand-row { justify-content: center; }
3336
+
3337
+ :root[data-sidebar="collapsed"] .workspace-nav-label, :root[data-sidebar="collapsed"] .workspace-brand-name, :root[data-sidebar="collapsed"] .workspace-chevron, :root[data-sidebar="collapsed"] .workspace-version, :root[data-sidebar="collapsed"] .workspace-identity, :root[data-sidebar="collapsed"] .language-switcher, :root[data-sidebar="collapsed"] .theme-controls { display: none; }
3338
+ :root[data-sidebar="collapsed"] .workspace-dropdown-content { padding-left: 0; border: 0; margin-left: 0; }
3339
+ :root[data-sidebar="collapsed"] .workspace-header nav a, :root[data-sidebar="collapsed"] .workspace-dropdown summary, :root[data-sidebar="collapsed"] .nav-form button { justify-content: center; padding: 10px; }
3340
+ :root[data-sidebar="collapsed"] .workspace-header [aria-current="page"] { box-shadow: inset 3px 0 var(--accent); }
3341
+ :root[data-sidebar="collapsed"] .workspace-header nav a:is(:hover, :focus-visible) .workspace-nav-label, :root[data-sidebar="collapsed"] .workspace-dropdown > summary:is(:hover, :focus-visible) .workspace-nav-label, :root[data-sidebar="collapsed"] .nav-form button:is(:hover, :focus-visible) .workspace-nav-label { display: block; position: fixed; left: 76px; z-index: 50; padding: 6px 10px; max-width: 240px; background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 6px; box-shadow: 0 3px 12px #0002; pointer-events: none; }
3342
+ }
3343
+ .workspace-header .language-switcher { margin: 12px 0 0; padding: 0 10px; font-size: 12px; }
3344
+
3212
3345
  .workspace-skip { position: absolute; left: 16px; top: -100px; z-index: 100; background: var(--panel); padding: 8px 16px; }
3213
3346
  .workspace-skip:focus { top: 8px; }
3214
3347
  :focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; }
@@ -3253,7 +3386,8 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
3253
3386
  @media (min-width: 1100px) {
3254
3387
  body:has(> .workspace-header) { padding-left: 196px; }
3255
3388
  .workspace-header { position: fixed; inset: 0 auto 0 0; width: 196px; padding: 24px 12px; border-bottom: 0; border-right: 1px solid var(--line); display: block; overflow-y: auto; z-index: 20; }
3256
- .workspace-brand { padding: 0 10px 26px; }
3389
+ .workspace-brand-row { min-height: 36px; margin-bottom: 18px; }
3390
+ .workspace-brand { padding: 0 0 0 10px; }
3257
3391
  .workspace-desktop { display: block; }
3258
3392
  .workspace-mobile { display: none; }
3259
3393
  }
@@ -3281,6 +3415,7 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
3281
3415
  .dashboard .row-main strong { font-size: 14px; }
3282
3416
  }
3283
3417
  </style>
3418
+ <link rel="stylesheet" href="/assets/ui-controls.css">
3284
3419
  </head>
3285
3420
  <body>
3286
3421
  ${body}
@@ -3290,7 +3425,7 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
3290
3425
  }
3291
3426
 
3292
3427
  function formatSelect(selected) {
3293
- return `<select name="format">
3428
+ return `<select name="format" class="form-select">
3294
3429
  ${ARTIFACT_FORMATS.map((format) => {
3295
3430
  const label = format[0].toUpperCase() + format.slice(1);
3296
3431
  return `<option value="${format}"${format === selected ? " selected" : ""}>${label}</option>`;
@@ -3299,13 +3434,13 @@ function formatSelect(selected) {
3299
3434
  }
3300
3435
 
3301
3436
  function artifactTypeSelect(selected) {
3302
- return `<select name="artifactType">
3437
+ return `<select name="artifactType" class="form-select">
3303
3438
  ${artifactTypeOptions(selected)}
3304
3439
  </select>`;
3305
3440
  }
3306
3441
 
3307
3442
  function visibilitySelect(selected, view) {
3308
- return `<select name="visibility">
3443
+ return `<select name="visibility" class="form-select">
3309
3444
  ${VISIBILITY_VALUES.map((value) => `<option value="${value}"${value === selected ? " selected" : ""}>${view.text(`artifact.visibility.${value}`)}</option>`).join("")}
3310
3445
  </select>`;
3311
3446
  }
@@ -3333,6 +3468,13 @@ function editorScript(locale) {
3333
3468
  return `<script>window.ARTIFACTY_I18N=${config};</script><script type="module" src="${EDITOR_CLIENT_PATH}"></script>`;
3334
3469
  }
3335
3470
 
3471
+ function renderDateTime(value, view) {
3472
+ const date = new Date(value);
3473
+ if (!Number.isFinite(date.getTime())) return escapeHtml(value);
3474
+ const label = new Intl.DateTimeFormat(view.locale, { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" }).format(date);
3475
+ return `<time datetime="${escapeAttribute(value)}" title="${escapeAttribute(value)}">${escapeHtml(label)} UTC</time>`;
3476
+ }
3477
+
3336
3478
  function viewContext(locale, currentPath) {
3337
3479
  const i18n = createI18n(locale);
3338
3480
  return {
@@ -3394,13 +3536,11 @@ function statusBadge(label) {
3394
3536
  return `<span class="badge s-archived">${label}</span>`;
3395
3537
  }
3396
3538
 
3397
- // Only rendered for 'private' artifacts (the non-default visibility) so the
3398
- // common case doesn't add visual noise to every row.
3539
+ // Highlight explicit public/private visibility while keeping default team rows compact.
3399
3540
  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>`;
3541
+ const visibility = artifact.visibility || "team";
3542
+ if (!["private", "public"].includes(visibility)) return "";
3543
+ return `<span class="badge v-${visibility}">${view.text(`artifact.visibility.${visibility}`)}</span>`;
3404
3544
  }
3405
3545
 
3406
3546
  function htmlFrameContent(content) {
@@ -3494,37 +3634,50 @@ function frameResizeScript() {
3494
3634
  return `<script>(function(){window.addEventListener("message",function(event){var data=event.data;if(!data||typeof data.__artifactyHeight!=="number"){return;}var frames=document.querySelectorAll(".artifact-frame");for(var i=0;i<frames.length;i+=1){if(frames[i].contentWindow===event.source){var height=Math.min(Math.max(Math.ceil(data.__artifactyHeight),200),200000);frames[i].style.height=height+"px";break;}}});})();</script>`;
3495
3635
  }
3496
3636
 
3497
- function viewerScript() {
3498
- return `<script type="module" src="${VIEWER_CLIENT_PATH}"></script>`;
3637
+ function viewerScript(locale = DEFAULT_LOCALE) {
3638
+ const view = viewContext(locale, "/");
3639
+ const messages = Object.fromEntries(["filter", "filterColumn", "ruleFilter", "rowsShown", "resultsShown"].map((key) => [key, view.raw(`viewer.${key}`, { column: "{column}", visible: "{visible}", total: "{total}" })]));
3640
+ const config = JSON.stringify(messages).replaceAll("<", "\\u003c");
3641
+ return `<script type="application/json" id="artifacty-viewer-i18n">${config}</script><script type="module" src="${VIEWER_CLIENT_PATH}"></script>`;
3499
3642
  }
3500
3643
 
3501
3644
  function reactRendererEnabled() {
3502
3645
  return process.env.ARTIFACTY_ENABLE_REACT_RENDERER === "true";
3503
3646
  }
3504
3647
 
3648
+ function themeControls(view) {
3649
+ return `<fieldset class="theme-controls" data-theme-controls hidden><legend>${view.text("theme.label")}</legend>
3650
+ <div class="theme-choices">${["system", "light", "dark"].map((theme) => `<button type="button" data-theme-choice="${theme}" aria-pressed="${theme === "system"}" aria-label="${view.attr(`theme.${theme}`)}" title="${view.attr(`theme.${theme}`)}" class="btn btn-ghost">${uiIcon({ system: "device-desktop", light: "sun", dark: "moon" }[theme])}<span class="dashboard-sr-only">${view.text(`theme.${theme}`)}</span></button>`).join("")}</div>
3651
+ </fieldset>`;
3652
+ }
3653
+
3505
3654
  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>`;
3655
+ const current = new URL(view.currentPath || "/", "http://artifacty.local");
3656
+ const preserved = [...current.searchParams.entries()].filter(([name]) => name !== "lang")
3657
+ .map(([name, value]) => `<input type="hidden" name="${escapeAttribute(name)}" value="${escapeAttribute(value)}">`).join("");
3658
+ const options = availableLanguages().filter((language) => language.enabled).map((language) =>
3659
+ `<option value="${escapeAttribute(language.locale)}"${language.locale === view.locale ? " selected" : ""}>${escapeHtml(language.nativeName)}</option>`
3660
+ ).join("");
3661
+ return `<form class="language-switcher" data-auto-apply="all" method="get" action="${escapeAttribute(`${current.pathname}${current.hash}`)}">
3662
+ ${preserved}<label><span>${view.text("language.label")}</span><select name="lang" class="form-select">${options}</select></label>
3663
+ <button type="submit" class="btn btn-outline-secondary">${view.text("language.apply")}</button>
3664
+ </form>`;
3513
3665
  }
3514
3666
 
3515
3667
  function workspaceHeader(user, view) {
3516
3668
  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>`;
3669
+ const icons = { "/": "files", "/inbox": "inbox", "/new": "plus", "/import": "upload", "/admin/access": "settings", "/admin/users": "users", "/admin/backup": "database-export", "/admin/retention": "archive", "/admin/webhooks": "webhook", "/admin/update": "refresh", "/admin/languages": "language", "/admin/email": "mail", "/account": "user-circle", "/account/password": "key", "/login": "login", "/docs/api": "code" };
3670
+ const link = (href, key, active = pathname === href, className = "") => `<a href="${view.href(href)}" title="${view.attr(key)}" aria-label="${view.attr(key)}"${active ? ' aria-current="page"' : ""}${className ? ` class="${className}"` : ""}>${uiIcon(icons[href] || "files")}<span class="workspace-nav-label">${view.text(key)}</span></a>`;
3518
3671
  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>
3672
+ const adminLinks = user?.role === "admin" ? `<details class="workspace-dropdown"${pathname.startsWith("/admin/") ? " open" : ""}><summary${pathname.startsWith("/admin/") ? ' class="is-current"' : ""} title="${view.attr("nav.administration")}" aria-label="${view.attr("nav.administration")}">${uiIcon("settings")}<span class="workspace-nav-label">${view.text("nav.administration")}</span><span class="workspace-chevron">${uiIcon("chevron-down", { size: 14 })}</span></summary><div class="workspace-dropdown-content">
3673
+ ${link("/admin/access", "access.title")}${link("/admin/users", "nav.users")}${link("/admin/backup", "nav.backup")}${link("/admin/retention", "retention.navLabel")}${link("/admin/webhooks", "nav.webhooks")}${link("/admin/update", "update.title")}${link("/admin/languages", "languageAdmin.title")}${link("/admin/email", "smtp.title")}<span class="workspace-version">Artifacty v${escapeHtml(APP_VERSION)}</span>
3521
3674
  </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">
3675
+ const account = user ? `<details class="workspace-dropdown"${pathname.startsWith("/account") ? " open" : ""}><summary${pathname.startsWith("/account") ? ' class="is-current"' : ""} title="${view.attr("nav.account")}" aria-label="${view.attr("nav.account")}">${uiIcon("user-circle")}<span class="workspace-nav-label">${view.text("nav.account")}</span><span class="workspace-chevron">${uiIcon("chevron-down", { size: 14 })}</span></summary><div class="workspace-dropdown-content">
3523
3676
  <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>
3677
+ <form method="post" action="/logout" class="nav-form">${localeInput(view.locale)}<button type="submit" title="${view.attr("nav.signOut")}" aria-label="${view.attr("nav.signOut")}" class="btn btn-ghost">${uiIcon("logout")}<span class="workspace-nav-label">${view.text("nav.signOut")}</span></button></form>
3525
3678
  </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>
3679
+ const links = `<div class="workspace-primary">${link("/", "nav.artifacts", artifactActive)}${user ? link("/inbox", "inbox.title") : ""}</div><div class="workspace-actions">${link("/new", "nav.new", pathname === "/new", "workspace-create")}${link("/import", "nav.import")}${adminLinks}${account}${link("/docs/api", "nav.apiDocs")}${languageSwitcher(view)}${themeControls(view)}</div>`;
3680
+ return `<a class="workspace-skip" href="#main-content">${view.text("nav.skip")}</a><header class="workspace-header"><div class="workspace-brand-row"><a class="workspace-brand" aria-label="Artifacty" href="${view.href("/")}">${brandMark()}<span class="workspace-brand-name">artifacty</span></a><button type="button" class="sidebar-toggle btn btn-ghost" data-sidebar-toggle hidden aria-expanded="true" aria-label="${view.attr("nav.collapseSidebar")}" title="${view.attr("nav.collapseSidebar")}" data-collapse-label="${view.attr("nav.collapseSidebar")}" data-expand-label="${view.attr("nav.expandSidebar")}"><span data-sidebar-collapse-icon>${uiIcon("layout-sidebar-left-collapse")}</span><span data-sidebar-expand-icon hidden>${uiIcon("layout-sidebar-left-expand")}</span></button></div>
3528
3681
  <nav class="workspace-desktop" aria-label="${view.attr("dashboard.navigation")}">${links}</nav>
3529
3682
  <details class="workspace-mobile"><summary>${view.text("dashboard.more")}</summary><nav aria-label="${view.attr("dashboard.navigation")}">${links}</nav></details>
3530
3683
  </header>`;
@@ -3634,7 +3787,7 @@ function markdownToHtml(markdown, diagramState = createMermaidDiagramState()) {
3634
3787
  inList = true;
3635
3788
  }
3636
3789
  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>`);
3790
+ html.push(`<li class="task-list-item"><label><input type="checkbox" disabled${checked ? " checked" : ""} class="form-check-input"> ${inlineMarkdown(taskItem[2])}</label></li>`);
3638
3791
  continue;
3639
3792
  }
3640
3793
 
@@ -3769,19 +3922,20 @@ const CSV_RENDER_COLUMN_LIMIT = 80;
3769
3922
  const SARIF_RENDER_RESULT_LIMIT = 500;
3770
3923
 
3771
3924
  function renderMediaArtifact(format, content, metadata = {}, options = {}) {
3925
+ const view = options.view || viewContext(options.locale || DEFAULT_LOCALE, "/");
3772
3926
  const mimeType = mediaMimeType(metadata, format);
3773
3927
  const source = options.rawUrl || mediaDataUrl(content, mimeType);
3774
3928
  if (!source) {
3775
3929
  return `<section class="artifact-media-note">
3776
- Media preview is unavailable because this artifact does not contain valid base64 media content.
3930
+ ${view.text("media.unavailable")}
3777
3931
  </section>
3778
3932
  <pre class="artifact-code"><code>${escapeHtml(content)}</code></pre>`;
3779
3933
  }
3780
3934
 
3781
3935
  const label = [
3782
3936
  mimeType || format,
3783
- metadata.encoding ? `encoding: ${metadata.encoding}` : "",
3784
- metadata.originalEncoding ? `source: ${metadata.originalEncoding}` : ""
3937
+ metadata.encoding ? view.raw("media.encoding", { encoding: metadata.encoding }) : "",
3938
+ metadata.originalEncoding ? view.raw("media.source", { encoding: metadata.originalEncoding }) : ""
3785
3939
  ].filter(Boolean).join(" · ");
3786
3940
 
3787
3941
  if (format === "video") {
@@ -3792,7 +3946,7 @@ function renderMediaArtifact(format, content, metadata = {}, options = {}) {
3792
3946
  }
3793
3947
 
3794
3948
  return `<figure class="artifact-media artifact-image">
3795
- <img src="${escapeAttribute(source)}" alt="Image artifact">
3949
+ <img src="${escapeAttribute(source)}" alt="${view.text("media.image")}">
3796
3950
  <figcaption>${escapeHtml(label || "image artifact")}</figcaption>
3797
3951
  </figure>`;
3798
3952
  }
@@ -3817,17 +3971,17 @@ function mediaDataUrl(content, mimeType) {
3817
3971
  return `data:${mimeType};base64,${base64}`;
3818
3972
  }
3819
3973
 
3820
- function renderCsv(content) {
3974
+ function renderCsv(content, view) {
3821
3975
  const parsed = parseCsv(content);
3822
3976
  if (!parsed.ok) {
3823
3977
  return `<section class="artifact-csv">
3824
- <p class="artifact-csv-note">CSV parsing failed: ${escapeHtml(parsed.error)}. Showing escaped source.</p>
3978
+ <p class="artifact-csv-note">${view.text("csv.error", { error: parsed.error })}</p>
3825
3979
  <pre class="artifact-code"><code>${escapeHtml(content)}</code></pre>
3826
3980
  </section>`;
3827
3981
  }
3828
3982
 
3829
3983
  if (parsed.rows.length === 0) {
3830
- return `<section class="artifact-csv"><p class="artifact-csv-note">Empty CSV artifact.</p></section>`;
3984
+ return `<section class="artifact-csv"><p class="artifact-csv-note">${view.text("csv.empty")}</p></section>`;
3831
3985
  }
3832
3986
 
3833
3987
  const columnCount = Math.max(...parsed.rows.map((row) => row.length));
@@ -3837,7 +3991,7 @@ function renderCsv(content) {
3837
3991
  const rowTruncated = parsed.rows.length - 1 > CSV_RENDER_ROW_LIMIT;
3838
3992
  const columnTruncated = columnCount > CSV_RENDER_COLUMN_LIMIT;
3839
3993
  const headerHtml = Array.from({ length: visibleColumns }, (_, index) =>
3840
- `<th data-csv-col="${index}">${escapeHtml(header[index] || `Column ${index + 1}`)}</th>`
3994
+ `<th data-csv-col="${index}">${escapeHtml(header[index] || view.raw("csv.column", { column: index + 1 }))}</th>`
3841
3995
  ).join("");
3842
3996
  const bodyHtml = bodyRows.map((row) =>
3843
3997
  `<tr data-csv-row>${Array.from({ length: visibleColumns }, (_, index) =>
@@ -3845,10 +3999,10 @@ function renderCsv(content) {
3845
3999
  ).join("")}</tr>`
3846
4000
  ).join("\n");
3847
4001
  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` : ""
4002
+ view.raw("csv.rows", { count: parsed.rows.length - 1 }),
4003
+ view.raw("csv.columns", { count: columnCount }),
4004
+ rowTruncated ? view.raw("csv.firstRows", { count: CSV_RENDER_ROW_LIMIT }) : "",
4005
+ columnTruncated ? view.raw("csv.firstColumns", { count: CSV_RENDER_COLUMN_LIMIT }) : ""
3852
4006
  ].filter(Boolean).join(" · ");
3853
4007
 
3854
4008
  return `<section class="artifact-csv" data-artifact-csv>
@@ -3862,20 +4016,20 @@ function renderCsv(content) {
3862
4016
  </section>`;
3863
4017
  }
3864
4018
 
3865
- function renderSarif(content) {
4019
+ function renderSarif(content, view) {
3866
4020
  let sarif;
3867
4021
  try {
3868
4022
  sarif = JSON.parse(content);
3869
4023
  } catch {
3870
4024
  return `<section class="artifact-sarif">
3871
- <p class="artifact-sarif-note">Invalid SARIF JSON. Showing escaped source.</p>
4025
+ <p class="artifact-sarif-note">${view.text("sarif.invalid")}</p>
3872
4026
  <pre class="artifact-code"><code>${escapeHtml(content)}</code></pre>
3873
4027
  </section>`;
3874
4028
  }
3875
4029
 
3876
4030
  if (!isSarifDocument(sarif)) {
3877
4031
  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>
4032
+ <p class="artifact-sarif-note">${view.text("sarif.shape")}</p>
3879
4033
  <pre class="artifact-code"><code>${escapeHtml(formatJson(content))}</code></pre>
3880
4034
  </section>`;
3881
4035
  }
@@ -3891,26 +4045,26 @@ function renderSarif(content) {
3891
4045
  <td>${escapeHtml(result.tool)}</td>
3892
4046
  </tr>`).join("\n");
3893
4047
  const note = results.length > SARIF_RENDER_RESULT_LIMIT
3894
- ? `Showing first ${SARIF_RENDER_RESULT_LIMIT} of ${results.length} results.`
3895
- : `${results.length} results.`;
4048
+ ? view.raw("sarif.first", { count: SARIF_RENDER_RESULT_LIMIT, total: results.length })
4049
+ : view.raw("sarif.results", { count: results.length });
3896
4050
 
3897
4051
  return `<section class="artifact-sarif" data-artifact-sarif>
3898
4052
  <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)}
4053
+ ${summaryCard(view.raw("sarif.runs"), sarif.runs.length)}
4054
+ ${summaryCard(view.raw("sarif.resultsTitle"), results.length)}
4055
+ ${summaryCard(view.raw("sarif.errors"), counts.error || 0)}
4056
+ ${summaryCard(view.raw("sarif.warnings"), counts.warning || 0)}
4057
+ ${summaryCard(view.raw("sarif.notes"), counts.note || 0)}
3904
4058
  </div>
3905
4059
  <p class="artifact-sarif-note">${escapeHtml(note)}</p>
3906
4060
  <div class="artifact-table-scroll">
3907
4061
  <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>
4062
+ <thead><tr><th data-sarif-sort="level">${view.text("sarif.level")}</th><th data-sarif-sort="rule">${view.text("sarif.rule")}</th><th>${view.text("sarif.message")}</th><th data-sarif-sort="location">${view.text("sarif.location")}</th><th>${view.text("sarif.tool")}</th></tr></thead>
3909
4063
  <tbody>${rowHtml}</tbody>
3910
4064
  </table>
3911
4065
  </div>
3912
4066
  <details class="artifact-raw-details">
3913
- <summary>Raw SARIF JSON</summary>
4067
+ <summary>${view.text("sarif.raw")}</summary>
3914
4068
  <pre class="artifact-code"><code>${escapeHtml(formatJson(content))}</code></pre>
3915
4069
  </details>
3916
4070
  </section>`;
@@ -3991,7 +4145,7 @@ const NOTEBOOK_OUTPUT_MIME_PRIORITY = [
3991
4145
  "text/plain"
3992
4146
  ];
3993
4147
 
3994
- function renderNotebook(content) {
4148
+ function renderNotebook(content, view) {
3995
4149
  let notebook;
3996
4150
  try {
3997
4151
  notebook = JSON.parse(content);
@@ -4006,16 +4160,16 @@ function renderNotebook(content) {
4006
4160
  const diagramState = createMermaidDiagramState();
4007
4161
  const cells = notebook.cells;
4008
4162
  const rendered = cells.slice(0, NOTEBOOK_MAX_RENDERED_CELLS)
4009
- .map((cell) => renderNotebookCell(cell, language, diagramState))
4163
+ .map((cell) => renderNotebookCell(cell, language, diagramState, view))
4010
4164
  .join("");
4011
4165
  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>`
4166
+ ? `<p class="artifact-notebook-notice">${view.text("notebook.cells", { count: NOTEBOOK_MAX_RENDERED_CELLS, total: cells.length })}</p>`
4013
4167
  : "";
4014
4168
 
4015
4169
  return `<article class="artifact-doc artifact-notebook">${notice}${rendered}</article>`;
4016
4170
  }
4017
4171
 
4018
- function renderNotebookCell(cell, language, diagramState) {
4172
+ function renderNotebookCell(cell, language, diagramState, view) {
4019
4173
  const cellType = cell?.cell_type;
4020
4174
  const source = notebookText(cell?.source);
4021
4175
 
@@ -4034,9 +4188,9 @@ function renderNotebookCell(cell, language, diagramState) {
4034
4188
  const cellOutputs = Array.isArray(cell?.outputs) ? cell.outputs : [];
4035
4189
  const visibleOutputs = cellOutputs.slice(0, NOTEBOOK_MAX_OUTPUTS_PER_CELL);
4036
4190
  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>`
4191
+ ? `<p class="artifact-notebook-notice">${view.text("notebook.outputs", { count: NOTEBOOK_MAX_OUTPUTS_PER_CELL, total: cellOutputs.length })}</p>`
4038
4192
  : "";
4039
- const outputs = visibleOutputs.map((output) => renderNotebookOutput(output, diagramState)).join("") + outputsNotice;
4193
+ const outputs = visibleOutputs.map((output) => renderNotebookOutput(output, diagramState, view)).join("") + outputsNotice;
4040
4194
  return `<section class="artifact-notebook-cell artifact-notebook-cell-code">
4041
4195
  <div class="artifact-notebook-execcount">${escapeHtml(executionLabel)}</div>
4042
4196
  <pre><code${classAttribute}>${escapeHtml(source)}</code></pre>
@@ -4055,11 +4209,11 @@ function stripAnsiEscapes(value) {
4055
4209
  return String(value ?? "").replace(ANSI_ESCAPE_PATTERN, "");
4056
4210
  }
4057
4211
 
4058
- function renderNotebookOutput(output, diagramState) {
4212
+ function renderNotebookOutput(output, diagramState, view) {
4059
4213
  const outputType = output?.output_type;
4060
4214
 
4061
4215
  if (outputType === "stream") {
4062
- return renderNotebookTextOutput(notebookText(output.text));
4216
+ return renderNotebookTextOutput(notebookText(output.text), "", view);
4063
4217
  }
4064
4218
 
4065
4219
  if (outputType === "error") {
@@ -4073,18 +4227,18 @@ function renderNotebookOutput(output, diagramState) {
4073
4227
  : notebookText(output.traceback);
4074
4228
  const traceback = stripAnsiEscapes(rawTraceback) ||
4075
4229
  `${notebookText(output.ename)}: ${notebookText(output.evalue)}`;
4076
- return renderNotebookTextOutput(traceback, "artifact-notebook-error");
4230
+ return renderNotebookTextOutput(traceback, "artifact-notebook-error", view);
4077
4231
  }
4078
4232
 
4079
4233
  if (outputType === "execute_result" || outputType === "display_data") {
4080
- return renderNotebookMimeBundle(output.data, diagramState);
4234
+ return renderNotebookMimeBundle(output.data, diagramState, view);
4081
4235
  }
4082
4236
 
4083
4237
  return "";
4084
4238
  }
4085
4239
 
4086
- function renderNotebookTextOutput(text, extraClass = "") {
4087
- const truncated = notebookTruncateIfOversized(text);
4240
+ function renderNotebookTextOutput(text, extraClass = "", view) {
4241
+ const truncated = notebookTruncateIfOversized(text, view);
4088
4242
  if (truncated) {
4089
4243
  return truncated;
4090
4244
  }
@@ -4092,7 +4246,7 @@ function renderNotebookTextOutput(text, extraClass = "") {
4092
4246
  return `<pre class="artifact-notebook-output artifact-code${classAttribute}"><code>${escapeHtml(text)}</code></pre>`;
4093
4247
  }
4094
4248
 
4095
- function renderNotebookMimeBundle(data, diagramState) {
4249
+ function renderNotebookMimeBundle(data, diagramState, view) {
4096
4250
  const bundle = data && typeof data === "object" && !Array.isArray(data) ? data : {};
4097
4251
  const keys = Object.keys(bundle);
4098
4252
  if (keys.length === 0) {
@@ -4101,11 +4255,11 @@ function renderNotebookMimeBundle(data, diagramState) {
4101
4255
 
4102
4256
  const mime = NOTEBOOK_OUTPUT_MIME_PRIORITY.find((candidate) => keys.includes(candidate));
4103
4257
  if (!mime) {
4104
- return `<p class="artifact-notebook-output-placeholder">Unsupported output type: ${escapeHtml(keys[0])}</p>`;
4258
+ return `<p class="artifact-notebook-output-placeholder">${view.text("notebook.unsupported", { type: keys[0] })}</p>`;
4105
4259
  }
4106
4260
 
4107
4261
  const value = notebookText(bundle[mime]);
4108
- const truncated = notebookTruncateIfOversized(value);
4262
+ const truncated = notebookTruncateIfOversized(value, view);
4109
4263
  if (truncated) {
4110
4264
  return truncated;
4111
4265
  }
@@ -4119,9 +4273,9 @@ function renderNotebookMimeBundle(data, diagramState) {
4119
4273
  if (mime === "image/png" || mime === "image/jpeg") {
4120
4274
  const dataUrl = mediaDataUrl(value, mime);
4121
4275
  if (!dataUrl) {
4122
- return `<p class="artifact-notebook-output-placeholder">Unsupported output type: ${escapeHtml(mime)}</p>`;
4276
+ return `<p class="artifact-notebook-output-placeholder">${view.text("notebook.unsupported", { type: mime })}</p>`;
4123
4277
  }
4124
- return `<figure class="artifact-media artifact-image artifact-notebook-output"><img src="${escapeAttribute(dataUrl)}" alt="Notebook output image"></figure>`;
4278
+ return `<figure class="artifact-media artifact-image artifact-notebook-output"><img src="${escapeAttribute(dataUrl)}" alt="${view.text("notebook.image")}"></figure>`;
4125
4279
  }
4126
4280
  if (mime === "image/svg+xml") {
4127
4281
  return `<iframe class="artifact-frame artifact-svg-frame artifact-notebook-output" sandbox srcdoc="${escapeAttribute(svgFrameContent(value))}"></iframe>`;
@@ -4132,12 +4286,12 @@ function renderNotebookMimeBundle(data, diagramState) {
4132
4286
  return "";
4133
4287
  }
4134
4288
 
4135
- function notebookTruncateIfOversized(value) {
4289
+ function notebookTruncateIfOversized(value, view) {
4136
4290
  const size = Buffer.byteLength(String(value ?? ""), "utf8");
4137
4291
  if (size <= NOTEBOOK_MAX_OUTPUT_BYTES) {
4138
4292
  return null;
4139
4293
  }
4140
- return `<p class="artifact-notebook-output-truncated">Output truncated: ${escapeHtml(String(size))} bytes exceeds the 2 MB limit.</p>`;
4294
+ return `<p class="artifact-notebook-output-truncated">${view.text("notebook.truncated", { size })}</p>`;
4141
4295
  }
4142
4296
 
4143
4297
  function notebookText(value) {