Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions e2e/i18n.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { waitForHydration } from './support/helpers';
/** Load the saved auth storage state, optionally stripping the locale cookie. */
function loadAuthState(projectName: string, { withoutLocale = false } = {}) {
const authFile = path.join(import.meta.dirname, `.auth/user-setup-${projectName}.json`);
// eslint-disable-next-line security/detect-non-literal-fs-filename
const state = JSON.parse(fs.readFileSync(authFile, 'utf-8'));
if (withoutLocale) {
state.cookies = state.cookies.filter((c: { name: string }) => c.name !== 'PARAGLIDE_LOCALE');
Expand Down
1 change: 1 addition & 0 deletions e2e/support/global-setup.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable security/detect-non-literal-fs-filename */
import fs from 'node:fs';
import path from 'path';

Expand Down
17 changes: 14 additions & 3 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import prettier from 'eslint-config-prettier';
import js from '@eslint/js';
import { includeIgnoreFile } from '@eslint/compat';
import { defineConfig } from 'eslint/config';
import svelte from 'eslint-plugin-svelte';
import betterTailwindcss from 'eslint-plugin-better-tailwindcss';
import security from 'eslint-plugin-security';
Expand All @@ -10,7 +11,7 @@ import ts from 'typescript-eslint';

const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));

export default ts.config(
export default defineConfig(
includeIgnoreFile(gitignorePath),
{ ignores: ['src/lib/editor/generated/**'] },
js.configs.recommended,
Expand All @@ -27,7 +28,9 @@ export default ts.config(
'no-undef': 'off',
// It will currently also error on external links or links with query parameters
// https://github.com/sveltejs/eslint-plugin-svelte/issues/1353
'svelte/no-navigation-without-resolve': 'warn'
'svelte/no-navigation-without-resolve': 'warn',
// Too common occurance in this project, disabling for now
'security/detect-object-injection': 'off'
}
},
{
Expand All @@ -49,7 +52,15 @@ export default ts.config(
}
},
rules: {
'better-tailwindcss/no-unknown-classes': ['warn', { detectComponentClasses: true }]
'better-tailwindcss/no-unknown-classes': [
'warn',
{
detectComponentClasses: true,
// DaisyUIs nestes selectors are not detected by this rule.
// Ignore them to catch outdated classes from DaisyUI v4 Agents seem to love.
ignore: ['dropdown-content', 'tab-active', 'swap-on', 'swap-off', 'menu-disabled']
}
]
}
}
);
2 changes: 2 additions & 0 deletions messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@
"header_user_menu": "Benutzermenü",
"theme_switch_light": "Zum hellen Modus wechseln",
"theme_switch_dark": "Zum dunklen Modus wechseln",
"theme_toggle": "Design umschalten",
"page_title_dashboard": "Dashboard",
"page_title_default": "Stackable",
"page_title_suffix": "Stackable",
"meta_description": "Stackable Unified Data Platform UI – verwalten Sie Ihre Dateninfrastruktur über eine zentrale Oberfläche.",
"page_title_trino": "Trino",
"setup_steps_label": "Einrichtungsschritte",
"language_label": "Sprache",
Expand Down
2 changes: 2 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@
"header_user_menu": "User menu",
"theme_switch_light": "Switch to light mode",
"theme_switch_dark": "Switch to dark mode",
"theme_toggle": "Toggle theme",
"page_title_dashboard": "Dashboard",
"page_title_default": "Stackable",
"page_title_suffix": "Stackable",
"meta_description": "Stackable Unified Data Platform UI — manage your data infrastructure from a single interface.",
"page_title_trino": "Trino",
"setup_steps_label": "Setup steps",
"language_label": "Language",
Expand Down
50 changes: 31 additions & 19 deletions src/app.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
@import 'tailwindcss';
@plugin 'daisyui';

/*
* DaisyUI's shipped dark-theme --color-primary (oklch(58% 0.233 277.117)) only reaches
* ~3.3:1 contrast against base-100/base-200, failing WCAG AA (4.5:1) for text-primary used
* as body text (e.g. active nav labels). This keeps the same hue while satisfying contrast.
*/
[data-theme='dark'] {
--color-primary: oklch(70% 0.17 277.117);
--color-primary-content: oklch(24% 0.12 277.117);
}

