@jant/core 0.3.31 → 0.3.33

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.
Files changed (95) hide show
  1. package/dist/client/client.css +1 -1
  2. package/dist/client/client.js +1442 -989
  3. package/dist/index.js +1429 -1055
  4. package/package.json +2 -2
  5. package/src/__tests__/helpers/app.ts +6 -3
  6. package/src/__tests__/helpers/db.ts +3 -0
  7. package/src/client.ts +2 -1
  8. package/src/db/migrations/0011_add_path_registry.sql +23 -0
  9. package/src/db/schema.ts +12 -1
  10. package/src/i18n/locales/en.po +225 -91
  11. package/src/i18n/locales/en.ts +1 -1
  12. package/src/i18n/locales/zh-Hans.po +201 -152
  13. package/src/i18n/locales/zh-Hans.ts +1 -1
  14. package/src/i18n/locales/zh-Hant.po +201 -152
  15. package/src/i18n/locales/zh-Hant.ts +1 -1
  16. package/src/lib/__tests__/excerpt.test.ts +25 -0
  17. package/src/lib/__tests__/resolve-config.test.ts +26 -2
  18. package/src/lib/__tests__/timeline.test.ts +2 -1
  19. package/src/lib/compose-bridge.ts +30 -1
  20. package/src/lib/excerpt.ts +16 -7
  21. package/src/lib/nav-manager-bridge.ts +54 -0
  22. package/src/lib/navigation.ts +7 -4
  23. package/src/lib/render.tsx +5 -2
  24. package/src/lib/resolve-config.ts +7 -0
  25. package/src/lib/view.ts +42 -10
  26. package/src/middleware/error-handler.ts +16 -0
  27. package/src/routes/api/__tests__/posts.test.ts +80 -0
  28. package/src/routes/api/__tests__/settings.test.ts +1 -1
  29. package/src/routes/api/posts.ts +6 -29
  30. package/src/routes/api/upload.ts +2 -14
  31. package/src/routes/auth/__tests__/setup.test.ts +2 -1
  32. package/src/routes/compose.tsx +13 -5
  33. package/src/routes/dash/__tests__/pages.test.ts +2 -1
  34. package/src/routes/dash/__tests__/settings-avatar.test.ts +151 -33
  35. package/src/routes/dash/appearance.tsx +71 -4
  36. package/src/routes/dash/collections.tsx +15 -21
  37. package/src/routes/dash/media.tsx +1 -13
  38. package/src/routes/dash/pages.tsx +5 -150
  39. package/src/routes/dash/posts.tsx +25 -32
  40. package/src/routes/dash/redirects.tsx +9 -11
  41. package/src/routes/dash/settings.tsx +29 -111
  42. package/src/routes/feed/__tests__/rss.test.ts +5 -1
  43. package/src/routes/pages/__tests__/collections.test.ts +2 -1
  44. package/src/routes/pages/__tests__/featured.test.ts +2 -1
  45. package/src/routes/pages/page.tsx +20 -25
  46. package/src/services/__tests__/collection.test.ts +2 -1
  47. package/src/services/__tests__/media.test.ts +78 -1
  48. package/src/services/__tests__/navigation.test.ts +2 -1
  49. package/src/services/__tests__/page.test.ts +78 -1
  50. package/src/services/__tests__/path-registry.test.ts +165 -0
  51. package/src/services/__tests__/post-timeline.test.ts +2 -1
  52. package/src/services/__tests__/post.test.ts +103 -1
  53. package/src/services/__tests__/redirect.test.ts +53 -4
  54. package/src/services/__tests__/search.test.ts +2 -1
  55. package/src/services/__tests__/settings.test.ts +153 -0
  56. package/src/services/index.ts +12 -4
  57. package/src/services/media.ts +72 -4
  58. package/src/services/page.ts +64 -17
  59. package/src/services/path-registry.ts +160 -0
  60. package/src/services/post.ts +119 -24
  61. package/src/services/redirect.ts +23 -3
  62. package/src/services/settings.ts +181 -0
  63. package/src/styles/components.css +135 -0
  64. package/src/styles/tokens.css +6 -1
  65. package/src/styles/ui.css +70 -26
  66. package/src/types/bindings.ts +1 -0
  67. package/src/types/config.ts +7 -2
  68. package/src/types/constants.ts +9 -1
  69. package/src/types/sortablejs.d.ts +8 -2
  70. package/src/types/views.ts +1 -1
  71. package/src/ui/color-themes.ts +31 -31
  72. package/src/ui/components/__tests__/jant-settings-avatar.test.ts +0 -3
  73. package/src/ui/components/__tests__/jant-settings-general.test.ts +2 -6
  74. package/src/ui/components/jant-compose-dialog.ts +3 -2
  75. package/src/ui/components/jant-compose-editor.ts +17 -2
  76. package/src/ui/components/jant-nav-manager.ts +1067 -0
  77. package/src/ui/components/jant-settings-general.ts +2 -35
  78. package/src/ui/components/nav-manager-types.ts +72 -0
  79. package/src/ui/components/settings-types.ts +0 -3
  80. package/src/ui/compose/ComposePrompt.tsx +3 -11
  81. package/src/ui/dash/appearance/AdvancedContent.tsx +0 -3
  82. package/src/ui/dash/appearance/AppearanceNav.tsx +12 -8
  83. package/src/ui/dash/appearance/ColorThemeContent.tsx +1 -4
  84. package/src/ui/dash/appearance/FontThemeContent.tsx +0 -3
  85. package/src/ui/dash/appearance/NavigationContent.tsx +302 -0
  86. package/src/ui/dash/pages/PagesContent.tsx +74 -0
  87. package/src/ui/dash/settings/AccountContent.tsx +0 -3
  88. package/src/ui/dash/settings/GeneralContent.tsx +1 -19
  89. package/src/ui/dash/settings/SettingsNav.tsx +2 -6
  90. package/src/ui/feed/NoteCard.tsx +2 -2
  91. package/src/ui/layouts/DashLayout.tsx +83 -86
  92. package/src/ui/layouts/SiteLayout.tsx +82 -21
  93. package/src/lib/nav-reorder.ts +0 -26
  94. package/src/ui/dash/pages/LinkFormContent.tsx +0 -119
  95. package/src/ui/dash/pages/UnifiedPagesContent.tsx +0 -203
