From d78fa1fcb8d339538c79c2b873b3429d44f08e79 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Sun, 20 Sep 2026 22:24:00 +0700 Subject: [PATCH 01/10] fix(tokens): define bg-subtle/accent-subtle, fix square avatar, drop hex fallbacks - define --color-bg-subtle and --color-accent-subtle in @theme (#272) - account avatar: var(--radius-full) was undefined (square avatars) -> var(--radius-pill) - strip dormant hex fallbacks from var() in schedules/ScheduleDetail/PostDetail/ConfirmDialog (#272) --- web/src/app.css | 3 +++ web/src/lib/components/ConfirmDialog.svelte | 2 +- web/src/lib/components/PostDetail.svelte | 2 +- web/src/lib/components/ScheduleDetail.svelte | 2 +- web/src/routes/admin/accounts/+page.svelte | 2 +- web/src/routes/admin/schedules/+page.svelte | 8 ++++---- 6 files changed, 11 insertions(+), 8 deletions(-) diff --git a/web/src/app.css b/web/src/app.css index bdcf2b2..9989462 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -72,6 +72,9 @@ --color-warning-border: oklch(80% 0.10 85); --color-warning-text: var(--color-warning-ink); --color-muted-bg: var(--color-paper-3); + /* ── Subtle surface + accent wash (row tints, avatar placeholder) ── */ + --color-bg-subtle: var(--color-paper-3); + --color-accent-subtle: oklch(60% 0.15 260 / 0.15); --color-success-bg: var(--color-success-dim); /* ── shadcn component palette ── */ diff --git a/web/src/lib/components/ConfirmDialog.svelte b/web/src/lib/components/ConfirmDialog.svelte index 6550224..1b32a94 100644 --- a/web/src/lib/components/ConfirmDialog.svelte +++ b/web/src/lib/components/ConfirmDialog.svelte @@ -59,7 +59,7 @@ width: 100%; margin-top: 0.75rem; padding: 0.5rem 0.75rem; - border: 1px solid var(--border, #ccc); + border: 1px solid var(--border); border-radius: 0.375rem; font-size: 0.875rem; } diff --git a/web/src/lib/components/PostDetail.svelte b/web/src/lib/components/PostDetail.svelte index 89f3845..91f2bb8 100644 --- a/web/src/lib/components/PostDetail.svelte +++ b/web/src/lib/components/PostDetail.svelte @@ -267,7 +267,7 @@ line-height: 0; } .thumb-btn:focus-visible { - outline: 2px solid var(--color-primary, #6366f1); + outline: 2px solid var(--color-primary); outline-offset: 2px; border-radius: var(--radius-sm); } diff --git a/web/src/lib/components/ScheduleDetail.svelte b/web/src/lib/components/ScheduleDetail.svelte index b93eba3..a3450f8 100644 --- a/web/src/lib/components/ScheduleDetail.svelte +++ b/web/src/lib/components/ScheduleDetail.svelte @@ -283,7 +283,7 @@ line-height: 0; } .thumb-btn:focus-visible { - outline: 2px solid var(--color-primary, #6366f1); + outline: 2px solid var(--color-primary); outline-offset: 2px; border-radius: var(--radius-sm); } diff --git a/web/src/routes/admin/accounts/+page.svelte b/web/src/routes/admin/accounts/+page.svelte index 91b4438..966477b 100644 --- a/web/src/routes/admin/accounts/+page.svelte +++ b/web/src/routes/admin/accounts/+page.svelte @@ -387,7 +387,7 @@ .account-avatar { width: 40px; height: 40px; - border-radius: var(--radius-full); + border-radius: var(--radius-pill); object-fit: cover; flex-shrink: 0; } diff --git a/web/src/routes/admin/schedules/+page.svelte b/web/src/routes/admin/schedules/+page.svelte index b02790a..43fb0e8 100644 --- a/web/src/routes/admin/schedules/+page.svelte +++ b/web/src/routes/admin/schedules/+page.svelte @@ -1208,12 +1208,12 @@ import MediaLightbox from '$lib/components/MediaLightbox.svelte'; flex-direction: column; gap: var(--space-sm, 0.5rem); padding: var(--space-md, 1rem); - border: 1px solid var(--color-border, #e2e8f0); + border: 1px solid var(--color-border); border-radius: var(--radius-md, 8px); - background: var(--color-bg, #fff); + background: var(--color-bg); } .sched-card.draft { - border-left: 3px solid var(--color-warning, #f59e0b); + border-left: 3px solid var(--color-warning); } .sched-card-head { display: flex; @@ -1460,7 +1460,7 @@ import MediaLightbox from '$lib/components/MediaLightbox.svelte'; line-height: 0; } .thumb-btn:focus-visible { - outline: 2px solid var(--color-primary, #6366f1); + outline: 2px solid var(--color-primary); outline-offset: 2px; border-radius: var(--radius-sm); } From 1eb81c4fed36fd307820ade9db7f3d71908ca174 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Sun, 20 Sep 2026 22:25:07 +0700 Subject: [PATCH 02/10] fix(a11y): toast live region + role=alert on errors + per-type icons (#270) - container role=status aria-live=polite; error toasts role=alert - success/error/info icons give a non-color cue (WCAG 1.4.1) --- web/src/app.css | 10 ++++++++++ web/src/routes/admin/+layout.svelte | 16 +++++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/web/src/app.css b/web/src/app.css index 9989462..ddfe440 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -979,6 +979,16 @@ textarea.form-input { .toast--error { border-left: 3px solid var(--color-error); } .toast--info { border-left: 3px solid var(--color-info); } +/* Non-color cue per toast type (icon mirrors the border color) */ +.toast-icon { + flex-shrink: 0; + width: 1rem; + height: 1rem; +} +.toast--success .toast-icon { color: var(--color-success); } +.toast--error .toast-icon { color: var(--color-error); } +.toast--info .toast-icon { color: var(--color-info); } + /* ── Tabular numbers for data ── */ .tabular-nums { font-variant-numeric: tabular-nums; diff --git a/web/src/routes/admin/+layout.svelte b/web/src/routes/admin/+layout.svelte index a62f5bf..59ba953 100644 --- a/web/src/routes/admin/+layout.svelte +++ b/web/src/routes/admin/+layout.svelte @@ -8,6 +8,9 @@ import MessageSquare from '@lucide/svelte/icons/message-square'; import ChartLine from '@lucide/svelte/icons/chart-line'; import ImageIcon from '@lucide/svelte/icons/image'; import SettingsIcon from '@lucide/svelte/icons/settings'; +import CheckCircle2 from '@lucide/svelte/icons/check-circle-2'; +import XCircle from '@lucide/svelte/icons/x-circle'; +import Info from '@lucide/svelte/icons/info'; import '../../app.css'; import { getToasts } from '$lib/toast.svelte'; import { page } from '$app/state'; @@ -181,10 +184,17 @@ import SettingsIcon from '@lucide/svelte/icons/settings'; - -
+ +
{#each getToasts().toasts as t (t.id)} -
+
+ {#if t.type === 'success'} +
{/each} From f37be3d6f0efe0665d182d8fe59035eed8cc619a Mon Sep 17 00:00:00 2001 From: ajianaz Date: Sun, 20 Sep 2026 22:28:08 +0700 Subject: [PATCH 03/10] =?UTF-8?q?fix(ux):=20two-stage=20approve=20on=20HIT?= =?UTF-8?q?L=20schedules=20=E2=80=94=20arm=20then=20confirm=20(#271)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single mis-click no longer approves; button flips to 'Confirm?' for 3s with aria-pressed + consequence copy in title. Both table and card views. --- web/src/routes/admin/schedules/+page.svelte | 38 ++++++++++++++++++--- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/web/src/routes/admin/schedules/+page.svelte b/web/src/routes/admin/schedules/+page.svelte index 43fb0e8..5a5da31 100644 --- a/web/src/routes/admin/schedules/+page.svelte +++ b/web/src/routes/admin/schedules/+page.svelte @@ -145,6 +145,31 @@ import MediaLightbox from '$lib/components/MediaLightbox.svelte'; // Approve loading state let approvingId = $state(null); + // Two-stage approve: first click arms, second click within the window + // commits — protects the HITL gate from a single mis-click (#271). + let armedApproveId = $state(null); + let armTimer: ReturnType | null = null; + const APPROVE_ARM_MS = 3000; + + function requestApprove(schedule: Schedule) { + if (armedApproveId === schedule.id) { + handleApprove(schedule); + return; + } + disarmApprove(); + armedApproveId = schedule.id; + armTimer = setTimeout(() => { + armedApproveId = null; + armTimer = null; + }, APPROVE_ARM_MS); + } + + function disarmApprove() { + if (armTimer) clearTimeout(armTimer); + armTimer = null; + armedApproveId = null; + } + async function loadData() { loading = true; try { @@ -420,6 +445,7 @@ import MediaLightbox from '$lib/components/MediaLightbox.svelte'; toast(e.message || 'Failed to approve schedule', 'error'); } finally { approvingId = null; + disarmApprove(); } } @@ -727,10 +753,12 @@ import MediaLightbox from '$lib/components/MediaLightbox.svelte'; variant="default" size="sm" class="bg-[var(--color-success)]" - onclick={() => handleApprove(s)} + onclick={() => requestApprove(s)} disabled={approvingId === s.id} + aria-pressed={armedApproveId === s.id} + title={armedApproveId === s.id ? 'Click again to confirm publish' : 'Approve — publishes to Threads when due'} > - {approvingId === s.id ? '…' : 'Approve'} + {approvingId === s.id ? '…' : armedApproveId === s.id ? 'Confirm?' : 'Approve'} @@ -787,10 +815,12 @@ import MediaLightbox from '$lib/components/MediaLightbox.svelte'; variant="default" size="sm" class="bg-[var(--color-success)]" - onclick={() => handleApprove(s)} + onclick={() => requestApprove(s)} disabled={approvingId === s.id} + aria-pressed={armedApproveId === s.id} + title={armedApproveId === s.id ? 'Click again to confirm publish' : 'Approve — publishes to Threads when due'} > - {approvingId === s.id ? '…' : 'Approve'} + {approvingId === s.id ? '…' : armedApproveId === s.id ? 'Confirm?' : 'Approve'} From 34f5e04baccfe691315a691a7efe014ff016ec85 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Sun, 20 Sep 2026 22:31:52 +0700 Subject: [PATCH 04/10] fix(ux): title on truncated text + keyboard-accessible sort headers (#275) - truncate sites get title attr (dashboard captions, media filename) - DataTable sortable th: native button inside th + aria-sort; rows unchanged --- web/src/lib/components/DataTable.svelte | 39 +++++++++++++++++---- web/src/routes/admin/dashboard/+page.svelte | 4 +-- web/src/routes/admin/media/+page.svelte | 2 +- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/web/src/lib/components/DataTable.svelte b/web/src/lib/components/DataTable.svelte index f651bbe..2595dbc 100644 --- a/web/src/lib/components/DataTable.svelte +++ b/web/src/lib/components/DataTable.svelte @@ -122,12 +122,22 @@ {#each allColumns as col} col.sortable && toggleSort(col.key)} + class={[col.sortable ? 'sort-th' : '', colClass(col)].filter(Boolean).join(' ')} + aria-sort={col.sortable ? (sortKey === col.key ? (sortAsc ? 'ascending' : 'descending') : 'none') : undefined} > - {col.label} - {#if col.sortable && sortKey === col.key} - {sortAsc ? '↑' : '↓'} + {#if col.sortable} + + {:else} + {col.label} {/if} {/each} @@ -202,7 +212,24 @@ } :global(.row-detail td) { - background: var(--color-bg-subtle, rgba(0, 0, 0, 0.025)); + background: var(--color-bg-subtle); border-top: none; } + + /* Sortable header: native button for keyboard access (Enter/Space), + styled to visually merge with the th (#275). */ + .th-sort-btn { + display: inline-flex; + align-items: center; + gap: 0.25rem; + background: none; + border: none; + padding: 0; + margin: 0; + font: inherit; + letter-spacing: inherit; + text-transform: inherit; + color: inherit; + cursor: pointer; + } diff --git a/web/src/routes/admin/dashboard/+page.svelte b/web/src/routes/admin/dashboard/+page.svelte index 8a56bc2..c2d9191 100644 --- a/web/src/routes/admin/dashboard/+page.svelte +++ b/web/src/routes/admin/dashboard/+page.svelte @@ -316,7 +316,7 @@ {#each recentPosts as post}
  • - {post.caption || '(no caption)'} + {post.caption || '(no caption)'}
    @@ -342,7 +342,7 @@ {#each upcoming.slice(0, 5) as schedule}
  • - {schedule.caption || '(no caption)'} + {schedule.caption || '(no caption)'}
    diff --git a/web/src/routes/admin/media/+page.svelte b/web/src/routes/admin/media/+page.svelte index 0fb7983..ab63353 100644 --- a/web/src/routes/admin/media/+page.svelte +++ b/web/src/routes/admin/media/+page.svelte @@ -360,7 +360,7 @@
    -

    {previewItem.filename}

    +

    {previewItem.filename}

    {previewItem.content_type} · {formatSize(previewItem.size_bytes)}

    From 93518f175d79a35ea13b6d1e8c1d486a2d3e6203 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Sun, 20 Sep 2026 22:34:12 +0700 Subject: [PATCH 05/10] fix(ux): mobile drawer Escape+scroll lock, distinct nav icons, dead switcher removed (#275) - Escape closes mobile drawer; body scroll locked while open - Mentions nav icon -> AtSign (was duplicate of Comments) - sidebar footer clock emoji -> Lucide Clock - remove never-rendered account-switcher state/handler/CSS - StatSkeleton: drop unused label prop, fix invalid h-2.4 class --- web/src/lib/components/StatSkeleton.svelte | 8 +-- web/src/routes/admin/+layout.svelte | 73 +++++++--------------- 2 files changed, 23 insertions(+), 58 deletions(-) diff --git a/web/src/lib/components/StatSkeleton.svelte b/web/src/lib/components/StatSkeleton.svelte index 4239e46..aa2741e 100644 --- a/web/src/lib/components/StatSkeleton.svelte +++ b/web/src/lib/components/StatSkeleton.svelte @@ -1,14 +1,8 @@
    - +
    diff --git a/web/src/routes/admin/+layout.svelte b/web/src/routes/admin/+layout.svelte index 59ba953..5a1528a 100644 --- a/web/src/routes/admin/+layout.svelte +++ b/web/src/routes/admin/+layout.svelte @@ -11,11 +11,12 @@ import SettingsIcon from '@lucide/svelte/icons/settings'; import CheckCircle2 from '@lucide/svelte/icons/check-circle-2'; import XCircle from '@lucide/svelte/icons/x-circle'; import Info from '@lucide/svelte/icons/info'; +import AtSign from '@lucide/svelte/icons/at-sign'; +import Clock from '@lucide/svelte/icons/clock'; import '../../app.css'; import { getToasts } from '$lib/toast.svelte'; import { page } from '$app/state'; - import { checkSession, logout, listAccounts } from '$lib/api'; - import type { Account } from '$lib/types'; + import { checkSession, logout } from '$lib/api'; import { fetchTimezone, getTimezone } from '$lib/tz'; import { goto } from '$app/navigation'; @@ -26,36 +27,18 @@ import Info from '@lucide/svelte/icons/info'; let appVersion = $state(''); let tzLabel = $state(''); - // Global account switcher: akun aktif dipakai sebagai default filter - // di Schedules/Posts/Calendar (halaman tetap bisa override). - let accounts = $state([]); - let accountsLoaded = $state(false); - let activeAccountId = $state( - typeof localStorage !== 'undefined' - ? localStorage.getItem('titen.activeAccount') || 'all' - : 'all' - ); - - function setActiveAccount(id: string) { - activeAccountId = id; - try { - localStorage.setItem('titen.activeAccount', id); - } catch { - /* private mode — ignore */ - } + // Mobile drawer: Escape closes; body scroll locked while open (#275) + function onWindowKeydown(e: KeyboardEvent) { + if (e.key === 'Escape' && sidebarOpen) closeSidebar(); } $effect(() => { - if (!authed || accountsLoaded) return; - (async () => { - try { - accounts = (await listAccounts()) ?? []; - } catch { - /* non-fatal — switcher stays empty */ - } finally { - accountsLoaded = true; // guard against re-fetch loops - } - })(); + if (!sidebarOpen) return; + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = prev; + }; }); $effect(() => { @@ -92,7 +75,7 @@ import Info from '@lucide/svelte/icons/info'; { href: '/admin/schedules', label: 'Schedules', icon: CalendarClock }, { href: '/admin/calendar', label: 'Calendar', icon: CalendarDays }, { href: '/admin/comments', label: 'Comments', icon: MessageSquare }, - { href: '/admin/mentions', label: 'Mentions', icon: MessageSquare }, + { href: '/admin/mentions', label: 'Mentions', icon: AtSign }, { href: '/admin/analytics', label: 'Analytics', icon: ChartLine }, { href: '/admin/media', label: 'Media', icon: ImageIcon }, { href: '/admin/settings', label: 'Settings', icon: SettingsIcon }, @@ -116,6 +99,8 @@ import Info from '@lucide/svelte/icons/info'; Titen Admin + +
    @@ -167,7 +152,7 @@ import Info from '@lucide/svelte/icons/info'; {#if appVersion}v{appVersion}{/if} - {#if tzLabel}🕒 {tzLabel}{/if} + {#if tzLabel}{/if}
    @@ -201,26 +186,9 @@ import Info from '@lucide/svelte/icons/info';