From 70fc8e858dc1da3e9056afcb144149bd360fe815 Mon Sep 17 00:00:00 2001 From: Abdelrahman Ahmed Date: Sun, 8 Nov 2026 11:10:39 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=8E=A8=20Add=20the=20shared=20header,?= =?UTF-8?q?=20footer,=20theme=20toggle=20and=20one=20button=20spec=20to=20?= =?UTF-8?q?the=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/app/eslint.config.js | 2 +- apps/app/index.html | 1 + apps/app/public/theme-init.js | 5 + apps/app/src/components.tsx | 166 +++++++++++++++++++++++++------- apps/app/src/config.ts | 2 + apps/app/src/pages/Licenses.tsx | 2 +- apps/app/src/styles.css | 129 +++++++++++++++++++------ apps/app/src/theme.ts | 38 ++++++++ 8 files changed, 283 insertions(+), 62 deletions(-) create mode 100644 apps/app/public/theme-init.js create mode 100644 apps/app/src/theme.ts diff --git a/apps/app/eslint.config.js b/apps/app/eslint.config.js index 758dc53..03d6c30 100644 --- a/apps/app/eslint.config.js +++ b/apps/app/eslint.config.js @@ -5,7 +5,7 @@ import reactHooks from 'eslint-plugin-react-hooks'; import tseslint from 'typescript-eslint'; export default tseslint.config( - {ignores: ['dist']}, + {ignores: ['dist', 'public']}, js.configs.recommended, ...tseslint.configs.recommended, jsxA11y.flatConfigs.recommended, diff --git a/apps/app/index.html b/apps/app/index.html index fdc3238..70103b9 100644 --- a/apps/app/index.html +++ b/apps/app/index.html @@ -9,6 +9,7 @@ kwatch +
diff --git a/apps/app/public/theme-init.js b/apps/app/public/theme-init.js new file mode 100644 index 0000000..4e5b504 --- /dev/null +++ b/apps/app/public/theme-init.js @@ -0,0 +1,5 @@ +// Sets data-theme before first paint so a saved choice never flashes. +try { + var t = localStorage.getItem('kwatch-theme'); + if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t); +} catch (e) {} diff --git a/apps/app/src/components.tsx b/apps/app/src/components.tsx index d25ab17..5564f9f 100644 --- a/apps/app/src/components.tsx +++ b/apps/app/src/components.tsx @@ -3,8 +3,10 @@ import type {ReactNode, RefObject} from 'react'; import {NavLink, Outlet, useNavigate} from 'react-router-dom'; import {logout, logoutAll} from './api'; import type {ApiError} from './api'; -import {DOCS_URL, SITE_URL} from './config'; +import {BLOG_URL, DOCS_URL, GITHUB_URL, SITE_URL} from './config'; import {useSession} from './session'; +import {useTheme} from './theme'; +import type {Theme} from './theme'; export function Mark({size = 34}: {size?: number}) { return ( @@ -23,12 +25,116 @@ export function usePageTitle(title: string) { }, [title]); } +const THEME_LABEL: Record = {system: 'System', light: 'Light', dark: 'Dark'}; + +function ThemeIcon({theme}: {theme: Theme}) { + const common = { + width: 20, + height: 20, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: 2, + strokeLinecap: 'round' as const, + strokeLinejoin: 'round' as const, + 'aria-hidden': true, + }; + if (theme === 'light') { + return ( + + + + + ); + } + if (theme === 'dark') { + return ( + + + + ); + } + return ( + + + + + ); +} + +export function ThemeToggle() { + const [theme, next] = useTheme(); + return ( + + ); +} + +export function SkipLink() { + return ( + + Skip to content + + ); +} + +/** The one kwatch header: the same links on web, docs, blog and app. */ +export function SiteHeader({signedIn = false}: {signedIn?: boolean}) { + return ( +
+ + + kwatch + + + +
+ ); +} + +export function SiteFooter() { + return ( + + ); +} + /** Centered card used by the sign-in screens and the 404. */ export function Card({children}: {children: ReactNode}) { return ( -
-
{children}
-
+ <> + + +
+
{children}
+
+ ); } @@ -57,36 +163,30 @@ export function Shell() { } return ( -
- -
-
- -
-
kwatch · source available under ELv2
+ <> + + +
+ +
+
+ +
+ +
-
+ ); } diff --git a/apps/app/src/config.ts b/apps/app/src/config.ts index b8417d9..84f7be7 100644 --- a/apps/app/src/config.ts +++ b/apps/app/src/config.ts @@ -7,6 +7,8 @@ export const TURNSTILE_SITEKEY: string = env.VITE_TURNSTILE_SITEKEY ?? '0x4AAAAA export const SITE_URL = 'https://kwatch.dev'; export const DOCS_URL = 'https://docs.kwatch.dev'; +export const BLOG_URL = 'https://blog.kwatch.dev'; +export const GITHUB_URL = 'https://github.com/abahmed/kwatch'; export const CONTACT_URL = 'mailto:hello@kwatch.dev'; export const SUPPORT_EMAIL = 'support@kwatch.dev'; export const SUPPORT_MAILTO = `mailto:${SUPPORT_EMAIL}`; diff --git a/apps/app/src/pages/Licenses.tsx b/apps/app/src/pages/Licenses.tsx index 992ef1c..cc68491 100644 --- a/apps/app/src/pages/Licenses.tsx +++ b/apps/app/src/pages/Licenses.tsx @@ -354,7 +354,7 @@ export default function Licenses() { } const button = ( - ); diff --git a/apps/app/src/styles.css b/apps/app/src/styles.css index c487806..116e975 100644 --- a/apps/app/src/styles.css +++ b/apps/app/src/styles.css @@ -1,5 +1,26 @@ @import '@kwatch/brand/tokens.css'; +/* + Manual theme (row 6). tokens.css has no [data-theme] block yet (kwatch-web owns it), + so mirror its light and dark values here. Delete this block when tokens.css ships it. +*/ +:root[data-theme="light"] { + color-scheme: light; + --bg: #FFFFFF; --surface: #F2F3F0; --line: #DADDE2; --text: #0E1116; --text-muted: #5B6270; + --signal: #C8F542; --on-signal: #0E1116; + --failing: #B42318; --failing-bg: #FDECEC; --degraded: #8A5300; --degraded-bg: #FDF1DC; + --resolved: #1D5FC2; --resolved-bg: #E8F0FD; --success: #1E6B3A; --success-bg: #E6F4EA; + --control-border: #7C828E; --primary-hover: #2A2F38; --text-soft: #3A404B; +} +:root[data-theme="dark"] { + color-scheme: dark; + --bg: #15171B; --surface: #1E2126; --line: #2A2E35; --text: #ECEDEA; --text-muted: #9AA0AA; + --signal: #B5DE3C; --on-signal: #0E1116; + --failing: #FF9B8F; --failing-bg: #3A1F1E; --degraded: #F5B953; --degraded-bg: #3A2C14; + --resolved: #8DB8FF; --resolved-bg: #1B2A44; --success: #7FD99A; --success-bg: #1A3326; + --control-border: #6B7280; --primary-hover: #FFFFFF; --text-soft: #C3C7CE; +} + *, *::before, *::after { box-sizing: border-box; } html { color-scheme: light dark; } body { @@ -28,25 +49,49 @@ code, .mono { font-family: var(--font-mono); } /* Controls */ button { font: inherit; cursor: pointer; } button:disabled { cursor: not-allowed; } -.btn:disabled { background: var(--surface); color: var(--text-muted); border-color: var(--line); } -:focus-visible { outline: var(--focus-width) solid var(--text); outline-offset: var(--focus-offset); } -.btn { +:focus-visible { outline: var(--focus-width) solid var(--text); outline-offset: var(--focus-offset); border-radius: 4px; } +/* + One button spec (rows 3, 21, 25): pill, 44px, 1.5px border, 11px 22px. + Line = --text label on --control-border; solid = --text fill with the opposite token + (--bg) as label. Every state sets color explicitly so link defaults cannot win. +*/ +.btn, .btn-action { background: transparent; color: var(--text); border: var(--btn-border) solid var(--control-border); border-radius: var(--radius-pill); - min-height: var(--btn-height); padding: 0 18px; font-weight: 600; font-size: 15px; + min-height: var(--btn-height); padding: 11px 22px; font-weight: 600; font-size: 15px; line-height: 1.2; display: inline-flex; align-items: center; justify-content: center; text-decoration: none; } -.btn-sm { min-height: var(--hit); padding: 0 14px; font-size: 14px; font-weight: 500; } -.btn-primary { background: var(--text); color: var(--bg); border-color: var(--text); } -.btn-signal { background: var(--signal); color: var(--on-signal); border-color: var(--signal); font-weight: 600; } -.btn-danger { color: var(--failing); border-color: var(--failing); } +.btn:visited, .btn-action:visited { color: var(--text); } +.btn:hover:not(:disabled), .btn:focus-visible, .btn-action:hover:not(:disabled), .btn-action:focus-visible { + background: var(--text); color: var(--bg); border-color: var(--text); +} +.btn:active:not(:disabled), .btn-action:active:not(:disabled) { background: var(--text); color: var(--bg); border-color: var(--text); } +.btn-sm { padding: 11px 18px; font-size: 14px; font-weight: 500; } +.btn-primary, .btn-primary:visited, .btn-action.is-primary, .btn-action.is-primary:visited { + background: var(--text); color: var(--bg); border-color: var(--text); +} +.btn-primary:hover:not(:disabled), .btn-primary:focus-visible, .btn-primary:active:not(:disabled), +.btn-action.is-primary:hover:not(:disabled), .btn-action.is-primary:focus-visible, .btn-action.is-primary:active:not(:disabled) { + background: var(--primary-hover); color: var(--bg); border-color: var(--primary-hover); +} +.btn-danger, .btn-danger:visited { color: var(--failing); border-color: var(--failing); } +.btn-danger:hover:not(:disabled), .btn-danger:focus-visible, .btn-danger:active:not(:disabled) { + background: var(--failing); color: var(--bg); border-color: var(--failing); +} +.btn:disabled, .btn-action:disabled, .btn-primary:disabled, .btn-action.is-primary:disabled, .btn-danger:disabled { + background: var(--surface); color: var(--text-muted); border-color: var(--line); cursor: not-allowed; +} .btn-block { width: 100%; margin-top: 14px; padding: 13px; } +/* Signal areas: labels, borders and icons use --on-signal, never --text (row 22). */ +.on-signal { background: var(--signal); color: var(--on-signal); } +.on-signal a, .on-signal .btn-line, .on-signal svg { color: var(--on-signal); border-color: var(--on-signal); } +.on-signal .btn-solid { background: var(--ink); color: var(--ink-text); border-color: var(--ink); } .link { background: none; border: 0; padding: 0; color: var(--text); text-decoration: underline; } label { display: block; margin-top: 22px; font-size: 14px; font-weight: 600; } input { display: block; width: 100%; margin-top: 6px; padding: 12px 14px; background: var(--bg); color: var(--text); - border: 1.5px solid var(--text); border-radius: var(--radius-input); font: inherit; + border: 1.5px solid var(--control-border); border-radius: var(--radius-input); font: inherit; } .check { margin-top: 12px; min-height: 65px; display: flex; align-items: center; } @@ -65,7 +110,7 @@ input { .limit a, .notice-warn a { color: var(--text); } /* Centered screens */ -.center { min-height: 100vh; display: grid; place-items: center; padding: 24px 16px; } +.center { min-height: calc(100vh - 69px); display: grid; place-items: center; padding: 24px 16px; } .card { width: 100%; max-width: 440px; border: 1px solid var(--line); border-radius: var(--radius-card); padding: 36px; } .card h1 { margin-top: 12px; } .card .mark + h1 { margin-top: 18px; } @@ -76,8 +121,40 @@ input { .back a { color: var(--text-muted); } form .muted.small { margin-top: 14px; font-size: 13px; } +/* Skip link and shared header */ +.skip-link { + position: absolute; top: -100px; left: 12px; z-index: 20; + background: var(--text); color: var(--bg); padding: 11px 22px; border-radius: var(--radius-pill); + font-weight: 600; text-decoration: none; +} +.skip-link:focus, .skip-link:focus-visible { top: 12px; color: var(--bg); } +.site-header { + display: flex; align-items: center; gap: var(--space-3) var(--space-5); + padding: var(--space-3) var(--space-6); border-bottom: 1px solid var(--line); +} +.site-header .brand { margin: 0; text-decoration: none; min-height: var(--hit); } +.site-nav { display: flex; align-items: center; flex-wrap: wrap; gap: 0 var(--space-2); margin-left: auto; font-size: 15px; } +.site-nav a { + display: inline-flex; align-items: center; min-height: var(--hit); padding: 0 var(--space-3); + color: var(--text); text-decoration: none; +} +.site-nav a:hover { text-decoration: underline; text-underline-offset: 3px; } +.site-nav a[aria-current="page"] { text-decoration: underline; text-underline-offset: 3px; } +.site-nav .nav-account { font-weight: 700; } +.theme-toggle { + display: inline-flex; align-items: center; justify-content: center; width: var(--hit); height: var(--hit); padding: 0; + background: transparent; color: var(--text); border: var(--btn-border) solid var(--control-border); border-radius: var(--radius-pill); +} +.theme-toggle:hover { background: var(--text); color: var(--bg); border-color: var(--text); } +@media (max-width: 700px) { + .site-header { flex-wrap: wrap; padding: var(--space-3) var(--space-4); } + .site-header .theme-toggle { margin-left: auto; } + .site-nav { order: 3; flex-basis: 100%; margin-left: calc(-1 * var(--space-2)); gap: 0; } + .site-nav a { padding: 0 var(--space-2); } +} + /* Shell */ -.shell { display: grid; grid-template-columns: 220px 1fr; min-height: 100vh; } +.shell { display: grid; grid-template-columns: 220px 1fr; min-height: calc(100vh - 69px); } .side { border-right: 1px solid var(--line); padding: 26px 22px; display: flex; flex-direction: column; gap: 12px; font-size: 15px; @@ -90,7 +167,13 @@ form .muted.small { margin-top: 14px; font-size: 13px; } .who .link { color: var(--text-muted); } .main { display: flex; flex-direction: column; min-width: 0; } .content { padding: var(--space-6) var(--space-6); max-width: var(--max-app); width: 100%; flex: 1; } -.site-footer { padding: var(--space-4) var(--space-6); max-width: var(--max-app); width: 100%; font-size: 14px; color: var(--text-muted); } +.site-footer { + padding: var(--space-4) var(--space-6); max-width: var(--max-app); width: 100%; font-size: 14px; color: var(--text-soft); + display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-5); align-items: center; +} +.site-footer nav { display: flex; flex-wrap: wrap; gap: 0 var(--space-4); } +.site-footer a { color: var(--text-soft); display: inline-flex; align-items: center; min-height: var(--hit); text-underline-offset: 3px; } +.site-footer a:hover { color: var(--text); } .page-head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 12px; } .page-head h1 { font-size: 34px; } @@ -129,21 +212,14 @@ form .muted.small { margin-top: 14px; font-size: 13px; } .license-footer { display: flex; flex-wrap: wrap; gap: 8px 16px; padding: 16px 22px; border-top: 1px solid var(--line); } .license-group { display: flex; flex-wrap: wrap; gap: 8px; } .license-group-manage { margin-inline-start: auto; } -.btn-action { - min-height: var(--btn-height); padding: 0 18px; font-size: 15px; font-weight: 500; - border: var(--btn-border) solid var(--control-border); border-radius: var(--radius-pill); - background: transparent; color: var(--text); -} +.btn-action { font-weight: 500; } .btn-action.is-toggle { min-width: 112px; } -.btn-action:hover:not(:disabled) { background: var(--surface); border-color: var(--text); } -.btn-action:active:not(:disabled) { background: var(--line); } -.btn-action.is-primary { background: var(--text); color: var(--bg); border-color: var(--text); font-weight: 600; min-width: 112px; } -.btn-action.is-primary:hover:not(:disabled) { background: var(--primary-hover); border-color: var(--primary-hover); } -.btn-action.is-primary:active:not(:disabled) { background: var(--primary-hover); transform: translateY(1px); } -.btn-action:disabled, .btn-action.is-primary:disabled { background: var(--surface); color: var(--text-muted); border-color: var(--line); cursor: not-allowed; } -.btn-quiet { border-color: transparent; color: var(--text-muted); padding: 0 12px; } -.btn-quiet:hover:not(:disabled) { background: transparent; border-color: transparent; color: var(--text); text-decoration: underline; } -.btn-quiet:active:not(:disabled) { background: transparent; color: var(--text); } +.btn-action.is-primary { font-weight: 600; min-width: 112px; } +.btn-quiet, .btn-quiet:visited { border-color: transparent; background: transparent; color: var(--text-soft); padding: 11px 12px; } +.btn-quiet:hover:not(:disabled), .btn-quiet:focus-visible, .btn-quiet:active:not(:disabled) { + background: transparent; border-color: transparent; color: var(--text); text-decoration: underline; +} +.btn-quiet:disabled { background: transparent; border-color: transparent; } @media (prefers-reduced-motion: no-preference) { .btn-action { transition: background-color 120ms, border-color 120ms; } } @@ -254,7 +330,6 @@ form .muted.small { margin-top: 14px; font-size: 13px; } @media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 2s; } } .icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; padding: 0; background: none; border: 0; border-radius: var(--radius-pill); color: var(--text-muted); cursor: pointer; transition: background var(--motion-fast), color var(--motion-fast); } .icon-btn:hover:not(:disabled) { color: var(--text); background: color-mix(in srgb, var(--text) 8%, transparent); } -.icon-btn:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; } .icon-btn:disabled { color: var(--text-muted); cursor: default; } .key-row { display: flex; align-items: center; gap: var(--space-1); min-width: 0; } .key-row .key { margin: 0; overflow-x: auto; } diff --git a/apps/app/src/theme.ts b/apps/app/src/theme.ts new file mode 100644 index 0000000..42f67b0 --- /dev/null +++ b/apps/app/src/theme.ts @@ -0,0 +1,38 @@ +import {useCallback, useState} from 'react'; + +export type Theme = 'system' | 'light' | 'dark'; +const KEY = 'kwatch-theme'; + +export function readTheme(): Theme { + try { + const t = localStorage.getItem(KEY); + if (t === 'light' || t === 'dark') return t; + } catch { + /* storage blocked: stay on System */ + } + return 'system'; +} + +export function applyTheme(theme: Theme) { + const root = document.documentElement; + if (theme === 'system') root.removeAttribute('data-theme'); + else root.setAttribute('data-theme', theme); + try { + if (theme === 'system') localStorage.removeItem(KEY); + else localStorage.setItem(KEY, theme); + } catch { + /* storage blocked: the choice lasts for this page only */ + } +} + +const ORDER: Theme[] = ['system', 'light', 'dark']; + +export function useTheme(): [Theme, () => void] { + const [theme, setTheme] = useState(readTheme); + const next = useCallback(() => { + const n = ORDER[(ORDER.indexOf(theme) + 1) % ORDER.length]; + applyTheme(n); + setTheme(n); + }, [theme]); + return [theme, next]; +} From 4e3c3ac5ae56ee225fbc3df8c50afd713d5e4b62 Mon Sep 17 00:00:00 2001 From: Abdelrahman Ahmed Date: Sun, 8 Nov 2026 11:19:24 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=94=A5=20Drop=20the=20temporary=20the?= =?UTF-8?q?me=20token=20copy=20now=20that=20tokens=20ship=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/app/src/styles.css | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/apps/app/src/styles.css b/apps/app/src/styles.css index 116e975..3c5c055 100644 --- a/apps/app/src/styles.css +++ b/apps/app/src/styles.css @@ -1,26 +1,5 @@ @import '@kwatch/brand/tokens.css'; -/* - Manual theme (row 6). tokens.css has no [data-theme] block yet (kwatch-web owns it), - so mirror its light and dark values here. Delete this block when tokens.css ships it. -*/ -:root[data-theme="light"] { - color-scheme: light; - --bg: #FFFFFF; --surface: #F2F3F0; --line: #DADDE2; --text: #0E1116; --text-muted: #5B6270; - --signal: #C8F542; --on-signal: #0E1116; - --failing: #B42318; --failing-bg: #FDECEC; --degraded: #8A5300; --degraded-bg: #FDF1DC; - --resolved: #1D5FC2; --resolved-bg: #E8F0FD; --success: #1E6B3A; --success-bg: #E6F4EA; - --control-border: #7C828E; --primary-hover: #2A2F38; --text-soft: #3A404B; -} -:root[data-theme="dark"] { - color-scheme: dark; - --bg: #15171B; --surface: #1E2126; --line: #2A2E35; --text: #ECEDEA; --text-muted: #9AA0AA; - --signal: #B5DE3C; --on-signal: #0E1116; - --failing: #FF9B8F; --failing-bg: #3A1F1E; --degraded: #F5B953; --degraded-bg: #3A2C14; - --resolved: #8DB8FF; --resolved-bg: #1B2A44; --success: #7FD99A; --success-bg: #1A3326; - --control-border: #6B7280; --primary-hover: #FFFFFF; --text-soft: #C3C7CE; -} - *, *::before, *::after { box-sizing: border-box; } html { color-scheme: light dark; } body {