@@ -12,7 +12,17 @@ import type { Database } from "../db/index.js";
12
12
  import { posts, postCollections } from "../db/schema.js";
13
13
  import { now } from "../lib/time.js";
14
14
  import { render as renderMarkdown } from "../lib/markdown.js";
15
+ import type { StorageDriver } from "../lib/storage.js";
16
+ import type { MediaService } from "./media.js";
15
17
  import type { Format, Status, Post, CreatePost, UpdatePost } from "../types.js";
18
+ import type { PathRegistryService } from "./path-registry.js";
19
+ import { ConflictError } from "../lib/errors.js";
20
+
21
+ /** Dependencies for operations that coordinate with other services */
22
+ export interface PostDeleteDeps {
23
+ media: MediaService;
24
+ storage?: StorageDriver | null;
25
+ }
16
26
 
17
27
  export interface PostFilters {
18
28
  format?: Format;
@@ -37,7 +47,14 @@ export interface PostService {
37
47
  count(filters?: PostFilters): Promise<number>;
38
48
  create(data: CreatePost): Promise<Post>;
39
49
  update(id: number, data: UpdatePost): Promise<Post | null>;
40
- delete(id: number): Promise<boolean>;
50
+ /**
51
+ * Soft-delete a post and clean up its media (storage files + DB records).
52
+ * Thread roots cascade to all replies.
53
+ *
54
+ * @param id - Post ID
55
+ * @param deps - Media service and optional storage driver for file cleanup
56
+ */
57
+ delete(id: number, deps?: PostDeleteDeps): Promise<boolean>;
41
58
  getThread(rootId: number): Promise<Post[]>;
42
59
  updateThreadStatusAndFeatured(
43
60
  rootId: number,
@@ -53,7 +70,16 @@ export interface PostService {
53
70
  ): Promise<Map<number, Post[]>>;
54
71
  }
55
72
 
56
- export function createPostService(db: Database): PostService {
73
+ /** Check if an error is a SQLite UNIQUE constraint violation (D1 or better-sqlite3) */
74
+ function isUniqueConstraintError(err: unknown): boolean {
75
+ const msg = String(err);
76
+ return msg.includes("UNIQUE constraint") || msg.includes("SQLITE_CONSTRAINT");
77
+ }
78
+
79
+ export function createPostService(
80
+ db: Database,
81
+ pathRegistry: PathRegistryService,
82
+ ): PostService {
57
83
  /** Build WHERE conditions from filters (shared by list and count) */
58
84
  function buildFilterConditions(filters: PostFilters) {
59
85
  const conditions = [];
@@ -189,31 +215,53 @@ export function createPostService(db: Database): PostService {
189
215
  }
190
216
  }
191
217
 
192
- const result = await db
193
- .insert(posts)
194
- .values({
195
- format: data.format,
196
- status,
197
- featured: featured ? 1 : 0,
198
- pinned: data.pinned ? 1 : 0,
199
- path: data.path ?? null,
200
- title: data.title ?? null,
201
- url: data.url ?? null,
202
- body: data.body ?? null,
203
- bodyHtml,
204
- quoteText: data.quoteText ?? null,
205
- rating: data.rating ?? null,
206
- replyToId: data.replyToId ?? null,
207
- threadId,
208
- publishedAt: data.publishedAt ?? timestamp,
209
- createdAt: timestamp,
210
- updatedAt: timestamp,
211
- })
212
- .returning();
218
+ // Validate path availability before DB insert — throws friendly
219
+ // ConflictError/ValidationError instead of a raw UNIQUE constraint error.
220
+ // Uses placeholder owner ID; corrected to real ID after insert.
221
+ if (data.path) {
222
+ await pathRegistry.claim(data.path, "post", 0);
223
+ }
224
+
225
+ let result;
226
+ try {
227
+ result = await db
228
+ .insert(posts)
229
+ .values({
230
+ format: data.format,
231
+ status,
232
+ featured: featured ? 1 : 0,
233
+ pinned: data.pinned ? 1 : 0,
234
+ path: data.path ?? null,
235
+ title: data.title ?? null,
236
+ url: data.url ?? null,
237
+ body: data.body ?? null,
238
+ bodyHtml,
239
+ quoteText: data.quoteText ?? null,
240
+ rating: data.rating ?? null,
241
+ replyToId: data.replyToId ?? null,
242
+ threadId,
243
+ publishedAt: data.publishedAt ?? timestamp,
244
+ createdAt: timestamp,
245
+ updatedAt: timestamp,
246
+ })
247
+ .returning();
248
+ } catch (err) {
249
+ if (data.path) await pathRegistry.release(data.path);
250
+ if (isUniqueConstraintError(err)) {
251
+ throw new ConflictError(`Path "${data.path}" is already in use`);
252
+ }
253
+ throw err;
254
+ }
213
255
 
214
256
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
215
257
  const post = toPost(result[0]!);
216
258
 
259
+ // Update registry with actual post ID
260
+ if (post.path) {
261
+ await pathRegistry.release(post.path);
262
+ await pathRegistry.claim(post.path, "post", post.id);
263
+ }
264
+
217
265
  // Sync collection memberships if provided
218
266
  if (data.collectionIds && data.collectionIds.length > 0) {
219
267
  await db.insert(postCollections).values(
@@ -231,6 +279,20 @@ export function createPostService(db: Database): PostService {
231
279
  const existing = await this.getById(id);
232
280
  if (!existing) return null;
233
281
 
282
+ // Handle path changes in the registry before modifying the post
283
+ const pathChanging =
284
+ data.path !== undefined && data.path !== existing.path;
285
+ if (pathChanging) {
286
+ // Claim new path (if non-null) before releasing old
287
+ if (data.path) {
288
+ await pathRegistry.claim(data.path, "post", id);
289
+ }
290
+ // Release old path (if it existed)
291
+ if (existing.path) {
292
+ await pathRegistry.release(existing.path);
293
+ }
294
+ }
295
+
234
296
  const timestamp = now();
235
297
  const updates: Partial<typeof posts.$inferInsert> = {
236
298
  updatedAt: timestamp,
@@ -335,10 +397,43 @@ export function createPostService(db: Database): PostService {
335
397
  return updateResult?.[0] ? toPost(updateResult[0]) : null;
336
398
  },
337
399
 
338
- async delete(id) {
400
+ async delete(id, deps) {
339
401
  const existing = await this.getById(id);
340
402
  if (!existing) return false;
341
403
 
404
+ // Clean up media for all affected posts
405
+ if (deps?.media) {
406
+ let postIds: number[];
407
+ if (!existing.threadId) {
408
+ const thread = await this.getThread(id);
409
+ postIds = thread.map((p) => p.id);
410
+ } else {
411
+ postIds = [id];
412
+ }
413
+
414
+ const mediaMap = await deps.media.getByPostIds(postIds);
415
+ const allMedia = [...mediaMap.values()].flat();
416
+ if (allMedia.length > 0) {
417
+ await deps.media.deleteByIds(
418
+ allMedia.map((m) => m.id),
419
+ deps.storage,
420
+ );
421
+ }
422
+ }
423
+
424
+ // Release paths from registry
425
+ if (!existing.threadId) {
426
+ // Thread root: release paths for all posts in thread
427
+ const thread = await this.getThread(id);
428
+ for (const post of thread) {
429
+ if (post.path) {
430
+ await pathRegistry.release(post.path);
431
+ }
432
+ }
433
+ } else if (existing.path) {
434
+ await pathRegistry.release(existing.path);
435
+ }
436
+
342
437
  const timestamp = now();
343
438
 
344
439
  // If this is a thread root, soft delete all posts in the thread
@@ -10,6 +10,8 @@ import { redirects } from "../db/schema.js";
10
10
  import { now } from "../lib/time.js";
11
11
  import { normalizePath } from "../lib/url.js";
12
12
  import type { Redirect } from "../types.js";
13
+ import type { PathRegistryService } from "./path-registry.js";
14
+ import { ConflictError } from "../lib/errors.js";
13
15
 
14
16
  export interface RedirectService {
15
17
  getByPath(fromPath: string): Promise<Redirect | null>;
@@ -18,7 +20,10 @@ export interface RedirectService {
18
20
  list(): Promise<Redirect[]>;
19
21
  }
20
22
 
21
- export function createRedirectService(db: Database): RedirectService {
23
+ export function createRedirectService(
24
+ db: Database,
25
+ pathRegistry: PathRegistryService,
26
+ ): RedirectService {
22
27
  function toRedirect(row: typeof redirects.$inferSelect): Redirect {
23
28
  return {
24
29
  id: row.id,
@@ -44,7 +49,16 @@ export function createRedirectService(db: Database): RedirectService {
44
49
  const timestamp = now();
45
50
  const normalizedFrom = normalizePath(fromPath);
46
51
 
47
- // Delete existing redirect from this path if any
52
+ // Check if path is claimed by a non-redirect entity
53
+ const existingClaim = await pathRegistry.getByPath(normalizedFrom);
54
+ if (existingClaim && existingClaim.ownerType !== "redirect") {
55
+ throw new ConflictError(`Path "${normalizedFrom}" is already in use`);
56
+ }
57
+
58
+ // Delete existing redirect from this path if any (upsert behavior)
59
+ if (existingClaim?.ownerType === "redirect") {
60
+ await pathRegistry.release(normalizedFrom);
61
+ }
48
62
  await db.delete(redirects).where(eq(redirects.fromPath, normalizedFrom));
49
63
 
50
64
  const result = await db
@@ -58,10 +72,16 @@ export function createRedirectService(db: Database): RedirectService {
58
72
  .returning();
59
73
 
60
74
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
61
- return toRedirect(result[0]!);
75
+ const redirect = toRedirect(result[0]!);
76
+
77
+ await pathRegistry.claim(normalizedFrom, "redirect", redirect.id);
78
+
79
+ return redirect;
62
80
  },
63
81
 
64
82
  async delete(id) {
83
+ // Release path registry entries for this redirect
84
+ await pathRegistry.releaseByOwner("redirect", id);
65
85
  const result = await db
66
86
  .delete(redirects)
67
87
  .where(eq(redirects.id, id))
@@ -13,6 +13,38 @@ import {
13
13
  ONBOARDING_STATUS,
14
14
  type SettingsKey,
15
15
  } from "../lib/constants.js";
16
+ import type { StorageDriver } from "../lib/storage.js";
17
+ import type { MediaService } from "./media.js";
18
+ import { validateUploadFile, generateStorageKey } from "../lib/upload.js";
19
+ import { arrayBufferToBase64 } from "../lib/favicon.js";
20
+ import { ValidationError } from "../lib/errors.js";
21
+
22
+ export interface GeneralSettingsData {
23
+ siteName: string;
24
+ siteDescription: string;
25
+ siteFooter: string;
26
+ siteLanguage: string;
27
+ homeDefaultView?: string;
28
+ headerNavMaxVisible?: string;
29
+ timeZone: string;
30
+ }
31
+
32
+ export interface GeneralSettingsResult {
33
+ languageChanged: boolean;
34
+ displayName: string;
35
+ }
36
+
37
+ export interface AvatarUploadData {
38
+ file: { stream(): ReadableStream; name: string; type: string; size: number };
39
+ faviconIco?: ArrayBuffer;
40
+ appleTouchIcon?: ArrayBuffer;
41
+ }
42
+
43
+ export interface AvatarUploadDeps {
44
+ media: MediaService;
45
+ storage: StorageDriver;
46
+ storageProvider: string;
47
+ }
16
48
 
17
49
  export interface SettingsService {
18
50
  get(key: SettingsKey): Promise<string | null>;
@@ -22,6 +54,34 @@ export interface SettingsService {
22
54
  remove(key: SettingsKey): Promise<void>;
23
55
  isOnboardingComplete(): Promise<boolean>;
24
56
  completeOnboarding(): Promise<void>;
57
+ /**
58
+ * Update general site settings with trim/set/remove logic.
59
+ * Empty strings are removed. Default values are removed to keep the DB clean.
60
+ *
61
+ * @param data - Form data from the settings page
62
+ * @param opts - Old language (for change detection) and fallback site name
63
+ * @returns Whether the language changed and the display name for the site
64
+ */
65
+ updateGeneral(
66
+ data: GeneralSettingsData,
67
+ opts: { oldLanguage: string; fallbackSiteName: string },
68
+ ): Promise<GeneralSettingsResult>;
69
+ /**
70
+ * Upload an avatar image: validates file, stores in storage, creates media record,
71
+ * updates settings (SITE_AVATAR, SITE_FAVICON_ICO, SITE_FAVICON_APPLE_TOUCH, SITE_FAVICON_VERSION).
72
+ *
73
+ * @param data - Avatar file and optional favicon variants
74
+ * @param deps - Media service and storage driver dependencies
75
+ * @throws {ValidationError} When file validation fails
76
+ */
77
+ uploadAvatar(data: AvatarUploadData, deps: AvatarUploadDeps): Promise<void>;
78
+ /**
79
+ * Remove avatar and all favicon-related settings.
80
+ * Deletes the apple-touch-icon from storage if it exists.
81
+ *
82
+ * @param storage - Optional storage driver for deleting the apple-touch-icon file
83
+ */
84
+ removeAvatar(storage?: StorageDriver | null): Promise<void>;
25
85
  }
26
86
 
27
87
  export function createSettingsService(db: Database): SettingsService {
@@ -96,5 +156,126 @@ export function createSettingsService(db: Database): SettingsService {
96
156
  ONBOARDING_STATUS.COMPLETED,
97
157
  );
98
158
  },
159
+
160
+ async updateGeneral(data, opts) {
161
+ // Site name: set if non-empty, remove otherwise
162
+ if (data.siteName.trim()) {
163
+ await this.set("SITE_NAME", data.siteName.trim());
164
+ } else {
165
+ await this.remove("SITE_NAME");
166
+ }
167
+
168
+ // Site description: set if non-empty, remove otherwise
169
+ if (data.siteDescription.trim()) {
170
+ await this.set("SITE_DESCRIPTION", data.siteDescription.trim());
171
+ } else {
172
+ await this.remove("SITE_DESCRIPTION");
173
+ }
174
+
175
+ // Footer: set if non-empty, remove otherwise
176
+ if (data.siteFooter?.trim()) {
177
+ await this.set("SITE_FOOTER", data.siteFooter.trim());
178
+ } else {
179
+ await this.remove("SITE_FOOTER");
180
+ }
181
+
182
+ // Language is always stored
183
+ await this.set("SITE_LANGUAGE", data.siteLanguage);
184
+
185
+ // Homepage default view: only update if provided (may be managed separately)
186
+ if (data.homeDefaultView !== undefined) {
187
+ if (data.homeDefaultView === "featured") {
188
+ await this.set("HOME_DEFAULT_VIEW", data.homeDefaultView);
189
+ } else {
190
+ await this.remove("HOME_DEFAULT_VIEW");
191
+ }
192
+ }
193
+
194
+ // Header nav max visible: only update if provided (may be managed separately)
195
+ if (data.headerNavMaxVisible !== undefined) {
196
+ const navMax = parseInt(String(data.headerNavMaxVisible), 10);
197
+ if (!isNaN(navMax) && navMax !== 3) {
198
+ await this.set("HEADER_NAV_MAX_VISIBLE", String(navMax));
199
+ } else {
200
+ await this.remove("HEADER_NAV_MAX_VISIBLE");
201
+ }
202
+ }
203
+
204
+ // Timezone: only store non-default (default is UTC)
205
+ if (data.timeZone && data.timeZone !== "UTC") {
206
+ await this.set("TIME_ZONE", data.timeZone);
207
+ } else {
208
+ await this.remove("TIME_ZONE");
209
+ }
210
+
211
+ return {
212
+ languageChanged: opts.oldLanguage !== data.siteLanguage,
213
+ displayName: data.siteName.trim() || opts.fallbackSiteName,
214
+ };
215
+ },
216
+
217
+ async uploadAvatar(data, deps) {
218
+ const uploadError = validateUploadFile(data.file as unknown as File);
219
+ if (uploadError) {
220
+ throw new ValidationError(uploadError);
221
+ }
222
+
223
+ const { id, filename, storageKey } = generateStorageKey(data.file.name);
224
+
225
+ await deps.storage.put(storageKey, data.file.stream(), {
226
+ contentType: data.file.type,
227
+ });
228
+
229
+ await deps.media.create({
230
+ id,
231
+ filename,
232
+ originalName: data.file.name,
233
+ mimeType: data.file.type,
234
+ size: data.file.size,
235
+ storageKey,
236
+ provider: deps.storageProvider,
237
+ });
238
+
239
+ await this.set("SITE_AVATAR", storageKey);
240
+
241
+ // Store favicon ICO as base64 in settings (tiny file, accessed every page load)
242
+ if (data.faviconIco) {
243
+ const b64 = arrayBufferToBase64(data.faviconIco);
244
+ await this.set("SITE_FAVICON_ICO", b64);
245
+ }
246
+
247
+ // Store apple-touch-icon in storage (180x180 PNG, not tiny enough for base64)
248
+ if (data.appleTouchIcon) {
249
+ const appleTouchKey = "favicon/apple-touch-icon.png";
250
+ await deps.storage.put(
251
+ appleTouchKey,
252
+ new Uint8Array(data.appleTouchIcon),
253
+ { contentType: "image/png" },
254
+ );
255
+ await this.set("SITE_FAVICON_APPLE_TOUCH", appleTouchKey);
256
+ }
257
+
258
+ // Set favicon version for cache-busting
259
+ const ts = new Date();
260
+ const version =
261
+ String(ts.getUTCFullYear()) +
262
+ String(ts.getUTCMonth() + 1).padStart(2, "0") +
263
+ String(ts.getUTCDate()).padStart(2, "0") +
264
+ String(ts.getUTCHours()).padStart(2, "0") +
265
+ String(ts.getUTCMinutes()).padStart(2, "0");
266
+ await this.set("SITE_FAVICON_VERSION", version);
267
+ },
268
+
269
+ async removeAvatar(storage) {
270
+ const appleTouchKey = await this.get("SITE_FAVICON_APPLE_TOUCH");
271
+ if (storage && appleTouchKey) {
272
+ await storage.delete(appleTouchKey);
273
+ }
274
+
275
+ await this.remove("SITE_AVATAR");
276
+ await this.remove("SITE_FAVICON_ICO");
277
+ await this.remove("SITE_FAVICON_APPLE_TOUCH");
278
+ await this.remove("SITE_FAVICON_VERSION");
279
+ },
99
280
  };
100
281
  }
@@ -5,6 +5,15 @@
5
5
  * AFTER Tailwind is initialized in the user's CSS entry.
6
6
  */
7
7
 
8
+ /* Icon stroke width — CSS property overrides SVG presentational attributes */
9
+ svg[stroke-width] {
10
+ stroke-width: var(--icon-stroke);
11
+ }
12
+
13
+ svg[stroke-width].icon-fine {
14
+ stroke-width: var(--icon-stroke-fine);
15
+ }
16
+
8
17
  /* Custom utilities */
9
18
  @layer utilities {
10
19
  .container {
@@ -93,6 +102,132 @@
93
102
  }
94
103
  }
95
104
 
105
+ /* Dashboard header */
106
+ @layer components {
107
+ .dash-header {
108
+ padding: 20px 0 0;
109
+ }
110
+
111
+ @media (min-width: 700px) {
112
+ .dash-header {
113
+ padding: 24px 0 0;
114
+ }
115
+ }
116
+
117
+ .dash-header-inner {
118
+ display: flex;
119
+ align-items: center;
120
+ gap: 16px;
121
+ padding-bottom: 12px;
122
+ }
123
+
124
+ .dash-header-left {
125
+ display: flex;
126
+ align-items: center;
127
+ gap: 6px;
128
+ }
129
+
130
+ .dash-header-nav {
131
+ display: flex;
132
+ align-items: center;
133
+ justify-content: center;
134
+ gap: 4px;
135
+ flex: 1;
136
+ }
137
+
138
+ .dash-header-logo {
139
+ font-size: 1.125rem;
140
+ font-weight: 800;
141
+ line-height: 1;
142
+ color: var(--color-foreground);
143
+ }
144
+
145
+ .dash-header-site-link {
146
+ @apply flex items-center justify-center;
147
+ color: var(--color-muted-foreground);
148
+ transition: color 0.15s;
149
+
150
+ &:hover {
151
+ color: var(--color-foreground);
152
+ }
153
+ }
154
+
155
+ .dash-header-link {
156
+ position: relative;
157
+ font-size: 0.875rem;
158
+ padding: 6px 10px;
159
+ border-radius: var(--radius);
160
+ color: var(--color-muted-foreground);
161
+ transition:
162
+ color 0.15s,
163
+ background-color 0.15s;
164
+
165
+ &:hover {
166
+ color: var(--color-foreground);
167
+ background-color: var(--color-accent);
168
+ }
169
+ }
170
+
171
+ .dash-header-link-active {
172
+ color: var(--color-foreground);
173
+ font-weight: 600;
174
+ background-color: var(--color-accent);
175
+ }
176
+
177
+ .dash-header-menu-btn {
178
+ @apply flex items-center justify-center;
179
+ width: 32px;
180
+ height: 32px;
181
+ border-radius: 999px;
182
+ border: none;
183
+ background: transparent;
184
+ color: var(--color-muted-foreground);
185
+ cursor: pointer;
186
+ transition:
187
+ color 0.15s,
188
+ background-color 0.15s;
189
+
190
+ &:hover {
191
+ color: var(--color-foreground);
192
+ background-color: var(--color-accent);
193
+ }
194
+ }
195
+
196
+ .dash-header-menu-btn + [data-popover] {
197
+ min-width: 170px;
198
+ }
199
+ }
200
+
201
+ /* Sub-navigation — underline style for secondary tabs (Settings, Appearance) */
202
+ @layer components {
203
+ .dash-subnav {
204
+ @apply flex gap-4 mb-6;
205
+
206
+ > a {
207
+ position: relative;
208
+ font-size: 0.8125rem;
209
+ padding-bottom: 8px;
210
+ color: var(--color-muted-foreground);
211
+ transition: color 0.15s;
212
+
213
+ &:hover {
214
+ color: var(--color-foreground);
215
+ }
216
+
217
+ &.active {
218
+ color: var(--color-foreground);
219
+ font-weight: 500;
220
+
221
+ &::after {
222
+ content: "";
223
+ @apply absolute inset-x-0 bottom-0 h-0.5 rounded-full;
224
+ background-color: var(--color-foreground);
225
+ }
226
+ }
227
+ }
228
+ }
229
+ }
230
+
96
231
  /* Skeleton screen sizing — used in SSR fallbacks while Lit hydrates */
97
232
  @layer components {
98
233
  .skel-label {
@@ -17,7 +17,7 @@
17
17
  --leading: 1.5;
18
18
 
19
19
  /* Layout */
20
- --site-width: 640px;
20
+ --site-width: 600px;
21
21
  --site-padding: 1.5rem;
22
22
  --content-gap: 1rem;
23
23
  --space-xl: 2rem;
@@ -38,6 +38,10 @@
38
38
  --avatar-radius: 50%;
39
39
  --media-radius: 0.5rem;
40
40
 
41
+ /* Icons */
42
+ --icon-stroke: 2;
43
+ --icon-stroke-fine: 1.5;
44
+
41
45
  /* Derived color tokens (from BaseCoat variables) */
42
46
  --site-column-outline: var(--border);
43
47
  --site-threadline: var(--border);
@@ -46,6 +50,7 @@
46
50
  --site-nav-hover-bg: var(--accent);
47
51
  --site-text-primary: var(--foreground);
48
52
  --site-text-secondary: var(--muted-foreground);
53
+ --site-text-placeholder: oklch(from var(--muted-foreground) l c h / 0.5);
49
54
  --site-media-outline: var(--border);
50
55
  --site-divider: var(--border);
51
56
  }