feat(web): adopt cyberspace terminal aesthetic + dithered images
Rebrand light/dark themes to the cyberspace.online look: warm cream-on-black palette (light/dark are exact inverses), self-hosted JetBrains Mono + VT323, square corners, border-driven surfaces with no soft shadows. Adds a terminal design-system CSS layer (DOS double-border modals with hatched corner, fg focus, inversion-on-hover), a theme-aware <RasterImage> (Atkinson-dithered canvas with img fallback), and unifies desktop icons, taskbar, window controls, pills and links under one idiom. Pins window titlebars to a fixed height and switches chat auto-scroll off scrollIntoView to avoid titlebar reflow. VERSION 0.15.1 -> 0.16.0
This commit is contained in:
@@ -4,12 +4,6 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Oikos</title>
|
<title>Oikos</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&family=Inknut+Antiqua:wght@300;400;500;600;700&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||||
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||||
<link rel="icon" type="image/png" sizes="192x192" href="android-chrome-192.png" />
|
<link rel="icon" type="image/png" sizes="192x192" href="android-chrome-192.png" />
|
||||||
|
|||||||
BIN
web/public/fonts/JetBrainsMono-Bold.woff2
Normal file
BIN
web/public/fonts/JetBrainsMono-Bold.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts/JetBrainsMono-Regular.woff2
Normal file
BIN
web/public/fonts/JetBrainsMono-Regular.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts/VT323-Regular.woff2
Normal file
BIN
web/public/fonts/VT323-Regular.woff2
Normal file
Binary file not shown.
374
web/src/app.css
374
web/src/app.css
@@ -2,6 +2,29 @@
|
|||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
/* ── Self-hosted type (cyberspace terminal aesthetic) ── */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/JetBrainsMono-Regular.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/JetBrainsMono-Bold.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'VT323';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/VT323-Regular.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
/* bits-ui components (Slider, and any future orientation/disabled-aware
|
/* bits-ui components (Slider, and any future orientation/disabled-aware
|
||||||
primitive) style themselves via shorthand data-* variants that Tailwind
|
primitive) style themselves via shorthand data-* variants that Tailwind
|
||||||
v4 doesn't ship — it only auto-generates variants for bare boolean data
|
v4 doesn't ship — it only auto-generates variants for bare boolean data
|
||||||
@@ -14,13 +37,16 @@
|
|||||||
@custom-variant data-disabled (&[data-disabled]);
|
@custom-variant data-disabled (&[data-disabled]);
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-sans: 'DM Sans', system-ui, sans-serif;
|
--font-sans: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||||
--font-mono: 'DM Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||||
--font-heading: 'Inknut Antiqua', Georgia, serif;
|
--font-heading: 'JetBrains Mono', ui-monospace, Menlo, monospace;
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
/* Square corners across the whole radius scale (--radius is pinned to 0
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
by both themes below). Kept as a 4-step scale so any future softer theme
|
||||||
|
can relax just --radius and get graded corners back for free. */
|
||||||
|
--radius-sm: var(--radius);
|
||||||
|
--radius-md: var(--radius);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
--radius-xl: var(--radius);
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
@@ -57,103 +83,107 @@
|
|||||||
--color-chart-5: var(--chart-5);
|
--color-chart-5: var(--chart-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Terracotta Light Theme ── */
|
/* ── Cyberspace Light (black ink on warm cream paper) ──
|
||||||
|
Ported from cyberspace.online's 3-color model (fg/bg/fgDim). Light and
|
||||||
|
dark are exact inverses of the same cream (#efe5c0). Emphasis is by
|
||||||
|
inversion (primary = fg ink), borders are fg-derived hairlines, and the
|
||||||
|
radius is 0 so every surface is square. */
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius: 0px;
|
||||||
--background: oklch(0.94 0.02 55);
|
--background: #efe5c0;
|
||||||
--foreground: oklch(0.18 0.03 45);
|
--foreground: #000000;
|
||||||
--card: oklch(0.91 0.025 55);
|
--card: #efe5c0;
|
||||||
--card-foreground: oklch(0.18 0.03 45);
|
--card-foreground: #000000;
|
||||||
--popover: oklch(0.91 0.025 55);
|
--popover: #efe5c0;
|
||||||
--popover-foreground: oklch(0.18 0.03 45);
|
--popover-foreground: #000000;
|
||||||
--primary: oklch(0.55 0.14 45);
|
--primary: #000000;
|
||||||
--primary-foreground: oklch(0.95 0.02 55);
|
--primary-foreground: #efe5c0;
|
||||||
--secondary: oklch(0.86 0.03 55);
|
--secondary: #e0d6b0;
|
||||||
--secondary-foreground: oklch(0.18 0.03 45);
|
--secondary-foreground: #000000;
|
||||||
--muted: oklch(0.86 0.025 55);
|
--muted: #e6dcc0;
|
||||||
--muted-foreground: oklch(0.45 0.04 45);
|
--muted-foreground: #3a3a3a;
|
||||||
--accent: oklch(0.84 0.035 55);
|
--accent: #000000;
|
||||||
--accent-foreground: oklch(0.18 0.03 45);
|
--accent-foreground: #efe5c0;
|
||||||
--destructive: oklch(0.5 0.2 25);
|
--destructive: #9d0006;
|
||||||
--destructive-foreground: oklch(0.95 0.02 55);
|
--destructive-foreground: #efe5c0;
|
||||||
--border: oklch(0.6 0.08 45);
|
--border: color-mix(in oklab, #000000 22%, transparent);
|
||||||
--input: oklch(0.78 0.04 55);
|
--input: color-mix(in oklab, #000000 30%, transparent);
|
||||||
--ring: oklch(0.55 0.14 45);
|
--ring: #000000;
|
||||||
--chart-1: oklch(0.55 0.14 45);
|
--chart-1: #000000;
|
||||||
--chart-2: oklch(0.65 0.1 70);
|
--chart-2: #3a3a3a;
|
||||||
--chart-3: oklch(0.5 0.08 30);
|
--chart-3: #b57614;
|
||||||
--chart-4: oklch(0.6 0.06 90);
|
--chart-4: #79740e;
|
||||||
--chart-5: oklch(0.4 0.04 45);
|
--chart-5: #076678;
|
||||||
--sidebar: oklch(0.9 0.025 55);
|
--sidebar: #efe5c0;
|
||||||
--sidebar-foreground: oklch(0.18 0.03 45);
|
--sidebar-foreground: #000000;
|
||||||
--sidebar-primary: oklch(0.55 0.14 45);
|
--sidebar-primary: #000000;
|
||||||
--sidebar-primary-foreground: oklch(0.95 0.02 55);
|
--sidebar-primary-foreground: #efe5c0;
|
||||||
--sidebar-accent: oklch(0.84 0.035 55);
|
--sidebar-accent: #e0d6b0;
|
||||||
--sidebar-accent-foreground: oklch(0.18 0.03 45);
|
--sidebar-accent-foreground: #000000;
|
||||||
--sidebar-border: oklch(0.6 0.08 45);
|
--sidebar-border: color-mix(in oklab, #000000 22%, transparent);
|
||||||
--sidebar-ring: oklch(0.55 0.14 45);
|
--sidebar-ring: #000000;
|
||||||
--success: #3fb950;
|
--success: #79740e;
|
||||||
--warning: #d29922;
|
--warning: #b57614;
|
||||||
|
|
||||||
--bg: var(--background);
|
--bg: var(--background);
|
||||||
--bg-surface: var(--card);
|
--bg-surface: var(--card);
|
||||||
--bg-deeper: oklch(0.98 0.01 55);
|
--bg-deeper: #e6dcc0;
|
||||||
--bg-hover: var(--secondary);
|
--bg-hover: var(--secondary);
|
||||||
--bg-active: var(--accent);
|
--bg-active: var(--accent);
|
||||||
--text: var(--foreground);
|
--text: var(--foreground);
|
||||||
--text-muted: var(--muted-foreground);
|
--text-muted: var(--muted-foreground);
|
||||||
--accent-blue: var(--primary);
|
--accent-blue: #076678;
|
||||||
--accent-green: var(--success);
|
--accent-green: var(--success);
|
||||||
--accent-red: var(--destructive);
|
--accent-red: var(--destructive);
|
||||||
--accent-orange: var(--warning);
|
--accent-orange: var(--warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Carbon Dark Theme ── */
|
/* ── Cyberspace Dark (warm cream on black) — exact inverse of Light ── */
|
||||||
.dark {
|
.dark {
|
||||||
--radius: 0.625rem;
|
--radius: 0px;
|
||||||
--background: oklch(0.145 0 0);
|
--background: #000000;
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: #efe5c0;
|
||||||
--card: oklch(0.205 0 0);
|
--card: #000000;
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: #efe5c0;
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: #000000;
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: #efe5c0;
|
||||||
--primary: oklch(0.922 0 0);
|
--primary: #efe5c0;
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: #000000;
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: #1a1a1a;
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: #efe5c0;
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: #141414;
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: #a89984;
|
||||||
--accent: oklch(0.371 0 0);
|
--accent: #efe5c0;
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: #000000;
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: #cc241d;
|
||||||
--destructive-foreground: oklch(0.985 0 0);
|
--destructive-foreground: #efe5c0;
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: color-mix(in oklab, #efe5c0 22%, transparent);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: color-mix(in oklab, #efe5c0 30%, transparent);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: #efe5c0;
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--chart-1: #efe5c0;
|
||||||
--chart-2: oklch(0.6 0.15 220);
|
--chart-2: #a89984;
|
||||||
--chart-3: oklch(0.5 0.1 160);
|
--chart-3: #fabd2f;
|
||||||
--chart-4: oklch(0.7 0.08 60);
|
--chart-4: #b8bb26;
|
||||||
--chart-5: oklch(0.45 0.05 320);
|
--chart-5: #83a598;
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: #000000;
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: #efe5c0;
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: #efe5c0;
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: #000000;
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: #1a1a1a;
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: #efe5c0;
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: color-mix(in oklab, #efe5c0 22%, transparent);
|
||||||
--sidebar-ring: oklch(0.439 0 0);
|
--sidebar-ring: #efe5c0;
|
||||||
--success: #3fb950;
|
--success: #b8bb26;
|
||||||
--warning: #d29922;
|
--warning: #fabd2f;
|
||||||
|
|
||||||
--bg: var(--background);
|
--bg: var(--background);
|
||||||
--bg-surface: var(--card);
|
--bg-surface: var(--card);
|
||||||
--bg-deeper: oklch(0.11 0 0);
|
--bg-deeper: #050505;
|
||||||
--bg-hover: var(--secondary);
|
--bg-hover: var(--secondary);
|
||||||
--bg-active: var(--accent);
|
--bg-active: var(--accent);
|
||||||
--text: var(--foreground);
|
--text: var(--foreground);
|
||||||
--text-muted: var(--muted-foreground);
|
--text-muted: var(--muted-foreground);
|
||||||
--accent-blue: var(--sidebar-primary);
|
--accent-blue: #83a598;
|
||||||
--accent-green: var(--success);
|
--accent-green: var(--success);
|
||||||
--accent-red: var(--destructive);
|
--accent-red: var(--destructive);
|
||||||
--accent-orange: var(--warning);
|
--accent-orange: var(--warning);
|
||||||
@@ -273,14 +303,17 @@
|
|||||||
color: var(--card-foreground);
|
color: var(--card-foreground);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: 0 8px 24px oklch(0 0 0 / 0.18);
|
/* Hard offset shadow (DOS-style), not a soft drop shadow — keeps the
|
||||||
|
border-driven system and still separates stacked windows, which matters
|
||||||
|
because --card now equals the desktop background. */
|
||||||
|
box-shadow: 3px 3px 0 0 var(--border);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-wm-window][data-wm-focused] {
|
[data-wm-window][data-wm-focused] {
|
||||||
border-color: var(--ring);
|
border-color: var(--ring);
|
||||||
box-shadow: 0 12px 32px oklch(0 0 0 / 0.28);
|
box-shadow: 3px 3px 0 0 var(--ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-wm-window][data-wm-dragging],
|
[data-wm-window][data-wm-dragging],
|
||||||
@@ -417,8 +450,177 @@ a:hover {
|
|||||||
}
|
}
|
||||||
.markdown-body a {
|
.markdown-body a {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
text-decoration: none;
|
text-decoration: underline;
|
||||||
|
text-decoration-style: dotted;
|
||||||
|
text-underline-offset: 2px;
|
||||||
}
|
}
|
||||||
.markdown-body a:hover {
|
.markdown-body a:hover {
|
||||||
text-decoration: underline;
|
text-decoration-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── cyberspace idioms (work in any theme; idiomatic for the terminal look) ── */
|
||||||
|
|
||||||
|
/* Pixel/terminal display face for stylized wordmarks & hero titles. Maps to
|
||||||
|
VT323 when available, JetBrains Mono fallback. Headings use --font-heading
|
||||||
|
(JetBrains Mono) by default; opt into this per-element for the "de-imagined"
|
||||||
|
title voice. */
|
||||||
|
.font-vt {
|
||||||
|
font-family: 'VT323', var(--font-mono);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bordered square card with a 2px fg focus ring — the universal cyberspace
|
||||||
|
surface. Use on any container that wants the terminal-box look. */
|
||||||
|
.terminal-box {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.terminal-box:focus-within {
|
||||||
|
box-shadow: 0 0 0 2px var(--ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════════════
|
||||||
|
TERMINAL DESIGN SYSTEM — cyberspace.online adoption
|
||||||
|
These rules are UNLAYERED (written after Tailwind's @layer utilities),
|
||||||
|
so they override utility classes — shadow-*, ring-*, rounded-* — on the
|
||||||
|
shadcn data-slot primitives regardless of specificity. The system is:
|
||||||
|
• border-driven — hairline borders separate surfaces; no soft shadows
|
||||||
|
• square — every corner is 0 (also enforced via --radius tokens)
|
||||||
|
• focus by color — :focus signals via border/text color, not glow rings
|
||||||
|
• DOS modals — dialogs get a double fg-line frame + hatched corner
|
||||||
|
════════════════════════════════════════════════════════════════════════ */
|
||||||
|
:root {
|
||||||
|
--dos-border: 1px; /* DOS frame line width (used doubled for the modal edge) */
|
||||||
|
--dos-dither: 4px; /* hatch tile size for the modal corner shadow */
|
||||||
|
--dos-offset: 7px; /* how far the hatched corner sits out from the frame */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Containers → terminal-box: solid hairline border, square, no shadow.
|
||||||
|
Replaces shadcn's `shadow-xs ring-1 ring-foreground/10 rounded-xl/md`. ── */
|
||||||
|
[data-slot='card'],
|
||||||
|
[data-slot='popover-content'],
|
||||||
|
[data-slot='hover-card-content'],
|
||||||
|
[data-slot='dropdown-menu-content'],
|
||||||
|
[data-slot='select-content'],
|
||||||
|
[data-slot='tooltip-content'],
|
||||||
|
[data-slot='sheet-content'],
|
||||||
|
[data-slot='menubar-content'],
|
||||||
|
[data-slot='alert-dialog-content'] {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dialogs → the DOS frame. A 1px fg border, then a bg gap, then a 1px fg
|
||||||
|
ring (two stacked box-shadows) = the double-line terminal window edge. ── */
|
||||||
|
[data-slot='dialog-content'] {
|
||||||
|
position: fixed;
|
||||||
|
border: var(--dos-border) solid var(--foreground);
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 var(--dos-border) var(--background),
|
||||||
|
0 0 0 calc(var(--dos-border) * 2) var(--foreground);
|
||||||
|
}
|
||||||
|
/* Hatched corner shadow (fg-dim 45° checks) bottom-right — the DOS window
|
||||||
|
tell. Clipped to a small square outside the frame. */
|
||||||
|
[data-slot='dialog-content']::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: calc(-1 * var(--dos-offset));
|
||||||
|
bottom: calc(-1 * var(--dos-offset));
|
||||||
|
width: var(--dos-offset);
|
||||||
|
height: var(--dos-offset);
|
||||||
|
pointer-events: none;
|
||||||
|
background-color: var(--muted-foreground);
|
||||||
|
background-image:
|
||||||
|
linear-gradient(
|
||||||
|
45deg,
|
||||||
|
var(--background) 25%,
|
||||||
|
transparent 25%,
|
||||||
|
transparent 75%,
|
||||||
|
var(--background) 75%
|
||||||
|
),
|
||||||
|
linear-gradient(
|
||||||
|
45deg,
|
||||||
|
var(--background) 25%,
|
||||||
|
transparent 25%,
|
||||||
|
transparent 75%,
|
||||||
|
var(--background) 75%
|
||||||
|
);
|
||||||
|
background-size: var(--dos-dither) var(--dos-dither);
|
||||||
|
background-position:
|
||||||
|
0 0,
|
||||||
|
calc(var(--dos-dither) / 2) calc(var(--dos-dither) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Overlays: opaque-ish theme background, no blur (terminal, not glass). ── */
|
||||||
|
[data-slot='dialog-overlay'],
|
||||||
|
[data-slot='alert-dialog-overlay'],
|
||||||
|
[data-slot='sheet-overlay'] {
|
||||||
|
background-color: color-mix(in oklab, var(--background) 82%, transparent);
|
||||||
|
backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Inputs: square, no resting shadow; focus = solid fg border (cyberspace
|
||||||
|
signals focus by border color, not a glow ring). ── */
|
||||||
|
[data-slot='input'],
|
||||||
|
[data-slot='textarea'] {
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
[data-slot='input']:focus,
|
||||||
|
[data-slot='input']:focus-visible,
|
||||||
|
[data-slot='textarea']:focus,
|
||||||
|
[data-slot='textarea']:focus-visible {
|
||||||
|
border-color: var(--foreground);
|
||||||
|
box-shadow: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Buttons: square, no resting shadow. Focus keeps the cva border-color
|
||||||
|
shift to fg (focus-visible:border-ring) rather than a ring. ── */
|
||||||
|
[data-slot='button'] {
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Badges & tabs: square. ── */
|
||||||
|
[data-slot='badge'] {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
[data-slot='tabs-trigger'],
|
||||||
|
[data-slot='tabs-list'] {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Window controls (titlebar minimize/maximize/close + taskbar item close).
|
||||||
|
ONE style across all window chrome: bordered square, invert on hover — the
|
||||||
|
same idiom as desktop icons and taskbar buttons. Close is intentionally not
|
||||||
|
"danger"-colored so every control reads as the same component. ── */
|
||||||
|
.win-ctrl {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
transition:
|
||||||
|
color 0.12s,
|
||||||
|
border-color 0.12s,
|
||||||
|
background 0.12s;
|
||||||
|
}
|
||||||
|
.win-ctrl:hover {
|
||||||
|
border-color: var(--foreground);
|
||||||
|
background: var(--foreground);
|
||||||
|
color: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pin every window titlebar to one exact height, targeted by attribute so it
|
||||||
|
holds for every window regardless of its titlebar classes — including
|
||||||
|
already-open wmkit windows whose titlebar markup can lag behind on HMR
|
||||||
|
(component HMR is unreliable for wmkit windows; CSS HMR is not).
|
||||||
|
`flex: 0 0 2.25rem` is the strongest guarantee a flex item won't grow or
|
||||||
|
shrink — it stops a scrolling/growing content pane from compressing the
|
||||||
|
titlebar (the chat-window symptom). */
|
||||||
|
[data-wm-drag] {
|
||||||
|
flex: 0 0 2.25rem !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,6 @@
|
|||||||
} = $props()
|
} = $props()
|
||||||
|
|
||||||
let input = $state(typeof initialDraft === 'string' ? initialDraft : '')
|
let input = $state(typeof initialDraft === 'string' ? initialDraft : '')
|
||||||
let messagesEnd = $state<HTMLDivElement | null>(null)
|
|
||||||
let scrolledUp = $state(false)
|
let scrolledUp = $state(false)
|
||||||
let container = $state<HTMLDivElement | null>(null)
|
let container = $state<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
@@ -150,12 +149,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-scroll to bottom on new messages (or a freshly-raised question) —
|
// Auto-scroll to bottom on new messages (or a freshly-raised question) —
|
||||||
// unless user scrolled up to read.
|
// unless user scrolled up to read. Sets scrollTop on the messages container
|
||||||
|
// directly instead of `scrollIntoView`, which walks ancestors and forces a
|
||||||
|
// reflow that can momentarily perturb the window titlebar height.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void messages
|
void messages
|
||||||
void question
|
void question
|
||||||
if (streaming || !scrolledUp) {
|
if (streaming || !scrolledUp) {
|
||||||
setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
|
setTimeout(
|
||||||
|
() => container?.scrollTo({ top: container.scrollHeight, behavior: 'smooth' }),
|
||||||
|
50
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -326,7 +330,6 @@
|
|||||||
{#if question}
|
{#if question}
|
||||||
<OperatorQuestion {sessionId} {question} />
|
<OperatorQuestion {sessionId} {question} />
|
||||||
{/if}
|
{/if}
|
||||||
<div bind:this={messagesEnd}></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,7 @@
|
|||||||
|
|
||||||
// pulse brightness
|
// pulse brightness
|
||||||
const alpha = p.pulse * (0.35 + 0.15 * Math.sin(t * 1.2 + p.phase))
|
const alpha = p.pulse * (0.35 + 0.15 * Math.sin(t * 1.2 + p.phase))
|
||||||
ctx.fillStyle = dark ? `rgba(140,175,230,${alpha})` : `rgba(60,90,140,${alpha})`
|
ctx.fillStyle = dark ? `rgba(168,153,132,${alpha})` : `rgba(58,58,58,${alpha})`
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2)
|
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2)
|
||||||
ctx.fill()
|
ctx.fill()
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
const dist = dx * dx + dy * dy
|
const dist = dx * dx + dy * dy
|
||||||
if (dist < CONNECT_DIST * CONNECT_DIST) {
|
if (dist < CONNECT_DIST * CONNECT_DIST) {
|
||||||
const alpha = (1 - Math.sqrt(dist) / CONNECT_DIST) * 0.18
|
const alpha = (1 - Math.sqrt(dist) / CONNECT_DIST) * 0.18
|
||||||
ctx.strokeStyle = dark ? `rgba(140,175,230,${alpha})` : `rgba(60,90,140,${alpha})`
|
ctx.strokeStyle = dark ? `rgba(168,153,132,${alpha})` : `rgba(58,58,58,${alpha})`
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.moveTo(a.x, a.y)
|
ctx.moveTo(a.x, a.y)
|
||||||
ctx.lineTo(b.x, b.y)
|
ctx.lineTo(b.x, b.y)
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
const cx = w / 2,
|
const cx = w / 2,
|
||||||
cy = h / 2
|
cy = h / 2
|
||||||
const scrim = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.hypot(cx, cy))
|
const scrim = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.hypot(cx, cy))
|
||||||
const base = dark ? '13,17,23' : '255,255,255'
|
const base = dark ? '0,0,0' : '239,229,192'
|
||||||
scrim.addColorStop(0, `rgba(${base},0.72)`)
|
scrim.addColorStop(0, `rgba(${base},0.72)`)
|
||||||
scrim.addColorStop(0.35, `rgba(${base},0.40)`)
|
scrim.addColorStop(0.35, `rgba(${base},0.40)`)
|
||||||
scrim.addColorStop(0.65, `rgba(${base},0.08)`)
|
scrim.addColorStop(0.65, `rgba(${base},0.08)`)
|
||||||
|
|||||||
@@ -1101,7 +1101,7 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 999px;
|
border-radius: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -1114,9 +1114,12 @@
|
|||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
}
|
}
|
||||||
.chip.on {
|
.chip.on {
|
||||||
color: var(--foreground);
|
color: var(--background);
|
||||||
background: var(--secondary);
|
background: var(--foreground);
|
||||||
border-color: var(--primary);
|
border-color: var(--foreground);
|
||||||
|
}
|
||||||
|
.chip.on b {
|
||||||
|
color: var(--background);
|
||||||
}
|
}
|
||||||
.chip .dot {
|
.chip .dot {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
|
|||||||
206
web/src/lib/components/RasterImage.svelte
Normal file
206
web/src/lib/components/RasterImage.svelte
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
// Cached dither result for the current source/size, so a theme change only
|
||||||
|
// re-paints (cheap) instead of re-running the error-diffusion pass.
|
||||||
|
export interface DitherCache {
|
||||||
|
key: string
|
||||||
|
bits: Uint8Array // 1 = light (paper), 0 = dark (ink)
|
||||||
|
alpha: Uint8Array
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
|
||||||
|
let {
|
||||||
|
src,
|
||||||
|
alt = '',
|
||||||
|
width = 256,
|
||||||
|
class: className = '',
|
||||||
|
plain = false,
|
||||||
|
bias = 0
|
||||||
|
}: {
|
||||||
|
src: string
|
||||||
|
alt?: string
|
||||||
|
/** CSS display width in px. The image is dithered at this resolution. */
|
||||||
|
width?: number
|
||||||
|
class?: string
|
||||||
|
/** Skip dithering; render the crisp source <img> instead. */
|
||||||
|
plain?: boolean
|
||||||
|
/** -255..255. Positive → more pixels resolve to ink (foreground). */
|
||||||
|
bias?: number
|
||||||
|
} = $props()
|
||||||
|
|
||||||
|
let canvas = $state<HTMLCanvasElement | null>(null)
|
||||||
|
let imgEl = $state<HTMLImageElement | null>(null)
|
||||||
|
let loaded = $state(false)
|
||||||
|
let tainted = $state(false)
|
||||||
|
let cache: DitherCache | null = null
|
||||||
|
|
||||||
|
const showSkeleton = $derived(!loaded)
|
||||||
|
const showCanvas = $derived(loaded && !plain && !tainted)
|
||||||
|
|
||||||
|
function readRgb(varName: string): [number, number, number] {
|
||||||
|
const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim()
|
||||||
|
const m = raw.match(/#([0-9a-fA-F]{6})/)
|
||||||
|
const hex = m ? m[1] : '000000'
|
||||||
|
return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function paint() {
|
||||||
|
if (!cache || !canvas) return
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
const { bits, alpha, w, h } = cache
|
||||||
|
canvas.width = w
|
||||||
|
canvas.height = h
|
||||||
|
// dark source (ink) → foreground; light source (paper) → background
|
||||||
|
const fg = readRgb('--foreground')
|
||||||
|
const bg = readRgb('--background')
|
||||||
|
const out = ctx.createImageData(w, h)
|
||||||
|
const d = out.data
|
||||||
|
for (let p = 0, i = 0; p < w * h; p++, i += 4) {
|
||||||
|
if (alpha[p] < 64) {
|
||||||
|
d[i + 3] = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const c = bits[p] ? bg : fg
|
||||||
|
d[i] = c[0]
|
||||||
|
d[i + 1] = c[1]
|
||||||
|
d[i + 2] = c[2]
|
||||||
|
d[i + 3] = 255
|
||||||
|
}
|
||||||
|
ctx.putImageData(out, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function process(img: HTMLImageElement) {
|
||||||
|
const nw = img.naturalWidth || img.width
|
||||||
|
const nh = img.naturalHeight || img.height
|
||||||
|
if (!nw || !nh) return
|
||||||
|
const w = Math.max(1, Math.round(width))
|
||||||
|
const h = Math.max(1, Math.round((w * nh) / nw))
|
||||||
|
const off = document.createElement('canvas')
|
||||||
|
off.width = w
|
||||||
|
off.height = h
|
||||||
|
const octx = off.getContext('2d', { willReadFrequently: true })
|
||||||
|
if (!octx) return
|
||||||
|
octx.drawImage(img, 0, 0, w, h)
|
||||||
|
let data: Uint8ClampedArray
|
||||||
|
try {
|
||||||
|
data = octx.getImageData(0, 0, w, h).data
|
||||||
|
} catch {
|
||||||
|
tainted = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const n = w * h
|
||||||
|
const lum = new Float32Array(n)
|
||||||
|
const alpha = new Uint8Array(n)
|
||||||
|
for (let p = 0, i = 0; p < n; p++, i += 4) {
|
||||||
|
lum[p] = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]
|
||||||
|
alpha[p] = data[i + 3]
|
||||||
|
}
|
||||||
|
const thr = 128 - bias
|
||||||
|
const bits = new Uint8Array(n)
|
||||||
|
// Atkinson error diffusion: 6 neighbors each get 1/8 of the quantization
|
||||||
|
// error. Softer & more "screen-printed" than Floyd–Steinberg.
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const idx = y * w + x
|
||||||
|
const oldv = lum[idx]
|
||||||
|
const newv = oldv < thr ? 0 : 255
|
||||||
|
bits[idx] = newv === 255 ? 1 : 0
|
||||||
|
const e = (oldv - newv) / 8
|
||||||
|
if (x + 1 < w) lum[idx + 1] += e
|
||||||
|
if (x + 2 < w) lum[idx + 2] += e
|
||||||
|
if (y + 1 < h) {
|
||||||
|
if (x - 1 >= 0) lum[idx + w - 1] += e
|
||||||
|
lum[idx + w] += e
|
||||||
|
if (x + 1 < w) lum[idx + w + 1] += e
|
||||||
|
}
|
||||||
|
if (y + 2 < h) lum[idx + 2 * w] += e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cache = { key: src + w, bits, alpha, w, h }
|
||||||
|
tainted = false
|
||||||
|
paint()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLoad() {
|
||||||
|
loaded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-dither when the source image, target width, or plain flag changes.
|
||||||
|
$effect(() => {
|
||||||
|
void src
|
||||||
|
void width
|
||||||
|
void plain
|
||||||
|
if (!loaded || !imgEl || plain || tainted) return
|
||||||
|
if (imgEl.complete && imgEl.naturalWidth > 0) process(imgEl)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Theme flip changes --foreground/--background on <html>'s class; re-paint
|
||||||
|
// the cached bits with the new palette (no re-dither needed).
|
||||||
|
const mo = new MutationObserver(() => {
|
||||||
|
if (cache && !plain) paint()
|
||||||
|
})
|
||||||
|
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme'] })
|
||||||
|
return () => mo.disconnect()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="raster-wrap {className}" style="--rw:{width}px">
|
||||||
|
{#if showSkeleton}
|
||||||
|
<div class="raster-skeleton" aria-hidden="true"></div>
|
||||||
|
{/if}
|
||||||
|
{#if !plain}
|
||||||
|
<canvas
|
||||||
|
bind:this={canvas}
|
||||||
|
class="raster-canvas"
|
||||||
|
class:hidden={!showCanvas}
|
||||||
|
role="img"
|
||||||
|
aria-label={alt}
|
||||||
|
></canvas>
|
||||||
|
{/if}
|
||||||
|
<img
|
||||||
|
bind:this={imgEl}
|
||||||
|
{src}
|
||||||
|
{alt}
|
||||||
|
class="raster-fallback"
|
||||||
|
class:hidden={showCanvas}
|
||||||
|
onload={onLoad}
|
||||||
|
onerror={() => {
|
||||||
|
tainted = true
|
||||||
|
loaded = true
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.raster-wrap {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
width: var(--rw);
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
.raster-canvas,
|
||||||
|
.raster-fallback {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
.raster-canvas.hidden,
|
||||||
|
.raster-fallback.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
/* No-flash placeholder while the source decodes — matches cyberspace's
|
||||||
|
raster-image-skeleton (empty, fills with the theme background). */
|
||||||
|
.raster-skeleton {
|
||||||
|
width: 100%;
|
||||||
|
min-height: calc(var(--rw) * 0.6);
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -80,25 +80,30 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="group absolute flex flex-col items-center gap-1 rounded-lg p-1.5 pointer-events-auto select-none focus-visible:outline-2 focus-visible:outline-ring {dragging
|
class="group absolute flex flex-col items-center gap-1.5 p-1.5 pointer-events-auto select-none transition-transform focus-visible:outline-2 focus-visible:outline-ring {dragging
|
||||||
? 'z-50 cursor-grabbing bg-accent/40'
|
? 'z-50 cursor-grabbing'
|
||||||
: 'cursor-pointer hover:bg-accent/30'}"
|
: 'cursor-pointer hover:-translate-y-0.5'}"
|
||||||
style="left: {left}px; top: {top}px; width: {GRID.cell}px;"
|
style="left: {left}px; top: {top}px; width: {GRID.cell}px;"
|
||||||
onpointerdown={onPointerDown}
|
onpointerdown={onPointerDown}
|
||||||
onkeydown={onKeydown}
|
onkeydown={onKeydown}
|
||||||
title={app.title}
|
title={app.title}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="relative flex size-10 items-center justify-center rounded-xl border bg-card/80 text-foreground shadow-sm backdrop-blur"
|
class="relative flex size-11 items-center justify-center border transition-all {dragging
|
||||||
|
? 'border-foreground bg-foreground text-background shadow-[4px_4px_0_0_var(--foreground)]'
|
||||||
|
: 'border-border bg-card text-foreground group-hover:border-foreground group-hover:bg-foreground group-hover:text-background'}"
|
||||||
>
|
>
|
||||||
<app.icon class="size-5" />
|
<app.icon class="size-5 transition-colors" />
|
||||||
{#if badge > 0}
|
{#if badge > 0}
|
||||||
<span
|
<span
|
||||||
class="absolute -right-1.5 -top-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold text-destructive-foreground"
|
class="absolute -right-1.5 -top-1.5 flex h-4 min-w-4 items-center justify-center border border-background bg-destructive px-1 text-[10px] font-semibold text-destructive-foreground"
|
||||||
>
|
>
|
||||||
{badge > 99 ? '99+' : badge}
|
{badge > 99 ? '99+' : badge}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="max-w-full truncate text-[11px] text-foreground/90">{app.title}</span>
|
<span
|
||||||
|
class="max-w-full truncate text-[10px] uppercase tracking-wider text-muted-foreground transition-colors group-hover:text-foreground"
|
||||||
|
>{app.title}</span
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -53,10 +53,10 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-11 shrink-0 items-center gap-1.5 border-t bg-muted/30 px-2">
|
<div class="flex h-11 shrink-0 items-center gap-1.5 border-t border-border bg-background px-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex shrink-0 items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
class="flex shrink-0 items-center justify-center border border-border p-1.5 text-muted-foreground transition-colors hover:border-foreground hover:bg-foreground hover:text-background"
|
||||||
onclick={toggleShowDesktop}
|
onclick={toggleShowDesktop}
|
||||||
title="Show desktop"
|
title="Show desktop"
|
||||||
>
|
>
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
|
|
||||||
<div class="h-6 w-px shrink-0 bg-border"></div>
|
<div class="h-6 w-px shrink-0 bg-border"></div>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
|
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto py-1.5">
|
||||||
{#each buttons as win (win.id)}
|
{#each buttons as win (win.id)}
|
||||||
{@const Icon = iconFor(win.id)}
|
{@const Icon = iconFor(win.id)}
|
||||||
{@const badge = badgeFor(win.id)}
|
{@const badge = badgeFor(win.id)}
|
||||||
@@ -73,12 +73,12 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-taskbar-btn={win.id}
|
data-taskbar-btn={win.id}
|
||||||
class="flex h-8 max-w-56 items-center gap-1.5 rounded-md border px-2 font-mono text-xs transition-colors {$wmState.focusedId ===
|
class="flex h-8 max-w-56 items-center gap-1.5 border px-2 font-mono text-xs transition-colors {$wmState.focusedId ===
|
||||||
win.id && win.stage !== 'minimized'
|
win.id && win.stage !== 'minimized'
|
||||||
? 'border-primary/50 bg-primary/10 text-foreground'
|
? 'border-foreground bg-foreground text-background'
|
||||||
: 'border-transparent bg-card/60 text-muted-foreground hover:bg-muted'} {win.stage ===
|
: 'border-border bg-background text-muted-foreground hover:border-foreground hover:bg-foreground hover:text-background'} {win.stage ===
|
||||||
'minimized'
|
'minimized'
|
||||||
? 'opacity-60'
|
? 'opacity-50'
|
||||||
: ''}"
|
: ''}"
|
||||||
onclick={() => toggle(win.id, win)}
|
onclick={() => toggle(win.id, win)}
|
||||||
title={win.title}
|
title={win.title}
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
<span class="min-w-0 truncate">{truncateMiddle(win.title, 26)}</span>
|
<span class="min-w-0 truncate">{truncateMiddle(win.title, 26)}</span>
|
||||||
{#if badge > 0}
|
{#if badge > 0}
|
||||||
<span
|
<span
|
||||||
class="flex h-3.5 min-w-3.5 shrink-0 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-semibold text-destructive-foreground"
|
class="flex h-3.5 min-w-3.5 shrink-0 items-center justify-center border border-background bg-destructive px-1 text-[9px] font-semibold text-destructive-foreground"
|
||||||
>
|
>
|
||||||
{badge > 99 ? '99+' : badge}
|
{badge > 99 ? '99+' : badge}
|
||||||
</span>
|
</span>
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="absolute -top-1.5 -right-1.5 hidden size-4 items-center justify-center rounded-full bg-muted-foreground/80 text-background hover:bg-destructive group-hover/tb:flex"
|
class="win-ctrl absolute -top-1.5 -right-1.5 hidden size-4 group-hover/tb:flex"
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
wm.close(win.id)
|
wm.close(win.id)
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
<div class="flex shrink-0 items-center gap-0.5">
|
<div class="flex shrink-0 items-center gap-0.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
class="flex items-center justify-center border border-border p-1.5 text-muted-foreground transition-colors hover:border-foreground hover:bg-foreground hover:text-background"
|
||||||
onclick={() => toggleTheme()}
|
onclick={() => toggleTheme()}
|
||||||
title="Cycle theme ({THEME_LABELS[getTheme()]})"
|
title="Cycle theme ({THEME_LABELS[getTheme()]})"
|
||||||
aria-label="Cycle theme, currently {THEME_LABELS[getTheme()]}"
|
aria-label="Cycle theme, currently {THEME_LABELS[getTheme()]}"
|
||||||
@@ -122,12 +122,12 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
class="flex items-center justify-center border border-border p-1.5 text-muted-foreground transition-colors hover:border-foreground hover:bg-foreground hover:text-background"
|
||||||
onclick={() => openAppWindow('settings')}
|
onclick={() => openAppWindow('settings')}
|
||||||
title="Settings"
|
title="Settings"
|
||||||
>
|
>
|
||||||
<SettingsIcon class="size-4" />
|
<SettingsIcon class="size-4" />
|
||||||
</button>
|
</button>
|
||||||
<span class="px-1.5 text-[11px] text-muted-foreground select-none">{VERSION}</span>
|
<span class="px-1.5 font-mono text-[11px] text-muted-foreground select-none">{VERSION}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,18 +67,18 @@
|
|||||||
<section use:dk.window={{ id }} class="min-w-0" aria-label={win.title}>
|
<section use:dk.window={{ id }} class="min-w-0" aria-label={win.title}>
|
||||||
<header
|
<header
|
||||||
data-wm-drag
|
data-wm-drag
|
||||||
class="flex shrink-0 cursor-move items-center justify-between gap-2 border-b bg-muted/40 px-3 py-1.5"
|
class="flex h-9 shrink-0 cursor-move items-center justify-between gap-2 overflow-hidden border-b border-border bg-background px-3"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
data-wm-title
|
data-wm-title
|
||||||
class="flex min-w-0 flex-1 items-center self-stretch truncate font-mono text-xs font-medium"
|
class="min-w-0 flex-1 truncate font-mono text-xs font-medium"
|
||||||
>{win.title}</span
|
>{win.title}</span
|
||||||
>
|
>
|
||||||
<div class="flex shrink-0 items-center gap-0.5">
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-wm-minimize
|
data-wm-minimize
|
||||||
class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
class="win-ctrl flex size-6"
|
||||||
aria-label="Minimize {win.title}"
|
aria-label="Minimize {win.title}"
|
||||||
>
|
>
|
||||||
<MinusIcon class="size-3.5" />
|
<MinusIcon class="size-3.5" />
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-wm-maximize
|
data-wm-maximize
|
||||||
class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
class="win-ctrl flex size-6"
|
||||||
aria-label="Maximize {win.title}"
|
aria-label="Maximize {win.title}"
|
||||||
>
|
>
|
||||||
<Maximize2Icon class="size-3.5" />
|
<Maximize2Icon class="size-3.5" />
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-wm-close
|
data-wm-close
|
||||||
class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
class="win-ctrl flex size-6"
|
||||||
aria-label="Close {win.title}"
|
aria-label="Close {win.title}"
|
||||||
>
|
>
|
||||||
<XIcon class="size-3.5" />
|
<XIcon class="size-3.5" />
|
||||||
|
|||||||
@@ -188,7 +188,7 @@
|
|||||||
<div class="flex flex-wrap gap-1.5">
|
<div class="flex flex-wrap gap-1.5">
|
||||||
{#each stats.topTags as [tag, count] (tag)}
|
{#each stats.topTags as [tag, count] (tag)}
|
||||||
<span
|
<span
|
||||||
class="flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground"
|
class="flex items-center gap-1 border px-2 py-0.5 text-[11px] text-muted-foreground"
|
||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
<span class="text-foreground/70 tabular-nums">{count}</span>
|
<span class="text-foreground/70 tabular-nums">{count}</span>
|
||||||
|
|||||||
@@ -377,7 +377,7 @@
|
|||||||
{#if item.tags.length}
|
{#if item.tags.length}
|
||||||
<div class="mb-3 flex max-w-[68ch] flex-wrap gap-1.5">
|
<div class="mb-3 flex max-w-[68ch] flex-wrap gap-1.5">
|
||||||
{#each item.tags as t (t)}<span
|
{#each item.tags as t (t)}<span
|
||||||
class="rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground"
|
class="border px-2 py-0.5 text-[11px] text-muted-foreground"
|
||||||
>{t}</span
|
>{t}</span
|
||||||
>{/each}
|
>{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,6 +42,6 @@ export function toggleTheme(): Theme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const THEME_LABELS: Record<Theme, string> = {
|
export const THEME_LABELS: Record<Theme, string> = {
|
||||||
light: 'Terracotta',
|
light: 'Light',
|
||||||
dark: 'Carbon'
|
dark: 'Dark'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { fetchWithAuth, setConfig, initConfig, getConfig, clearConfig } from '$lib/config'
|
import { fetchWithAuth, setConfig, initConfig, getConfig, clearConfig } from '$lib/config'
|
||||||
import { startLogin, logout as oidcLogout, getUser, isOIDCConfigured } from '$lib/oidc'
|
import { startLogin, logout as oidcLogout, getUser, isOIDCConfigured } from '$lib/oidc'
|
||||||
import ConfigBackground from '$lib/components/ConfigBackground.svelte'
|
import ConfigBackground from '$lib/components/ConfigBackground.svelte'
|
||||||
|
import RasterImage from '$lib/components/RasterImage.svelte'
|
||||||
import type { WailsGlobal } from '$lib/types'
|
import type { WailsGlobal } from '$lib/types'
|
||||||
|
|
||||||
let { onConnected, onCancel }: { onConnected: () => void; onCancel?: () => void } = $props()
|
let { onConnected, onCancel }: { onConnected: () => void; onCancel?: () => void } = $props()
|
||||||
@@ -93,11 +94,12 @@
|
|||||||
|
|
||||||
<div class="relative z-10 flex h-full items-center justify-center p-6">
|
<div class="relative z-10 flex h-full items-center justify-center p-6">
|
||||||
<div
|
<div
|
||||||
class="w-full max-w-[26rem] space-y-8 rounded-2xl border border-white/8 bg-card/60 p-8 shadow-2xl shadow-black/40 backdrop-blur-xl"
|
class="w-full max-w-[26rem] space-y-8 border border-border bg-card p-8 shadow-[3px_3px_0_0_var(--border)]"
|
||||||
>
|
>
|
||||||
<!-- Logo + heading -->
|
<!-- Logo + heading -->
|
||||||
<div class="flex flex-col items-center gap-4">
|
<div class="flex flex-col items-center gap-4">
|
||||||
<svg viewBox="0 0 91 100" class="h-16 w-16 fill-white/90" aria-hidden="true">
|
<RasterImage src="/mascot/egg-idle.png" alt="" width={72} bias={16} />
|
||||||
|
<svg viewBox="0 0 91 100" class="h-12 w-12 fill-foreground/90" aria-hidden="true">
|
||||||
<path
|
<path
|
||||||
d="m45.601 1q20.993 0 33.71 15.946 10.799 13.625 10.799 31.287 0 12.414-5.9548 25.131-5.9548 12.717-16.451 19.176-10.395 6.4592-23.213 6.4592-20.892 0-33.205-16.653-10.395-14.029-10.395-31.489 0-12.717 6.2577-25.232 6.3584-12.616 16.653-18.57 10.295-6.0556 21.801-6.0556zm-3.128 6.5605q-5.3492 0-10.799 3.2296-5.3492 3.1287-8.68 11.102-3.3305 7.9735-3.3305 20.488 0 20.185 7.973 34.82 8.0743 14.634 21.195 14.634 9.7896 0 16.149-8.0743 6.3584-8.0743 6.3584-27.755 0-24.627-10.597-38.756-7.1657-9.6888-18.268-9.6888z"
|
d="m45.601 1q20.993 0 33.71 15.946 10.799 13.625 10.799 31.287 0 12.414-5.9548 25.131-5.9548 12.717-16.451 19.176-10.395 6.4592-23.213 6.4592-20.892 0-33.205-16.653-10.395-14.029-10.395-31.489 0-12.717 6.2577-25.232 6.3584-12.616 16.653-18.57 10.295-6.0556 21.801-6.0556zm-3.128 6.5605q-5.3492 0-10.799 3.2296-5.3492 3.1287-8.68 11.102-3.3305 7.9735-3.3305 20.488 0 20.185 7.973 34.82 8.0743 14.634 21.195 14.634 9.7896 0 16.149-8.0743 6.3584-8.0743 6.3584-27.755 0-24.627-10.597-38.756-7.1657-9.6888-18.268-9.6888z"
|
||||||
/>
|
/>
|
||||||
@@ -128,7 +130,7 @@
|
|||||||
{#if showOidcContinue}
|
{#if showOidcContinue}
|
||||||
<!-- OIDC authenticated state -->
|
<!-- OIDC authenticated state -->
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center gap-3 rounded-xl border border-white/5 bg-background/40 p-5"
|
class="flex flex-col items-center gap-3 border border-border bg-background/40 p-5"
|
||||||
>
|
>
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">
|
||||||
Logged in as <span class="font-semibold text-foreground">{oidcUser}</span>
|
Logged in as <span class="font-semibold text-foreground">{oidcUser}</span>
|
||||||
|
|||||||
@@ -99,9 +99,9 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (filter = f.id)}
|
onclick={() => (filter = f.id)}
|
||||||
class="rounded-full border px-2.5 py-1 text-xs transition-colors {filter === f.id
|
class="border px-2.5 py-1 text-xs transition-colors {filter === f.id
|
||||||
? 'border-primary bg-primary/10 text-foreground'
|
? 'border-foreground bg-foreground text-background'
|
||||||
: 'border-border bg-card/60 text-muted-foreground backdrop-blur hover:bg-muted/50'}"
|
: 'border-border text-muted-foreground hover:border-foreground hover:text-foreground'}"
|
||||||
>
|
>
|
||||||
{f.label}
|
{f.label}
|
||||||
<span class="ml-1 opacity-60">{counts[f.id] ?? 0}</span>
|
<span class="ml-1 opacity-60">{counts[f.id] ?? 0}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user