diff --git a/app/assets/css/base.css b/app/assets/css/base.css new file mode 100644 index 0000000..1f1da85 --- /dev/null +++ b/app/assets/css/base.css @@ -0,0 +1,102 @@ +*, *::before, *::after { box-sizing: border-box; } + +html, body { + overflow-x: clip; +} + +html { + background: var(--color-paper); + color: var(--color-ink); + font-family: var(--font-body); + font-size: 100%; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +body { + margin: 0; + font-weight: 400; +} + +h1, h2, h3, h4 { + font-family: var(--font-display); + font-weight: 700; + line-height: 1.1; + letter-spacing: -0.02em; + margin: 0; +} + +a { + color: var(--color-accent); + text-decoration-thickness: 1px; + text-underline-offset: 2px; +} + +a:hover { + color: var(--color-ink); +} + +code, pre, kbd { + font-family: var(--font-mono); + font-variant-ligatures: none; +} + +:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: 2px; +} + +.skip-link { + position: absolute; + left: var(--space-md); + top: var(--space-md); + z-index: var(--z-toast); + padding: 0.5rem 0.9rem; + background: var(--color-paper-3); + border: var(--rule-hair) solid var(--color-accent); + border-radius: var(--radius-sm); + color: var(--color-ink); + font-family: var(--font-mono); + font-size: var(--text-sm); + text-decoration: none; + transform: translateY(-150%); +} + +.skip-link:focus { + transform: none; +} + +button { + font: inherit; +} + +::selection { + background: var(--color-accent); + color: var(--color-paper); +} + +.reveal { + opacity: 0; + transform: translateY(8px); + animation: reveal var(--dur-long) var(--ease-out) forwards; + animation-delay: calc(var(--i, 0) * 60ms); +} + +@keyframes reveal { + to { opacity: 1; transform: none; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 150ms !important; + animation-iteration-count: 1 !important; + transition-duration: 150ms !important; + } + .reveal { + animation: reveal-reduced 150ms linear forwards; + } + @keyframes reveal-reduced { + to { opacity: 1; transform: none; } + } +} diff --git a/app/assets/css/tokens.css b/app/assets/css/tokens.css new file mode 100644 index 0000000..ed1d83a --- /dev/null +++ b/app/assets/css/tokens.css @@ -0,0 +1,61 @@ +:root { + --color-paper: oklch(15% 0.012 250); + --color-paper-2: oklch(18% 0.014 250); + --color-paper-3: oklch(22% 0.016 250); + --color-rule: oklch(30% 0.012 250); + --color-rule-interactive: oklch(52% 0.014 250); + --color-neutral: oklch(64% 0.010 250); + --color-muted: oklch(72% 0.008 250); + --color-ink: oklch(94% 0.006 145); + + --color-accent: oklch(82% 0.19 145); + --color-accent-dim: oklch(62% 0.14 145); + --color-focus: oklch(82% 0.19 145); + + --color-ok: oklch(82% 0.18 145); + --color-warn: oklch(82% 0.15 85); + --color-error: oklch(72% 0.17 25); + --color-info: oklch(78% 0.12 240); + + --font-display: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, monospace; + --font-mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, monospace; + --font-body: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif; + + --text-xs: 0.75rem; + --text-sm: 0.85rem; + --text-base: 1rem; + --text-md: 1.2rem; + --text-lg: 1.5rem; + --text-xl: 1.95rem; + --text-2xl: 2.45rem; + --text-display: clamp(2.5rem, 6vw + 1rem, 4.75rem); + --text-display-s: clamp(1.9rem, 4vw + 1rem, 3rem); + + --space-2xs: 0.25rem; + --space-xs: 0.5rem; + --space-sm: 0.75rem; + --space-md: 1rem; + --space-lg: 1.5rem; + --space-xl: 2.5rem; + --space-2xl: 4rem; + --space-3xl: 6rem; + + --page-gutter: clamp(1rem, 4vw, 1.5rem); + --measure: 60ch; + + --rule-hair: 1px; + --radius-sm: 3px; + --radius-md: 5px; + + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --ease-in: cubic-bezier(0.7, 0, 0.84, 0); + --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); + --dur-micro: 120ms; + --dur-short: 220ms; + --dur-long: 420ms; + + --z-base: 1; + --z-sticky: 10; + --z-overlay: 100; + --z-toast: 1000; +} diff --git a/app/components/SynchubMark.vue b/app/components/SynchubMark.vue new file mode 100644 index 0000000..328968f --- /dev/null +++ b/app/components/SynchubMark.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/app/pages/dashboard.vue b/app/pages/dashboard.vue index 03c9848..1885f70 100644 --- a/app/pages/dashboard.vue +++ b/app/pages/dashboard.vue @@ -14,8 +14,15 @@ useSeoMeta({ const { data, refresh, error } = await useFetch('/api/me/dashboard') const flash = ref(null) +const flashIsError = computed(() => flash.value?.startsWith('Error:') ?? false) const pendingAction = ref(null) +const heading = useTemplateRef('heading') +const router = useRouter() +router.afterEach(() => { + nextTick(() => heading.value?.focus({ focusVisible: false })) +}) + function actionKey(scope: string, id: number | string) { return `${scope}:${id}` } @@ -93,6 +100,18 @@ function rotateKey() { ) } +async function logout() { + pendingAction.value = 'logout' + try { + await postAction('/api/me/logout') + await navigateTo('/') + } + catch { + pendingAction.value = null + flash.value = 'Error: could not sign out.' + } +} + function summariseRefs(refs: Record): string { const entries = Object.entries(refs) if (entries.length === 0) return '—' @@ -116,20 +135,25 @@ function fmtDate(iso: string | null): string {

- Connected to {{ data.installation?.accountLogin ?? 'this account' }}. - Connect a different installation? + connected to {{ data.installation?.accountLogin ?? 'this account' }}. + connect a different installation?

@@ -162,16 +186,17 @@ function fmtDate(iso: string | null): string {

SSH key

- No key on file yet. It's created automatically on first sign-in. + no key on file yet. it's created automatically on first sign-in.

+ + + + + -

- {{ flash }} +

+ {{ flash }}

diff --git a/app/pages/index.vue b/app/pages/index.vue index ccc33bc..a68588f 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -10,107 +10,313 @@ if (session.value && typeof session.value === 'object' && 'did' in session.value } const installUrl = 'https://github.com/apps/synchub-to/installations/new' + +const heading = useTemplateRef('heading') +const router = useRouter() +router.afterEach(() => { + nextTick(() => heading.value?.focus({ focusVisible: false })) +}) diff --git a/nuxt.config.ts b/nuxt.config.ts index e1ed929..24762ac 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -12,6 +12,13 @@ export default defineNuxtConfig({ '@nuxt/test-utils', ], devtools: { enabled: true }, + css: ['~/assets/css/tokens.css', '~/assets/css/base.css'], + fonts: { + families: [ + { name: 'JetBrains Mono', provider: 'google', weights: [400, 500, 700] }, + { name: 'IBM Plex Sans', provider: 'google', weights: [300, 400, 600] }, + ], + }, runtimeConfig: { databaseUrl: '', githubAppId: '', @@ -35,6 +42,10 @@ export default defineNuxtConfig({ app: { head: { htmlAttrs: { lang: 'en' }, + link: [ + { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }, + { rel: 'alternate icon', href: '/favicon.ico' }, + ], }, }, future: { @@ -43,6 +54,10 @@ export default defineNuxtConfig({ experimental: { typedPages: true, }, + routeRules: { + '/': { noScripts: true, prerender: true }, + '/dashboard': { ssr: false, prerender: true }, + }, nitro: { vercel: { config: { diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..f1849d5 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,9 @@ + + + + + + + + +