/* Subtle highlight for the currently executing statement in the Monaco editor */
.highlighted-statement {
background-color: oklch(from var(--color-primary) l c h / 0.08);
Expand Down Expand Up @@ -28,27 +38,29 @@ progress.progress::-moz-progress-bar {
* Visible scrollbars for preview panels. Uses DaisyUI semantic color variables
* so the thumb adapts to both light and dark themes automatically.
*/
.preview-scroll {
scrollbar-width: thin;
/* Use currentColor so Firefox resolves the thumb colour immediately at paint
time rather than deferring CSS custom-property resolution. */
scrollbar-color: color-mix(in oklch, currentColor 28%, transparent) transparent;
}
@layer components {
.preview-scroll {
scrollbar-width: thin;
/* Use currentColor so Firefox resolves the thumb colour immediately at paint
time rather than deferring CSS custom-property resolution. */
scrollbar-color: color-mix(in oklch, currentColor 28%, transparent) transparent;
}

.preview-scroll::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.preview-scroll::-webkit-scrollbar {
width: 8px;
height: 8px;
}

.preview-scroll::-webkit-scrollbar-track {
background: transparent;
}
.preview-scroll::-webkit-scrollbar-track {
background: transparent;
}

.preview-scroll::-webkit-scrollbar-thumb {
background-color: color-mix(in oklch, currentColor 28%, transparent);
border-radius: 4px;
}
.preview-scroll::-webkit-scrollbar-thumb {
background-color: color-mix(in oklch, currentColor 28%, transparent);
border-radius: 4px;
}

.preview-scroll::-webkit-scrollbar-thumb:hover {
background-color: color-mix(in oklch, currentColor 50%, transparent);
.preview-scroll::-webkit-scrollbar-thumb:hover {
background-color: color-mix(in oklch, currentColor 50%, transparent);
}
}
122 changes: 62 additions & 60 deletions src/lib/components/TabBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -124,72 +124,74 @@
}
</script>

<div class="flex items-end gap-1 overflow-x-auto px-0.5" role="tablist">
{#each items as item, index (item.id)}
{@const isActive = item.id === activeId}
{@const isDragOver =
dragOverIndex === index && dragFromIndex !== null && dragFromIndex !== index}
<div
class="group has-[:focus-visible]:ring-primary flex max-w-56 items-center rounded-t-lg border-x border-t transition-colors has-[:focus-visible]:ring-2
{isActive
? 'border-base-300 bg-base-100 text-base-content'
: 'bg-base-200/50 text-base-content/60 hover:bg-base-200 hover:text-base-content/80 border-transparent'}
{isDragOver ? 'ring-primary ring-2' : ''}"
>
<button
type="button"
id="tab-{item.id}"
class="flex min-w-0 flex-1 cursor-pointer items-center gap-1 rounded-t-lg px-3 py-1.5 text-sm select-none focus-visible:outline-none"
role="tab"
aria-selected={isActive}
tabindex={isActive ? 0 : -1}
draggable={onReorder ? 'true' : 'false'}
ondragstart={(e) => handleDragStart(e, index)}
ondragover={(e) => handleDragOver(e, index)}
ondrop={(e) => handleDrop(e, index)}
ondragend={handleDragEnd}
onclick={() => onSelect(item.id)}
onkeydown={(e) => handleTabKeydown(e, index)}
ondblclick={() => startRename(item.id, item.label)}
<div class="flex items-end gap-1 overflow-x-auto px-0.5">
<div class="flex items-end gap-1" role="tablist">
{#each items as item, index (item.id)}
{@const isActive = item.id === activeId}
{@const isDragOver =
dragOverIndex === index && dragFromIndex !== null && dragFromIndex !== index}
<div
class="group has-[:focus-visible]:ring-primary flex max-w-56 items-center rounded-t-lg border-x border-t transition-colors has-[:focus-visible]:ring-2
{isActive
? 'border-base-300 bg-base-100 text-base-content'
: 'bg-base-200/50 text-base-content/60 hover:bg-base-200 hover:text-base-content/80 border-transparent'}
{isDragOver ? 'ring-primary ring-2' : ''}"
>
{#if editingId === item.id}
<input
bind:this={editInput}
bind:value={editValue}
class="w-24 min-w-0 border-b border-current bg-transparent text-sm outline-none"
aria-label={m.trino_tab_rename()}
onblur={commitRename}
onkeydown={handleEditKeydown}
onclick={(e: MouseEvent) => e.stopPropagation()}
/>
{:else}
<span class="truncate">{item.label}</span>
{/if}
</button>

{#if showClose && item.closable !== false}
<button
type="button"
class="btn btn-ghost btn-xs mr-1 h-5 min-h-0 w-5 p-0 opacity-0 group-hover:opacity-100 focus-visible:opacity-100
{isActive ? 'opacity-60' : ''}"
id="tab-{item.id}"
class="flex min-w-0 flex-1 cursor-pointer items-center gap-1 rounded-t-lg px-3 py-1.5 text-sm select-none focus-visible:outline-none"
role="tab"
aria-selected={isActive}
tabindex={isActive ? 0 : -1}
aria-label={m.trino_tab_close({ name: item.label })}
onclick={() => onClose?.(item.id)}
draggable={onReorder ? 'true' : 'false'}
ondragstart={(e) => handleDragStart(e, index)}
ondragover={(e) => handleDragOver(e, index)}
ondrop={(e) => handleDrop(e, index)}
ondragend={handleDragEnd}
onclick={() => onSelect(item.id)}
onkeydown={(e) => handleTabKeydown(e, index)}
ondblclick={() => startRename(item.id, item.label)}
>
<svg
class="h-3 w-3"
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg
>
{#if editingId === item.id}
<input
bind:this={editInput}
bind:value={editValue}
class="w-24 min-w-0 border-b border-current bg-transparent text-sm outline-none"
aria-label={m.trino_tab_rename()}
onblur={commitRename}
onkeydown={handleEditKeydown}
onclick={(e: MouseEvent) => e.stopPropagation()}
/>
{:else}
<span class="truncate">{item.label}</span>
{/if}
</button>
{/if}
</div>
{/each}

{#if showClose && item.closable !== false}
<button
type="button"
class="btn btn-ghost btn-xs mr-1 h-5 min-h-0 w-5 p-0 opacity-0 group-hover:opacity-100 focus-visible:opacity-100
{isActive ? 'opacity-60' : ''}"
tabindex={isActive ? 0 : -1}
aria-label={m.trino_tab_close({ name: item.label })}
onclick={() => onClose?.(item.id)}
>
<svg
class="h-3 w-3"
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg
>
</button>
{/if}
</div>
{/each}
</div>

{#if onAdd && showAdd}
<button
Expand Down
22 changes: 16 additions & 6 deletions src/lib/components/layout/header/ThemeToggle.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { browser } from '$app/environment';
import { onMount } from 'svelte';
import * as m from '$lib/paraglide/messages.js';
import IconLightMode from 'virtual:icons/material-symbols/light-mode';
import IconDarkMode from 'virtual:icons/material-symbols/dark-mode';
Expand All @@ -11,8 +12,14 @@
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}

// Theme is only known clientside so the button is hidden until its state is known
let mounted = $state(false);
let dark = $state(prefersDark());

onMount(() => {
mounted = true;
});

$effect(() => {
if (!browser) return;
const theme = dark ? 'dark' : 'light';
Expand All @@ -24,11 +31,14 @@
<button
onclick={() => (dark = !dark)}
class="btn btn-ghost btn-sm btn-square"
aria-label={dark ? m.theme_switch_light() : m.theme_switch_dark()}
aria-label={mounted ? (dark ? m.theme_switch_light() : m.theme_switch_dark()) : m.theme_toggle()}
disabled={!mounted}
>
{#if dark}
<IconLightMode class="h-5 w-5" aria-hidden="true" />
{:else}
<IconDarkMode class="h-5 w-5" aria-hidden="true" />
{/if}
<div hidden={!mounted}>
{#if dark}
<IconLightMode class="h-5 w-5" aria-hidden="true" />
{:else}
<IconDarkMode class="h-5 w-5" aria-hidden="true" />
{/if}
</div>
</button>
3 changes: 2 additions & 1 deletion src/lib/components/layout/header/UserMenu.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import * as m from '$lib/paraglide/messages.js';
import IconLogout from 'virtual:icons/material-symbols/logout';
import type { User } from '$lib/types/auth.js';
Expand Down Expand Up @@ -41,7 +42,7 @@
</li>
<li><hr class="border-base-300 my-1" /></li>
<li class="mt-4">
<a href="/auth/logout" class="text-sm">
<a href={resolve('/auth/logout')} class="text-sm">
<IconLogout class="h-4 w-4" aria-hidden="true" />
{m.header_sign_out()}
</a>
Expand Down
7 changes: 4 additions & 3 deletions src/lib/components/layout/sidebar/Sidebar.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { page } from '$app/state';
import * as m from '$lib/paraglide/messages.js';
import type { Component } from 'svelte';
Expand Down Expand Up @@ -92,14 +93,14 @@

<ul class="flex flex-col gap-1">
{#each section.items as item (item.label)}
{@const active = isActive(item.href)}
{@const active = isActive(resolve(item.route))}
<li>
<a
href={item.href}
href={resolve(item.route)}
onclick={(e) => handleNavClick(e, item)}
onkeydown={(e) => handleNavKeydown(e, item)}
title={collapsed ? item.label : undefined}
class="flex min-h-[44px] items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors
class="flex min-h-11 items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors
{active
? 'bg-primary/10 text-primary'
: 'text-base-content/70 hover:bg-base-content/5 hover:text-base-content'}
Expand Down
6 changes: 3 additions & 3 deletions src/lib/components/layout/sidebar/nav-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,23 @@ export function getNavSections(flags: NavFlags = {}): NavSection[] {
const dataToolsItems: NavItem[] = [
{
label: m.nav_trino(),
href: '/trino',
route: '/trino',
icon: IconDatabase
}
];

if (storageBrowserEnabled) {
dataToolsItems.push({
label: m.nav_storage(),
href: '/storage',
route: '/storage',
icon: IconFolder
});
}

return [
{
title: m.nav_platform(),
items: [{ label: m.nav_dashboard(), href: '/', icon: IconDashboard }]
items: [{ label: m.nav_dashboard(), route: '/', icon: IconDashboard }]
},
{
title: m.nav_data_tools(),
Expand Down
Loading