feat: production-harden app and finalize merge-ready UX/theme baseline

- complete tokenized theme preset system and admin style guide visibility
- refine dashboard layout/scroll behavior and shared async/request UI states
- add user nav-visibility settings across API, migration, and frontend stores
- harden security headers/CSP handling and related test coverage
- enforce CI repository hygiene + frontend contract/theme/build quality gates
- update docs/env references and make migration smoke test head-aware
This commit is contained in:
Justin Visser 2026-02-19 15:43:20 +01:00
parent ab1d29b203
commit ae2ca8f149
66 changed files with 5655 additions and 853 deletions

View file

@ -2,7 +2,9 @@
import { computed } from "vue";
import AppButton from "@/components/ui/AppButton.vue";
import AppSelect from "@/components/ui/AppSelect.vue";
import { useThemeStore } from "@/stores/theme";
import type { ThemePresetId } from "@/theme/presets";
const theme = useThemeStore();
@ -10,6 +12,12 @@ const isDarkTheme = computed(() => theme.active === "dark");
const toggleThemeLabel = computed(() =>
isDarkTheme.value ? "Switch to light theme" : "Switch to dark theme",
);
const selectedPreset = computed<ThemePresetId>({
get: () => theme.preset,
set: (value) => theme.setPreset(value),
});
const presetOptions = computed(() => theme.availablePresets);
const themePresetLabel = computed(() => theme.presetLabel);
function onToggleTheme(): void {
theme.setPreference(isDarkTheme.value ? "light" : "dark");
@ -17,18 +25,32 @@ function onToggleTheme(): void {
</script>
<template>
<header class="sticky top-0 z-30 border-b border-zinc-200/70 bg-white/90 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/90">
<header class="sticky top-0 z-30 border-b border-stroke-subtle bg-surface-nav/90 backdrop-blur">
<div class="flex min-h-[4.5rem] w-full items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
<div class="flex min-w-0 items-center gap-2">
<RouterLink
to="/dashboard"
class="rounded-sm font-display text-lg tracking-tight text-zinc-900 transition hover:text-zinc-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:text-zinc-100 dark:hover:text-zinc-300 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950"
class="rounded-sm font-display text-lg tracking-tight text-ink-primary transition hover:text-ink-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
>
scholarr
</RouterLink>
</div>
<div class="flex items-center justify-end">
<div class="flex items-center justify-end gap-2">
<div class="w-36 sm:w-44">
<label for="theme-preset-select" class="sr-only">Theme preset</label>
<AppSelect
id="theme-preset-select"
v-model="selectedPreset"
:disabled="presetOptions.length <= 1"
:title="`Theme preset: ${themePresetLabel}`"
class="py-1.5"
>
<option v-for="preset in presetOptions" :key="preset.id" :value="preset.id">
{{ preset.label }}
</option>
</AppSelect>
</div>
<AppButton
variant="ghost"
class="h-10 w-10 rounded-full p-0"

View file

@ -4,24 +4,29 @@ import { useRouter } from "vue-router";
import AppButton from "@/components/ui/AppButton.vue";
import { useAuthStore } from "@/stores/auth";
import { useUserSettingsStore } from "@/stores/user_settings";
const auth = useAuthStore();
const userSettings = useUserSettingsStore();
const router = useRouter();
const links = computed(() => {
const base = [
{ to: "/dashboard", label: "Dashboard" },
{ to: "/scholars", label: "Scholars" },
{ to: "/publications", label: "Publications" },
{ to: "/settings", label: "Settings" },
{ id: "dashboard", to: "/dashboard", label: "Dashboard", adminOnly: false },
{ id: "scholars", to: "/scholars", label: "Scholars", adminOnly: false },
{ id: "publications", to: "/publications", label: "Publications", adminOnly: false },
{ id: "settings", to: "/settings", label: "Settings", adminOnly: false },
{ id: "style-guide", to: "/admin/style-guide", label: "Style Guide", adminOnly: true },
{ id: "runs", to: "/admin/runs", label: "Runs", adminOnly: true },
{ id: "users", to: "/admin/users", label: "Users", adminOnly: true },
];
if (auth.isAdmin) {
base.push({ to: "/admin/runs", label: "Runs" });
base.push({ to: "/admin/users", label: "Users" });
}
return base;
return base.filter((item) => {
if (item.adminOnly && !auth.isAdmin) {
return false;
}
return userSettings.isPageVisible(item.id);
});
});
async function onLogout(): Promise<void> {
@ -32,7 +37,7 @@ async function onLogout(): Promise<void> {
<template>
<aside
class="min-w-0 border-b border-zinc-200 bg-white/70 px-4 py-4 dark:border-zinc-800 dark:bg-zinc-900/70 lg:h-full lg:min-h-0 lg:overflow-y-auto lg:border-b-0 lg:border-r lg:px-5 lg:py-6"
class="min-h-0 min-w-0 overflow-x-hidden border-b border-stroke-subtle bg-surface-nav/70 px-4 py-4 lg:h-full lg:border-b-0 lg:border-r lg:px-5 lg:py-6"
>
<div class="flex h-full min-h-0 flex-col gap-4">
<nav class="grid grid-cols-2 content-start gap-2 sm:grid-cols-3 lg:grid-cols-1" aria-label="Primary">
@ -40,15 +45,15 @@ async function onLogout(): Promise<void> {
v-for="link in links"
:key="link.to"
:to="link.to"
class="inline-flex min-h-10 min-w-0 items-center rounded-lg border border-transparent px-3 py-2 text-sm font-medium text-zinc-600 transition hover:border-zinc-300 hover:bg-zinc-50 hover:text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800 dark:hover:text-zinc-100 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950"
active-class="border-zinc-900 bg-zinc-900 text-white hover:border-zinc-900 hover:bg-zinc-900 hover:text-white dark:border-brand-400 dark:bg-brand-400 dark:text-zinc-950 dark:hover:border-brand-300 dark:hover:bg-brand-300"
class="inline-flex min-h-10 min-w-0 items-center rounded-lg border border-transparent px-3 py-2 text-sm font-medium text-ink-secondary transition hover:border-stroke-interactive hover:bg-surface-card-muted hover:text-ink-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
active-class="border-stroke-interactive bg-surface-nav-active font-semibold text-ink-primary shadow-sm hover:border-stroke-interactive hover:bg-surface-nav-active hover:text-ink-primary"
>
{{ link.label }}
</RouterLink>
</nav>
<div class="mt-auto grid gap-2 border-t border-zinc-200 pt-3 dark:border-zinc-800">
<p class="truncate text-xs text-zinc-500 dark:text-zinc-400">{{ auth.user?.email }}</p>
<div class="mt-auto grid gap-2 border-t border-stroke-subtle pt-3">
<p class="truncate text-xs text-ink-muted">{{ auth.user?.email }}</p>
<AppButton variant="ghost" class="w-full justify-start" @click="onLogout">Logout</AppButton>
</div>
</div>

View file

@ -1,16 +1,31 @@
<script setup lang="ts">
defineProps<{
title: string;
subtitle?: string;
}>();
const props = withDefaults(
defineProps<{
title: string;
subtitle?: string;
fill?: boolean;
}>(),
{
fill: false,
},
);
</script>
<template>
<section class="page-stack min-w-0">
<section
:class="
props.fill
? 'min-w-0 flex min-h-0 flex-col gap-6 lg:h-full'
: 'page-stack min-w-0'
"
>
<header class="space-y-2">
<h1 class="page-title">{{ title }}</h1>
<p v-if="subtitle" class="page-subtitle">{{ subtitle }}</p>
<h1 class="page-title">{{ props.title }}</h1>
<p v-if="props.subtitle" class="page-subtitle">{{ props.subtitle }}</p>
</header>
<slot />
<div v-if="props.fill" class="min-h-0 flex-1">
<slot />
</div>
<slot v-else />
</section>
</template>

View file

@ -0,0 +1,34 @@
<script setup lang="ts">
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
import AppSkeleton from "@/components/ui/AppSkeleton.vue";
const props = withDefaults(
defineProps<{
loading: boolean;
loadingLines?: number;
empty?: boolean;
emptyTitle?: string;
emptyBody?: string;
showEmpty?: boolean;
}>(),
{
loadingLines: 6,
empty: false,
emptyTitle: "No data available",
emptyBody: "No records matched this request.",
showEmpty: true,
},
);
</script>
<template>
<AppSkeleton v-if="props.loading" :lines="props.loadingLines" />
<AppEmptyState
v-else-if="props.showEmpty && props.empty"
:title="props.emptyTitle"
:body="props.emptyBody"
>
<slot name="empty" />
</AppEmptyState>
<slot v-else />
</template>

View file

@ -8,14 +8,19 @@ const props = defineProps<{
<template>
<span
class="inline-flex flex-wrap items-center gap-2 rounded-full border border-zinc-300 bg-zinc-100 px-3 py-1 text-xs text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"
class="inline-flex flex-wrap items-center gap-2 rounded-xl border border-stroke-default bg-surface-card-muted/90 px-3 py-1.5 text-xs text-ink-secondary"
>
<span class="font-medium">Queue</span>
<span class="rounded-full bg-zinc-200 px-2 py-0.5 dark:bg-zinc-700">{{ props.queued }} queued</span>
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300">
<span class="font-semibold tracking-wide text-ink-primary">Queue</span>
<span class="inline-flex items-center gap-1 rounded-full bg-surface-card px-2 py-0.5">
<span class="h-1.5 w-1.5 rounded-full bg-ink-muted" />
{{ props.queued }} queued
</span>
<span class="inline-flex items-center gap-1 rounded-full bg-state-warning-bg px-2 py-0.5 text-state-warning-text">
<span class="h-1.5 w-1.5 rounded-full bg-warning-500" />
{{ props.retrying }} retrying
</span>
<span class="rounded-full bg-rose-100 px-2 py-0.5 text-rose-700 dark:bg-rose-950/50 dark:text-rose-300">
<span class="inline-flex items-center gap-1 rounded-full bg-state-danger-bg px-2 py-0.5 text-state-danger-text">
<span class="h-1.5 w-1.5 rounded-full bg-danger-500" />
{{ props.dropped }} dropped
</span>
</span>

View file

@ -0,0 +1,44 @@
<script setup lang="ts">
import AppAlert from "@/components/ui/AppAlert.vue";
const props = withDefaults(
defineProps<{
successMessage?: string | null;
successTitle?: string;
errorMessage?: string | null;
errorRequestId?: string | null;
errorTitle?: string;
}>(),
{
successMessage: null,
successTitle: "Operation complete",
errorMessage: null,
errorRequestId: null,
errorTitle: "Request failed",
},
);
const emit = defineEmits<{
dismissSuccess: [];
}>();
</script>
<template>
<div class="space-y-3">
<AppAlert
v-if="props.successMessage"
tone="success"
dismissible
@dismiss="emit('dismissSuccess')"
>
<template #title>{{ props.successTitle }}</template>
<p>{{ props.successMessage }}</p>
</AppAlert>
<AppAlert v-if="props.errorMessage" tone="danger">
<template #title>{{ props.errorTitle }}</template>
<p>{{ props.errorMessage }}</p>
<p class="text-secondary">Request ID: {{ props.errorRequestId || "n/a" }}</p>
</AppAlert>
</div>
</template>

View file

@ -9,18 +9,18 @@ const label = computed(() => props.status.replaceAll("_", " "));
const toneClass = computed(() => {
if (props.status === "success") {
return "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300";
return "border-state-success-border bg-state-success-bg text-state-success-text";
}
if (props.status === "partial_failure") {
return "border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-300";
return "border-state-warning-border bg-state-warning-bg text-state-warning-text";
}
if (props.status === "failed") {
return "border-rose-300 bg-rose-50 text-rose-700 dark:border-rose-800 dark:bg-rose-950/50 dark:text-rose-300";
return "border-state-danger-border bg-state-danger-bg text-state-danger-text";
}
if (props.status === "running") {
return "border-brand-300 bg-brand-50 text-brand-700 dark:border-brand-800 dark:bg-brand-950/50 dark:text-brand-300";
return "border-state-info-border bg-state-info-bg text-state-info-text";
}
return "border-zinc-300 bg-zinc-100 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300";
return "border-stroke-default bg-surface-card-muted text-ink-secondary";
});
</script>

View file

@ -18,15 +18,15 @@ const emit = defineEmits<{ dismiss: [] }>();
const toneClass = computed(() => {
if (props.tone === "danger") {
return "border-rose-300 bg-rose-50 text-rose-900 dark:border-rose-800 dark:bg-rose-950/45 dark:text-rose-200";
return "border-state-danger-border bg-state-danger-bg text-state-danger-text";
}
if (props.tone === "warning") {
return "border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-800 dark:bg-amber-950/45 dark:text-amber-200";
return "border-state-warning-border bg-state-warning-bg text-state-warning-text";
}
if (props.tone === "success") {
return "border-emerald-300 bg-emerald-50 text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/45 dark:text-emerald-200";
return "border-state-success-border bg-state-success-bg text-state-success-text";
}
return "border-brand-300 bg-brand-50 text-brand-900 dark:border-brand-800 dark:bg-brand-950/45 dark:text-brand-200";
return "border-state-info-border bg-state-info-bg text-state-info-text";
});
const alertRole = computed(() => (props.tone === "danger" || props.tone === "warning" ? "alert" : "status"));

View file

@ -7,22 +7,22 @@ const props = defineProps<{
const toneClass = computed(() => {
if (props.tone === "success") {
return "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300";
return "border-state-success-border bg-state-success-bg text-state-success-text";
}
if (props.tone === "warning") {
return "border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-300";
return "border-state-warning-border bg-state-warning-bg text-state-warning-text";
}
if (props.tone === "danger") {
return "border-rose-300 bg-rose-50 text-rose-700 dark:border-rose-800 dark:bg-rose-950/50 dark:text-rose-300";
return "border-state-danger-border bg-state-danger-bg text-state-danger-text";
}
if (props.tone === "info") {
return "border-brand-300 bg-brand-50 text-brand-700 dark:border-brand-800 dark:bg-brand-950/50 dark:text-brand-300";
return "border-state-info-border bg-state-info-bg text-state-info-text";
}
return "border-zinc-300 bg-zinc-100 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300";
return "border-stroke-default bg-surface-card-muted text-ink-secondary";
});
</script>

View file

@ -16,24 +16,24 @@ const props = withDefaults(
const variantClass = computed(() => {
if (props.variant === "secondary") {
return "border-zinc-300 bg-zinc-100 text-zinc-900 hover:bg-zinc-200 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700";
return "border-action-secondary-border bg-action-secondary-bg text-action-secondary-text shadow-sm hover:border-action-secondary-hover-border hover:bg-action-secondary-hover-bg hover:text-action-secondary-hover-text";
}
if (props.variant === "ghost") {
return "border-zinc-300 bg-transparent text-zinc-700 hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800";
return "border-action-ghost-border bg-action-ghost-bg text-action-ghost-text hover:border-action-ghost-hover-border hover:bg-action-ghost-hover-bg hover:text-action-ghost-hover-text";
}
if (props.variant === "danger") {
return "border-rose-300 bg-rose-100 text-rose-800 hover:bg-rose-200 dark:border-rose-800 dark:bg-rose-950/60 dark:text-rose-200 dark:hover:bg-rose-900/70";
return "border-action-danger-border bg-action-danger-bg text-action-danger-text shadow-sm hover:border-action-danger-hover-border hover:bg-action-danger-hover-bg hover:text-action-danger-hover-text";
}
return "border-brand-700 bg-brand-700 text-white hover:bg-brand-600 dark:border-brand-400 dark:bg-brand-400 dark:text-zinc-950 dark:hover:bg-brand-300";
return "border-action-primary-border bg-action-primary-bg text-action-primary-text shadow-sm hover:border-action-primary-hover-border hover:bg-action-primary-hover-bg hover:text-action-primary-hover-text";
});
</script>
<template>
<button
class="inline-flex min-h-10 items-center justify-center rounded-lg border px-3 py-2 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 disabled:cursor-not-allowed disabled:opacity-60 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950"
class="inline-flex min-h-10 items-center justify-center rounded-lg border px-3 py-2 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-60"
:class="variantClass"
:type="props.type"
:disabled="props.disabled"

View file

@ -1,5 +1,5 @@
<template>
<article class="min-w-0 rounded-2xl border border-zinc-200 bg-white p-5 shadow-panel dark:border-zinc-800 dark:bg-zinc-900">
<article class="min-w-0 rounded-2xl border border-stroke-default bg-surface-card p-5 shadow-panel">
<slot />
</article>
</template>

View file

@ -9,11 +9,11 @@ defineProps<{
</script>
<template>
<label class="inline-flex min-h-10 items-center gap-2 text-sm text-zinc-800 dark:text-zinc-200" :for="id">
<label class="inline-flex min-h-10 items-center gap-2 text-sm text-ink-primary" :for="id">
<input
:id="id"
v-model="model"
class="h-4 w-4 rounded border-zinc-400 text-brand-600 focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:border-zinc-600 dark:bg-zinc-900 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950"
class="h-4 w-4 rounded border-stroke-interactive bg-surface-input text-brand-600 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
type="checkbox"
:disabled="disabled"
/>

View file

@ -6,9 +6,9 @@ defineProps<{
</script>
<template>
<section class="rounded-xl border border-dashed border-zinc-300 bg-zinc-50 p-5 dark:border-zinc-700 dark:bg-zinc-900/60">
<h3 class="font-display text-lg font-semibold text-zinc-900 dark:text-zinc-100">{{ title }}</h3>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{{ body }}</p>
<section class="rounded-xl border border-dashed border-stroke-interactive bg-surface-card-muted p-5">
<h3 class="font-display text-lg font-semibold text-ink-primary">{{ title }}</h3>
<p class="mt-1 text-sm text-ink-secondary">{{ body }}</p>
<div v-if="$slots.default" class="mt-4">
<slot />
</div>

View file

@ -0,0 +1,209 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, useSlots, watch } from "vue";
type TooltipSide = "right" | "left" | "top" | "bottom";
const props = withDefaults(
defineProps<{
text: string;
side?: TooltipSide | "auto";
}>(),
{
side: "auto",
},
);
const slots = useSlots();
const GAP = 8;
const EDGE_PADDING = 8;
const triggerRef = ref<HTMLElement | null>(null);
const tooltipRef = ref<HTMLElement | null>(null);
const isOpen = ref(false);
const resolvedSide = ref<TooltipSide>("top");
const tooltipStyle = ref<Record<string, string>>({
left: "0px",
top: "0px",
});
const sideClass = computed(() => {
if (resolvedSide.value === "left") {
return "origin-right";
}
if (resolvedSide.value === "right") {
return "origin-left";
}
if (resolvedSide.value === "bottom") {
return "origin-top";
}
return "origin-bottom";
});
const hasTriggerSlot = computed(() => Boolean(slots.trigger));
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(Math.max(value, minimum), maximum);
}
function spaceBySide(rect: DOMRect): Record<TooltipSide, number> {
return {
top: rect.top,
right: window.innerWidth - rect.right,
bottom: window.innerHeight - rect.bottom,
left: rect.left,
};
}
function preferredSides(): TooltipSide[] {
const allSides: TooltipSide[] = ["top", "right", "bottom", "left"];
if (!props.side || props.side === "auto") {
return allSides;
}
return [props.side, ...allSides.filter((item) => item !== props.side)];
}
function chooseSide(rect: DOMRect, tooltipRect: DOMRect): TooltipSide {
const spaces = spaceBySide(rect);
const order = preferredSides();
for (const side of order) {
if (side === "top" || side === "bottom") {
if (spaces[side] >= tooltipRect.height + GAP) {
return side;
}
continue;
}
if (spaces[side] >= tooltipRect.width + GAP) {
return side;
}
}
return order.sort((left, right) => spaces[right] - spaces[left])[0] ?? "top";
}
function updatePosition(): void {
if (!isOpen.value || !triggerRef.value || !tooltipRef.value) {
return;
}
const trigger = triggerRef.value.getBoundingClientRect();
const tooltip = tooltipRef.value.getBoundingClientRect();
const side = chooseSide(trigger, tooltip);
resolvedSide.value = side;
let left = 0;
let top = 0;
if (side === "top") {
left = trigger.left + trigger.width / 2 - tooltip.width / 2;
top = trigger.top - tooltip.height - GAP;
} else if (side === "bottom") {
left = trigger.left + trigger.width / 2 - tooltip.width / 2;
top = trigger.bottom + GAP;
} else if (side === "left") {
left = trigger.left - tooltip.width - GAP;
top = trigger.top + trigger.height / 2 - tooltip.height / 2;
} else {
left = trigger.right + GAP;
top = trigger.top + trigger.height / 2 - tooltip.height / 2;
}
left = clamp(left, EDGE_PADDING, window.innerWidth - tooltip.width - EDGE_PADDING);
top = clamp(top, EDGE_PADDING, window.innerHeight - tooltip.height - EDGE_PADDING);
tooltipStyle.value = {
left: `${Math.round(left)}px`,
top: `${Math.round(top)}px`,
};
}
function closeTooltip(): void {
isOpen.value = false;
}
function openTooltip(): void {
isOpen.value = true;
void nextTick(() => {
updatePosition();
});
}
function onKeydown(event: KeyboardEvent): void {
if (event.key === "Escape") {
closeTooltip();
}
}
function onViewportChange(): void {
if (!isOpen.value) {
return;
}
updatePosition();
}
watch(isOpen, (open) => {
if (open) {
window.addEventListener("resize", onViewportChange);
window.addEventListener("scroll", onViewportChange, true);
window.addEventListener("keydown", onKeydown);
return;
}
window.removeEventListener("resize", onViewportChange);
window.removeEventListener("scroll", onViewportChange, true);
window.removeEventListener("keydown", onKeydown);
});
watch(
() => props.text,
() => {
if (isOpen.value) {
void nextTick(() => {
updatePosition();
});
}
},
);
onBeforeUnmount(() => {
window.removeEventListener("resize", onViewportChange);
window.removeEventListener("scroll", onViewportChange, true);
window.removeEventListener("keydown", onKeydown);
});
</script>
<template>
<span class="relative inline-flex items-center align-middle">
<component
:is="hasTriggerSlot ? 'span' : 'button'"
ref="triggerRef"
:type="hasTriggerSlot ? undefined : 'button'"
:tabindex="hasTriggerSlot ? 0 : undefined"
class="inline-flex items-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
:class="
hasTriggerSlot
? 'cursor-help rounded-sm'
: 'h-5 w-5 justify-center rounded-full border border-stroke-interactive/80 bg-surface-card-muted/80 text-[11px] font-semibold text-ink-secondary transition hover:bg-action-secondary-hover-bg'
"
:aria-label="text"
@mouseenter="openTooltip"
@mouseleave="closeTooltip"
@focus="openTooltip"
@blur="closeTooltip"
>
<slot v-if="hasTriggerSlot" name="trigger" />
<template v-else>?</template>
</component>
<Teleport to="body">
<span
v-if="isOpen"
ref="tooltipRef"
role="tooltip"
:style="tooltipStyle"
:class="sideClass"
class="pointer-events-none fixed z-[90] w-64 max-w-[calc(100vw-2rem)] rounded-lg border border-stroke-default bg-surface-card/95 px-2.5 py-2 text-xs leading-relaxed text-ink-secondary shadow-lg"
>
{{ text }}
</span>
</Teleport>
</span>
</template>

View file

@ -12,7 +12,7 @@ defineProps<{
<template>
<input
v-model="model"
class="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none ring-brand-300 transition placeholder:text-zinc-400 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:ring-brand-400 dark:focus-visible:ring-offset-zinc-950 dark:placeholder:text-zinc-500"
class="w-full rounded-lg border border-stroke-interactive bg-surface-input px-3 py-2 text-sm text-ink-primary outline-none transition placeholder:text-ink-muted focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-60"
:id="id"
:type="type || 'text'"
:placeholder="placeholder"

View file

@ -8,9 +8,9 @@ const emit = defineEmits<{ close: [] }>();
</script>
<template>
<div v-if="props.open" class="fixed inset-0 z-50 grid place-items-center bg-zinc-950/60 p-4" @click.self="emit('close')">
<div class="w-full max-w-lg rounded-2xl border border-zinc-200 bg-white p-6 shadow-panel dark:border-zinc-800 dark:bg-zinc-900">
<h3 class="mb-4 font-display text-xl font-semibold text-zinc-900 dark:text-zinc-100">{{ props.title }}</h3>
<div v-if="props.open" class="fixed inset-0 z-50 grid place-items-center bg-surface-overlay/60 p-4" @click.self="emit('close')">
<div class="w-full max-w-lg rounded-2xl border border-stroke-default bg-surface-card p-6 shadow-panel">
<h3 class="mb-4 font-display text-xl font-semibold text-ink-primary">{{ props.title }}</h3>
<slot />
</div>
</div>

View file

@ -10,7 +10,7 @@ defineProps<{
<template>
<select
v-model="model"
class="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none ring-brand-300 transition focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:ring-brand-400 dark:focus-visible:ring-offset-zinc-950"
class="w-full rounded-lg border border-stroke-interactive bg-surface-input px-3 py-2 text-sm text-ink-primary outline-none transition focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-60"
:id="id"
:disabled="disabled"
>

View file

@ -9,7 +9,7 @@ defineProps<{
<span
v-for="line in lines || 3"
:key="line"
class="block h-3 animate-pulse rounded-full bg-zinc-200 dark:bg-zinc-800"
class="block h-3 animate-pulse rounded-full bg-surface-card-muted"
/>
</div>
</template>

View file

@ -6,10 +6,10 @@ defineProps<{
<template>
<div
class="max-w-full overflow-x-auto rounded-xl border border-zinc-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:border-zinc-800 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950"
class="max-w-full overflow-x-auto rounded-xl border border-stroke-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
tabindex="0"
>
<table class="min-w-full border-collapse bg-white text-sm dark:bg-zinc-900" :aria-label="label || 'Data table'">
<table class="min-w-full border-collapse bg-surface-table text-sm" :aria-label="label || 'Data table'">
<slot />
</table>
</div>
@ -18,14 +18,14 @@ defineProps<{
<style scoped>
:deep(th),
:deep(td) {
@apply border-b border-zinc-200 px-3 py-2 text-left align-top dark:border-zinc-800;
@apply border-b border-stroke-default px-3 py-2 text-left align-top;
}
:deep(th) {
@apply sticky top-0 z-10 bg-zinc-100 font-semibold text-zinc-800 dark:bg-zinc-900 dark:text-zinc-200;
@apply sticky top-0 z-10 bg-surface-table-header font-semibold text-ink-primary;
}
:deep(td) {
@apply text-zinc-700 dark:text-zinc-300;
@apply text-ink-secondary;
}
</style>