6 Commits

Author SHA1 Message Date
alexei.dolgolyov 16c667ca15 feat(status): per-browser dismissal for Recent Incidents
The Recent Incidents list is derived server-side from raw AppStatus
health-check samples, so there is no incident row to delete and deleting
the underlying samples would corrupt uptime % and the sparkline timeline.
Per-browser, non-destructive dismissal is the right model: localStorage
holds the dismissed (appId, ISO startedAt) keys, the page filters them
out on render, and a Restore affordance brings them back.

- Per-row Dismiss (X) and section-level Clear all
- Restore link appears whenever any incident on the current page is hidden
- Dismissal key is (appId, startedAt) so it survives 24h/7d/30d switches
- Focus is moved to the Restore link after Clear all empties the list
  (otherwise the unmounting button would drop focus to <body>)
- Quota / disabled-localStorage failure is swallowed; in-memory state
  still works for the active session

Hand-rolled <button> elements match 14 other link-styled buttons already
in the project; both use the project-standard focus-visible:ring-2
focus-visible:ring-primary/30 ring.
2026-05-28 15:40:23 +03:00
alexei.dolgolyov dab13518ef feat(backup): harden restore — strict tar, two-phase rollback, degraded state
- Gate flag flipped synchronously in restore route before body parse,
  closing race where concurrent requests could slip through during awaits
- Strict tar extraction rejects symlinks, hardlinks, absolute paths, and
  parent-segment traversal entries
- Staging directory moved to a sibling of the uploads dir so atomic renames
  stay on the same filesystem (Windows %TEMP%/Linux tmpfs were causing EXDEV)
- Two-phase atomic-rename rollback for uploads — never rmrf the live dir
  before the safety is back in place; degraded flag set if rollback can't
  recover cleanly
- Prisma reconnect failure now marks process degraded; hooks.server.ts
  returns 503 to everything except /api/health so orchestrators can recycle
- /api/health distinguishes ok / restoring / degraded / db_down (503s)
- Legacy .db restore now runs structural SQLite integrity check before swap
- Schema-version check tightened: null on either side requires explicit
  allowSchemaMismatch override (was silently treated as a match)
- HMR/multi-import-safe global state (Vite dev reload no longer creates a
  fresh module while a restore is mid-flight)
- VACUUM INTO path: defensive rejection of quote/control characters
- Backup filename regex requires a leading alphanumeric (rejects '.tar.gz',
  '....db' which passed the previous loose pattern)
- Download: RFC 5987 Content-Disposition with filename* + sanitized fallback
- Restore route logs BACKUP_FAILED audit row with phase on failure
2026-05-28 14:56:57 +03:00
alexei.dolgolyov f087551454 feat(ui): cozy polish — primitives, motion, empty states
Adds 7 reusable primitives in src/lib/components/ui/ and migrates ~70 hand-rolled
call sites across forms, admin panels, and routes. Tokens unchanged — same Cozy
Home palette, just consistently applied.

Primitives
- Switch: pill toggle, role=switch, terracotta track, cubic-bezier knob
- Button: 5 variants × 4 sizes, press-squash, loading spinner, buttonClass()
  helper for <a> link-as-CTA cases
- Checkbox: rounded square with animated check-draw + indeterminate
- Select: native <select> with Cozy chevron + matched radius
- Slider: gradient track, terracotta-bordered knob, aria-valuetext
- Input + Field: documented in CLAUDE.md for future use
- 9 buttonClass unit tests

Migrations
- 23 <input type=checkbox> → <Switch> (boolean settings)
- 5 multi-select checkboxes → <Checkbox> (DiscoveryPanel, sys-stats metrics)
- ~28 <select> → <Select>
- 17 <input type=range> → <Slider> (ThemeCustomizer's hue picker kept custom)
- ~25 hand-rolled buttons → <Button> / buttonClass()

Surface polish
- Admin section wrappers: rounded-lg → rounded-[1.4rem] + shadow-soft
  (resolves the Phase-5 tradeoff from the Cozy migration memo)
- BoardPropertiesPanel: live theme preview swatch showing computed hsl() on a
  sample button; hue/sat use Slider; bg/cardSize use Select
- AppHealthBadge: role=status + aria-live=polite; .status-degraded (slow
  amber breathing) and .status-offline (single attention flash) now applied
- AppForm collapse triggers: rotating chevron + aria-expanded
- Empty states for /boards and /apps: inline SVGs using --room-* tokens
  (peach/sky/sage/butter) instead of generic Lucide icons
- Login Remember Me: showcase Switch (first-impression surface)

Motion (src/app.css)
- New cozy-rise / cozy-rise-stagger for staggered grid reveals (/boards, /apps)
- New cozy-expand for accordion sections (healthcheck, integration, wallpaper)
- All motion respects prefers-reduced-motion

CLAUDE.md
- New project guide with a mandatory Frontend reuse table — every primitive
  documented with "never use raw <input type=checkbox>/<select>/<range>" and
  "do not repeat rounded-xl bg-primary px-4 py-2 ..." rules

Verification
- npm run check: 0 errors, 0 warnings, 5831 files
- npm test: 301 passing
- npm run lint: 0 errors (19 pre-existing warnings unchanged)
- npm run build: ✔ done

Branch is feat/cozy-polish, ready to PR against master.
2026-05-28 14:39:53 +03:00
alexei.dolgolyov 555ac9ea63 feat(backup): tar.gz format with uploads + manifest, restore guard
- New tar.gz backup format bundling SQLite snapshot + uploads tree + manifest.json (version, app+schema versions, checksums, dbSize)
- BACKUPS_DIR env override; defaults to /app/data/backups in prod, <cwd>/data/backups in dev (matches uploads convention)
- 503 guard in hooks.server.ts while restore is mid-flight (DB file is being swapped); excludes static assets + /api/health; sets Retry-After: 15
- Legacy .db restore still supported (DB-only)
- Restore endpoint adds schema-mismatch detection + force flag; download/schedule endpoints updated
- 256 MiB free-disk safety margin before backup
- tar dep added to package.json; 18 new backupService tests
- i18n labels (en + ru) for new restore/format states
2026-05-28 14:39:24 +03:00
alexei.dolgolyov 0a13b6b58c fix(i18n): add missing admin.custom_css labels (en + ru)
Lint & Test / lint-and-check (push) Failing after 5m9s
Lint & Test / test (push) Has been skipped
Lint & Test / build (push) Has been skipped
Lint & Test / docker-build (push) Has been skipped
Lint & Test / audit (push) Has been skipped
These keys were referenced in SettingsForm but absent from both locales, so they rendered as raw keys instead of the intended text.
2026-05-27 23:11:40 +03:00
alexei.dolgolyov 5dcadd1c20 feat(ui): migrate entire UI to "Cozy Home" design
Warm, friendly redesign replacing the generic cold-shadcn look. Built as a
swappable token bundle so other presets can be added later; dark mode and the
user-tunable accent hue are retained.

Foundation
- app.css: warm cream (light) + "dusk" (dark) token system; terracotta accent
  (default hue 16); pastel --room-* palette; vivid --status-* (dots/bars) plus
  AA-legible --status-*-ink (text); soft warm shadows; --radius 1rem; font tokens
- Fonts: Fraunces (display) + Figtree (body), self-hosted in static/fonts
  (no Google CDN) so offline/LAN installs work; system-ui fallbacks kept
- h1/h2/h3 render in Fraunces via base layer

Chrome and surfaces
- Sidebar, Header, home, AppCard/BoardCard, BoardHeader, sections, favorites
- 29 widgets + integration renderers: cozy card shells, room-palette charts
- Default background is a static warm "cozy" glow (mesh demoted, rAF gated on
  prefers-reduced-motion)

System-wide
- Status colors tokenized (no raw bg/text-*-500 or status hex); success/warning
  to status tokens, categorical to room palette, errors to destructive
- Inputs rounded-xl; buttons rounded-xl; cards/dialogs rounded-[1.4rem];
  soft-shadow vocabulary only; focus-visible:ring-primary/30
- Forms, admin tables (now cozy cards), dialogs, popovers, auth screens

a11y: reduced-motion guards; darker status "ink" text for AA on cream.
Known tradeoff: terracotta primary + white button text ~2.96:1 (signature color,
user-tunable).

Verified: svelte-check 0/0, build ok, 274 tests pass, eslint 0 errors.
Design refs + system sheet in design-mockups/.
2026-05-27 23:04:47 +03:00
143 changed files with 7919 additions and 1285 deletions
+5
View File
@@ -50,6 +50,11 @@ ALLOW_PRIVATE_NETWORK_FETCH="false"
# scaling horizontally so only one node runs schedulers.
RUN_SCHEDULERS="true"
# Directory where backup archives are written. Defaults to /app/data/backups
# in production and <repo>/data/backups in development. Override if you want
# backups on a separate mount.
BACKUPS_DIR=""
# Optional bearer token for /api/metrics. When set, scrapers must send
# `Authorization: Bearer <token>`. When unset, the endpoint is open (typical
# when the scraper lives on the same private network).
+56
View File
@@ -0,0 +1,56 @@
# web-app-launcher — project guide for Claude
SvelteKit 2 + Svelte 5 (runes) + Tailwind 4 + Prisma + Vitest. Cozy Home design system (warm cream / dusk, terracotta accent, Fraunces + Figtree, soft shadows). Token contract lives in `src/app.css`.
## Frontend
### Basic-component reuse — MANDATORY
When you need any of the following, **use the existing primitive from `src/lib/components/ui/`. Do not hand-roll a new Tailwind class string for a control that already has a primitive.**
| Need | Primitive | Why |
|---|---|---|
| Boolean on/off setting | `Switch.svelte` | Pill toggle, `role="switch"`, AA contrast, terracotta track when on. Default for any "enable X" / "show Y" / "is default" field. **Never use `<input type="checkbox">` for booleans.** |
| Multi-select item in a list | `Checkbox.svelte` | Rounded square with animated check-draw. Only use when the control is truly "pick any number of these," not a single boolean. |
| Dropdown of fixed options | `Select.svelte` | Styled chevron, matches Cozy input radius. Wraps native `<select>`. **Do not use raw `<select>`.** |
| Single-line text / number / email / url / password | `Input.svelte` | Standard rounded-xl, focus ring, invalid state. **Do not repeat the `w-full rounded-xl border border-input bg-background px-3 py-2 ...` string anywhere.** |
| Number in a range (refresh interval, hue, blur, etc.) | `Slider.svelte` | Cozy gradient track, terracotta-bordered knob, value tooltip, `aria-valuetext`. **Do not use raw `<input type="range">`.** |
| Action button (submit, save, cancel, link-as-CTA) | `Button.svelte` | Variants `primary | secondary | outline | ghost | destructive`, sizes `sm | md | lg | icon`, built-in `loading` spinner, press-squash. **Do not repeat `rounded-xl bg-primary px-4 py-2 ...` strings.** |
| Label + hint + error wrapper around a control | `Field.svelte` | Consolidates `<label> + control + <p class="text-xs text-destructive">`. |
| Confirm-before-destructive | `ConfirmDialog.svelte` | Already exists. Use it. |
| Entity / icon / tag picker | `EntityPicker`, `MultiEntityPicker`, `IconPickerButton`, `TagsInput` | Already exist. Reuse. |
### Process
1. Before writing any form control in a `.svelte` file, **scan `src/lib/components/ui/` first**. If a matching primitive exists, import and use it.
2. If you find yourself copying a Tailwind class string verbatim from another file, **stop**: that's the trigger to extract a primitive (or expand an existing one).
3. If you genuinely need a new primitive, add it to `src/lib/components/ui/`, give it a `class?: string` prop merged via `cn()`, document it in this table, and migrate at least two call sites in the same PR so it's not dead code.
4. Tokens (`--primary`, `--card`, `--room-*`, `--shadow-soft`, etc.) are defined once in `src/app.css`. Never hardcode hex/HSL — read from the token.
### Cozy spec quick reminders
- Hero cards: `rounded-[1.4rem]` + `shadow-[var(--shadow-soft)]`. Dense panels: `rounded-xl`. **Never** `rounded-lg` on a section wrapper.
- Headings (`h1`, `h2`, `h3`) automatically get Fraunces via base layer — no need to add `font-display` unless overriding non-heading text.
- Focus uses `focus-visible:ring-2 focus-visible:ring-primary/30` — primitives already do this; mirror it on anything hand-rolled.
- Motion is gentle and present: prefer `cozy-rise` / `cozy-expand` from `app.css` over generic Tailwind animations. All motion classes already respect `prefers-reduced-motion`.
## Backend
- Auth: session cookie + optional OAuth. Roles: `admin` / user / guest. Always check role at the route load function, not the component.
- Validation: Zod schemas live in `src/lib/utils/validators.ts`. Reuse the same schema on client (superForms) and server.
- DB: Prisma. Never query the DB directly from a route — go through `src/lib/server/services/*Service.ts`.
## Testing
- Vitest, Node environment, no DOM (existing pattern). Component tests use the module-scope helpers (e.g., `buttonClass` in `Button.svelte`) rather than rendering — keep that convention.
- Run before committing: `npm run check && npm run lint && npm test && npm run build`.
## Commands
```bash
npm run dev # vite dev on :5181
npm run check # svelte-check (TS + Svelte)
npm run lint # eslint
npm test # vitest run
npm run build # production build
```
+789
View File
@@ -0,0 +1,789 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Web App Launcher — Command Deck</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=Saira:wght@400;500;600;700&family=Saira+Condensed:wght@500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap"
rel="stylesheet"
/>
<style>
:root {
--bg: #070a0d;
--panel: #0d1217;
--panel-2: #10171e;
--line: #1d2730;
--line-bright: #2b3946;
--ink: #e7eef3;
--ink-dim: #7c8b97;
--ink-faint: #4a5763;
--accent: #36e0a4; /* tactical green */
--accent-2: #ffb020; /* amber */
--danger: #ff4d5e;
--warn: #ffb020;
--grid: rgba(54, 224, 164, 0.04);
--radius: 4px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
height: 100%;
}
body {
background: var(--bg);
color: var(--ink);
font-family: 'Saira', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
/* subtle scanline grid backdrop */
body::before {
content: '';
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background-image:
linear-gradient(var(--grid) 1px, transparent 1px),
linear-gradient(90deg, var(--grid) 1px, transparent 1px);
background-size: 40px 40px;
mask-image: radial-gradient(ellipse 80% 60% at 70% 0%, #000 30%, transparent 90%);
}
.app {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 74px 1fr;
min-height: 100vh;
}
.mono {
font-family: 'JetBrains Mono', monospace;
}
.cond {
font-family: 'Saira Condensed', sans-serif;
}
/* ===== Rail ===== */
.rail {
border-right: 1px solid var(--line);
background: linear-gradient(180deg, var(--panel), #080c10);
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 16px 0;
}
.logo {
width: 40px;
height: 40px;
border: 1px solid var(--accent);
border-radius: var(--radius);
display: grid;
place-items: center;
color: var(--accent);
margin-bottom: 18px;
box-shadow:
0 0 0 1px #0a1f18,
0 0 18px -4px var(--accent);
position: relative;
}
.logo svg {
width: 20px;
height: 20px;
}
.rail-btn {
width: 44px;
height: 44px;
border-radius: var(--radius);
display: grid;
place-items: center;
color: var(--ink-dim);
position: relative;
cursor: pointer;
transition: 0.15s;
}
.rail-btn:hover {
color: var(--ink);
background: var(--panel-2);
}
.rail-btn.active {
color: var(--accent);
}
.rail-btn.active::before {
content: '';
position: absolute;
left: -16px;
top: 10px;
bottom: 10px;
width: 2px;
background: var(--accent);
box-shadow: 0 0 8px var(--accent);
}
.rail-btn svg {
width: 20px;
height: 20px;
}
.rail-spacer {
flex: 1;
}
.rail-avatar {
width: 36px;
height: 36px;
border-radius: var(--radius);
background: linear-gradient(135deg, #1a2a22, #0f1a14);
border: 1px solid var(--line-bright);
display: grid;
place-items: center;
font-weight: 700;
color: var(--accent);
font-size: 13px;
}
/* ===== Main ===== */
.main {
display: flex;
flex-direction: column;
min-width: 0;
}
.topbar {
display: flex;
align-items: center;
gap: 16px;
height: 60px;
padding: 0 26px;
border-bottom: 1px solid var(--line);
background: rgba(8, 12, 16, 0.6);
backdrop-filter: blur(8px);
}
.crumbs {
font-family: 'Saira Condensed';
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 12px;
color: var(--ink-faint);
}
.crumbs b {
color: var(--ink-dim);
font-weight: 600;
}
.search {
margin-left: auto;
display: flex;
align-items: center;
gap: 10px;
width: 340px;
max-width: 38vw;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 9px 12px;
color: var(--ink-dim);
font-size: 13px;
cursor: text;
transition: 0.15s;
}
.search:hover {
border-color: var(--line-bright);
}
.search svg {
width: 15px;
height: 15px;
}
.search .kbd {
margin-left: auto;
font-family: 'JetBrains Mono';
font-size: 10px;
color: var(--ink-faint);
border: 1px solid var(--line);
border-radius: 3px;
padding: 2px 6px;
}
.ico-btn {
width: 38px;
height: 38px;
border: 1px solid var(--line);
border-radius: var(--radius);
display: grid;
place-items: center;
color: var(--ink-dim);
cursor: pointer;
transition: 0.15s;
background: var(--panel);
}
.ico-btn:hover {
color: var(--ink);
border-color: var(--line-bright);
}
.ico-btn svg {
width: 17px;
height: 17px;
}
.content {
padding: 26px;
max-width: 1320px;
width: 100%;
margin: 0 auto;
}
/* status bar */
.statline {
display: flex;
align-items: stretch;
gap: 1px;
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
margin-bottom: 24px;
background: var(--line);
}
.stat {
flex: 1;
background: var(--panel);
padding: 16px 18px;
position: relative;
}
.stat .lbl {
font-family: 'Saira Condensed';
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 11px;
color: var(--ink-faint);
}
.stat .val {
font-family: 'JetBrains Mono';
font-size: 26px;
font-weight: 700;
margin-top: 6px;
letter-spacing: -0.02em;
}
.stat .val.ok {
color: var(--accent);
}
.stat .val.warn {
color: var(--warn);
}
.stat .val.bad {
color: var(--danger);
}
.stat .sub {
font-size: 12px;
color: var(--ink-dim);
margin-top: 2px;
}
.stat::after {
content: '';
position: absolute;
left: 0;
top: 0;
height: 2px;
width: 100%;
background: linear-gradient(90deg, var(--accent), transparent);
}
.stat.s2::after {
background: linear-gradient(90deg, var(--accent-2), transparent);
}
.stat.s3::after {
background: linear-gradient(90deg, #3aa0ff, transparent);
}
.stat.s4::after {
background: linear-gradient(90deg, var(--danger), transparent);
}
.sec-head {
display: flex;
align-items: center;
gap: 14px;
margin: 30px 0 16px;
}
.sec-head h2 {
font-family: 'Saira Condensed';
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 15px;
color: var(--ink);
}
.sec-head .rule {
flex: 1;
height: 1px;
background: linear-gradient(90deg, var(--line-bright), transparent);
}
.sec-head .count {
font-family: 'JetBrains Mono';
font-size: 11px;
color: var(--ink-faint);
}
/* app grid */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(248px, 1fr));
gap: 14px;
}
.node {
background: linear-gradient(180deg, var(--panel), var(--panel-2));
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 16px;
cursor: pointer;
position: relative;
transition:
transform 0.15s,
border-color 0.15s,
box-shadow 0.15s;
overflow: hidden;
}
.node::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at 100% 0%, rgba(54, 224, 164, 0.08), transparent 60%);
opacity: 0;
transition: 0.2s;
}
.node:hover {
transform: translateY(-2px);
border-color: var(--line-bright);
box-shadow: 0 10px 30px -12px #000;
}
.node:hover::before {
opacity: 1;
}
.node-top {
display: flex;
align-items: center;
gap: 12px;
}
.node-ico {
width: 40px;
height: 40px;
border-radius: var(--radius);
background: #0a0f13;
border: 1px solid var(--line);
display: grid;
place-items: center;
font-size: 18px;
flex-shrink: 0;
}
.node-name {
font-weight: 600;
font-size: 15px;
line-height: 1.1;
}
.node-cat {
font-family: 'Saira Condensed';
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 10px;
color: var(--ink-faint);
margin-top: 3px;
}
.led {
width: 8px;
height: 8px;
border-radius: 50%;
margin-left: auto;
flex-shrink: 0;
position: relative;
}
.led.ok {
background: var(--accent);
box-shadow: 0 0 10px var(--accent);
}
.led.ok::after {
content: '';
position: absolute;
inset: -4px;
border-radius: 50%;
border: 1px solid var(--accent);
opacity: 0.4;
animation: ping 2s ease-out infinite;
}
.led.warn {
background: var(--warn);
box-shadow: 0 0 10px var(--warn);
}
.led.bad {
background: var(--danger);
box-shadow: 0 0 10px var(--danger);
}
@keyframes ping {
0% {
transform: scale(0.8);
opacity: 0.5;
}
100% {
transform: scale(2.2);
opacity: 0;
}
}
.node-foot {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.node-foot .up {
font-family: 'JetBrains Mono';
font-size: 11px;
color: var(--ink-dim);
}
.node-foot .up b {
color: var(--accent);
font-weight: 500;
}
.node-foot .up.bad b {
color: var(--danger);
}
.spark {
height: 22px;
width: 96px;
}
.footer-note {
margin-top: 40px;
text-align: center;
font-family: 'JetBrains Mono';
font-size: 11px;
color: var(--ink-faint);
}
/* entrance */
@keyframes rise {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: none;
}
}
.node,
.stat {
animation: rise 0.5s both;
}
.stat:nth-child(2) {
animation-delay: 0.05s;
}
.stat:nth-child(3) {
animation-delay: 0.1s;
}
.stat:nth-child(4) {
animation-delay: 0.15s;
}
.grid .node:nth-child(1) {
animation-delay: 0.1s;
}
.grid .node:nth-child(2) {
animation-delay: 0.16s;
}
.grid .node:nth-child(3) {
animation-delay: 0.22s;
}
.grid .node:nth-child(4) {
animation-delay: 0.28s;
}
.grid .node:nth-child(5) {
animation-delay: 0.34s;
}
.grid .node:nth-child(6) {
animation-delay: 0.4s;
}
.grid .node:nth-child(7) {
animation-delay: 0.46s;
}
.grid .node:nth-child(8) {
animation-delay: 0.52s;
}
</style>
</head>
<body>
<div class="app">
<!-- Rail -->
<nav class="rail">
<div class="logo" title="Launcher">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
</div>
<a class="rail-btn active" title="Overview"
><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="21" x2="9" y2="9" /></svg
></a>
<a class="rail-btn" title="Apps"
><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="9" />
<line x1="3" y1="12" x2="21" y2="12" />
<path d="M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18" /></svg
></a>
<a class="rail-btn" title="Status"
><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M22 12h-4l-3 9L9 3l-3 9H2" /></svg
></a>
<a class="rail-btn" title="Admin"
><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="3" />
<path
d="M19 12a7 7 0 0 0-.1-1l2-1.6-2-3.4-2.4 1a7 7 0 0 0-1.7-1l-.4-2.6h-4l-.4 2.6a7 7 0 0 0-1.7 1l-2.4-1-2 3.4 2 1.6a7 7 0 0 0 0 2l-2 1.6 2 3.4 2.4-1a7 7 0 0 0 1.7 1l.4 2.6h4l.4-2.6a7 7 0 0 0 1.7-1l2.4 1 2-3.4-2-1.6a7 7 0 0 0 .1-1z"
/></svg
></a>
<div class="rail-spacer"></div>
<div class="rail-avatar">AD</div>
</nav>
<!-- Main -->
<div class="main">
<header class="topbar">
<div class="crumbs">SYSTEMS / <b>OVERVIEW</b></div>
<div class="search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
Search apps, boards, commands…
<span class="kbd">⌘K</span>
</div>
<div class="ico-btn" title="Notifications">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.7 21a2 2 0 0 1-3.4 0" />
</svg>
</div>
<div class="ico-btn" title="Theme">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="4" />
<path
d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"
/>
</svg>
</div>
</header>
<div class="content">
<!-- Status line -->
<div class="statline">
<div class="stat">
<div class="lbl">Services Online</div>
<div class="val ok">08 / 10</div>
<div class="sub">2 require attention</div>
</div>
<div class="stat s2">
<div class="lbl">Avg Response</div>
<div class="val warn">
142<span style="font-size: 14px; color: var(--ink-faint)"> ms</span>
</div>
<div class="sub">p95 over 24h</div>
</div>
<div class="stat s3">
<div class="lbl">Fleet Uptime</div>
<div class="val" style="color: #3aa0ff">99.4%</div>
<div class="sub">rolling 30 days</div>
</div>
<div class="stat s4">
<div class="lbl">UPS Load</div>
<div class="val bad">61%</div>
<div class="sub">est. 38 min on battery</div>
</div>
</div>
<!-- Favorites / pinned -->
<div class="sec-head">
<h2>Pinned Services</h2>
<span class="rule"></span><span class="count">8 ACTIVE</span>
</div>
<div class="grid">
<!-- 1 Jellyfin -->
<div class="node">
<div class="node-top">
<div class="node-ico">🎬</div>
<div>
<div class="node-name">Jellyfin</div>
<div class="node-cat">Media</div>
</div>
<div class="led ok"></div>
</div>
<div class="node-foot">
<svg class="spark" viewBox="0 0 96 22" preserveAspectRatio="none">
<polyline
fill="none"
stroke="#36e0a4"
stroke-width="1.5"
points="0,16 12,14 24,17 36,9 48,12 60,7 72,10 84,5 96,8"
/></svg
><span class="up"><b>99.9%</b> 24h</span>
</div>
</div>
<!-- 2 Immich -->
<div class="node">
<div class="node-top">
<div class="node-ico">📷</div>
<div>
<div class="node-name">Immich</div>
<div class="node-cat">Photos</div>
</div>
<div class="led ok"></div>
</div>
<div class="node-foot">
<svg class="spark" viewBox="0 0 96 22" preserveAspectRatio="none">
<polyline
fill="none"
stroke="#36e0a4"
stroke-width="1.5"
points="0,12 12,13 24,11 36,12 48,10 60,11 72,9 84,11 96,10"
/></svg
><span class="up"><b>100%</b> 24h</span>
</div>
</div>
<!-- 3 Gitea -->
<div class="node">
<div class="node-top">
<div class="node-ico">🌿</div>
<div>
<div class="node-name">Gitea</div>
<div class="node-cat">Git</div>
</div>
<div class="led ok"></div>
</div>
<div class="node-foot">
<svg class="spark" viewBox="0 0 96 22" preserveAspectRatio="none">
<polyline
fill="none"
stroke="#36e0a4"
stroke-width="1.5"
points="0,14 12,10 24,12 36,8 48,11 60,9 72,13 84,8 96,9"
/></svg
><span class="up"><b>99.8%</b> 24h</span>
</div>
</div>
<!-- 4 Portainer -->
<div class="node">
<div class="node-top">
<div class="node-ico">🐳</div>
<div>
<div class="node-name">Portainer</div>
<div class="node-cat">Containers</div>
</div>
<div class="led warn"></div>
</div>
<div class="node-foot">
<svg class="spark" viewBox="0 0 96 22" preserveAspectRatio="none">
<polyline
fill="none"
stroke="#ffb020"
stroke-width="1.5"
points="0,10 12,12 24,9 36,15 48,11 60,18 72,12 84,16 96,13"
/></svg
><span class="up"><b style="color: var(--warn)">98.1%</b> 24h</span>
</div>
</div>
<!-- 5 Pi-hole -->
<div class="node">
<div class="node-top">
<div class="node-ico">🛡️</div>
<div>
<div class="node-name">Pi-hole</div>
<div class="node-cat">DNS</div>
</div>
<div class="led ok"></div>
</div>
<div class="node-foot">
<svg class="spark" viewBox="0 0 96 22" preserveAspectRatio="none">
<polyline
fill="none"
stroke="#36e0a4"
stroke-width="1.5"
points="0,13 12,11 24,12 36,10 48,11 60,9 72,10 84,8 96,9"
/></svg
><span class="up"><b>100%</b> 24h</span>
</div>
</div>
<!-- 6 Planka -->
<div class="node">
<div class="node-top">
<div class="node-ico">📋</div>
<div>
<div class="node-name">Planka</div>
<div class="node-cat">Kanban</div>
</div>
<div class="led ok"></div>
</div>
<div class="node-foot">
<svg class="spark" viewBox="0 0 96 22" preserveAspectRatio="none">
<polyline
fill="none"
stroke="#36e0a4"
stroke-width="1.5"
points="0,15 12,12 24,14 36,11 48,12 60,10 72,12 84,9 96,11"
/></svg
><span class="up"><b>99.5%</b> 24h</span>
</div>
</div>
<!-- 7 Deluge -->
<div class="node">
<div class="node-top">
<div class="node-ico">⬇️</div>
<div>
<div class="node-name">Deluge</div>
<div class="node-cat">Downloads</div>
</div>
<div class="led bad"></div>
</div>
<div class="node-foot">
<svg class="spark" viewBox="0 0 96 22" preserveAspectRatio="none">
<polyline
fill="none"
stroke="#ff4d5e"
stroke-width="1.5"
points="0,9 12,11 24,14 36,12 48,18 60,16 72,20 84,19 96,21"
/></svg
><span class="up bad"><b>OFFLINE</b></span>
</div>
</div>
<!-- 8 Pi-hole / NPM -->
<div class="node">
<div class="node-top">
<div class="node-ico">🔀</div>
<div>
<div class="node-name">Nginx Proxy Mgr</div>
<div class="node-cat">Network</div>
</div>
<div class="led ok"></div>
</div>
<div class="node-foot">
<svg class="spark" viewBox="0 0 96 22" preserveAspectRatio="none">
<polyline
fill="none"
stroke="#36e0a4"
stroke-width="1.5"
points="0,12 12,11 24,12 36,10 48,11 60,11 72,9 84,10 96,9"
/></svg
><span class="up"><b>99.9%</b> 24h</span>
</div>
</div>
</div>
<div class="footer-note">
// COMMAND DECK — Saira + JetBrains Mono · tactical dark · LED telemetry · monospace
data
</div>
</div>
</div>
</div>
</body>
</html>
+915
View File
@@ -0,0 +1,915 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Web App Launcher — Aurora Glass</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=Outfit:wght@300;400;500;600;700&family=Manrope:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<style>
:root {
--bg: #0a0a14;
--ink: #f3f2fb;
--ink-dim: #a7a6c4;
--ink-faint: #6f6e90;
--accent-h: 265; /* user-tunable hue → this is the killer feature */
--accent: hsl(var(--accent-h) 90% 66%);
--accent-2: hsl(calc(var(--accent-h) + 60) 85% 64%);
--accent-soft: hsl(var(--accent-h) 90% 66% / 0.14);
--glass: rgba(255, 255, 255, 0.05);
--glass-2: rgba(255, 255, 255, 0.07);
--glass-line: rgba(255, 255, 255, 0.1);
--ok: #34e0a1;
--warn: #ffc24b;
--bad: #ff5d73;
--radius: 18px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
height: 100%;
}
body {
background: var(--bg);
color: var(--ink);
font-family: 'Manrope', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
position: relative;
}
/* aurora mesh */
body::before {
content: '';
position: fixed;
inset: -20%;
z-index: 0;
pointer-events: none;
filter: blur(60px);
opacity: 0.9;
background:
radial-gradient(40% 40% at 18% 22%, hsl(var(--accent-h) 90% 60% / 0.55), transparent 70%),
radial-gradient(
38% 38% at 82% 18%,
hsl(calc(var(--accent-h) + 70) 90% 60% / 0.42),
transparent 70%
),
radial-gradient(
45% 45% at 70% 85%,
hsl(calc(var(--accent-h) - 40) 90% 58% / 0.4),
transparent 72%
),
radial-gradient(
40% 40% at 25% 90%,
hsl(calc(var(--accent-h) + 120) 80% 55% / 0.3),
transparent 72%
);
animation: drift 22s ease-in-out infinite alternate;
}
@keyframes drift {
0% {
transform: translate(0, 0) scale(1);
}
100% {
transform: translate(-3%, 2%) scale(1.08);
}
}
body::after {
content: '';
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background: radial-gradient(
120% 120% at 50% -10%,
transparent 40%,
rgba(10, 10, 20, 0.6) 100%
);
}
.shell {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 248px 1fr;
min-height: 100vh;
}
/* sidebar (glass) */
.side {
margin: 16px 0 16px 16px;
border-radius: 24px;
padding: 20px;
background: var(--glass);
border: 1px solid var(--glass-line);
backdrop-filter: blur(26px) saturate(160%);
-webkit-backdrop-filter: blur(26px) saturate(160%);
display: flex;
flex-direction: column;
}
.brand {
display: flex;
align-items: center;
gap: 11px;
margin-bottom: 26px;
padding: 4px 6px;
}
.brand .mark {
width: 38px;
height: 38px;
border-radius: 12px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
display: grid;
place-items: center;
color: #fff;
box-shadow: 0 8px 24px -6px var(--accent);
}
.brand .mark svg {
width: 20px;
height: 20px;
}
.brand .name {
font-family: 'Outfit';
font-weight: 600;
font-size: 17px;
letter-spacing: -0.01em;
}
.brand .name span {
display: block;
font-family: 'Manrope';
font-weight: 500;
font-size: 11px;
color: var(--ink-faint);
letter-spacing: 0.04em;
}
.nav-grp {
font-family: 'Outfit';
text-transform: uppercase;
letter-spacing: 0.13em;
font-size: 10px;
color: var(--ink-faint);
margin: 14px 8px 8px;
}
.nav-i {
display: flex;
align-items: center;
gap: 12px;
padding: 11px 12px;
border-radius: 12px;
color: var(--ink-dim);
font-weight: 500;
font-size: 14.5px;
cursor: pointer;
transition: 0.18s;
position: relative;
}
.nav-i svg {
width: 19px;
height: 19px;
opacity: 0.85;
}
.nav-i:hover {
color: var(--ink);
background: var(--glass-2);
}
.nav-i.on {
color: var(--ink);
background: var(--accent-soft);
box-shadow: inset 0 0 0 1px hsl(var(--accent-h) 90% 66% / 0.35);
}
.nav-i.on::before {
content: '';
position: absolute;
left: -20px;
top: 9px;
bottom: 9px;
width: 3px;
border-radius: 3px;
background: var(--accent);
}
.nav-i .dot {
margin-left: auto;
font-size: 11px;
color: var(--ink-faint);
font-weight: 600;
}
.side-foot {
margin-top: auto;
display: flex;
align-items: center;
gap: 11px;
padding: 10px;
border-radius: 14px;
background: var(--glass);
border: 1px solid var(--glass-line);
}
.av {
width: 34px;
height: 34px;
border-radius: 10px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
display: grid;
place-items: center;
font-weight: 700;
color: #fff;
font-size: 13px;
}
.side-foot .who {
font-size: 13px;
font-weight: 600;
line-height: 1.1;
}
.side-foot .who span {
display: block;
font-size: 11px;
color: var(--ink-faint);
font-weight: 500;
}
/* main */
.main {
padding: 30px 34px;
min-width: 0;
}
.head {
display: flex;
align-items: flex-end;
gap: 20px;
margin-bottom: 26px;
}
.hello {
font-family: 'Outfit';
font-weight: 600;
font-size: 30px;
letter-spacing: -0.02em;
line-height: 1.05;
}
.hello em {
font-style: normal;
background: linear-gradient(120deg, var(--accent), var(--accent-2));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.sub {
color: var(--ink-dim);
font-size: 14px;
margin-top: 6px;
}
.searchwrap {
margin-left: auto;
display: flex;
align-items: center;
gap: 10px;
}
.search {
display: flex;
align-items: center;
gap: 10px;
width: 300px;
background: var(--glass);
border: 1px solid var(--glass-line);
backdrop-filter: blur(20px);
border-radius: 14px;
padding: 11px 14px;
color: var(--ink-faint);
font-size: 13.5px;
cursor: text;
}
.search svg {
width: 16px;
height: 16px;
}
.search .k {
margin-left: auto;
font-size: 11px;
background: var(--glass-2);
border-radius: 6px;
padding: 2px 7px;
}
.gbtn {
width: 42px;
height: 42px;
border-radius: 14px;
background: var(--glass);
border: 1px solid var(--glass-line);
backdrop-filter: blur(20px);
display: grid;
place-items: center;
color: var(--ink-dim);
cursor: pointer;
transition: 0.18s;
}
.gbtn:hover {
color: var(--ink);
background: var(--glass-2);
}
.gbtn svg {
width: 18px;
height: 18px;
}
/* metric row */
.metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 28px;
}
.metric {
background: var(--glass);
border: 1px solid var(--glass-line);
backdrop-filter: blur(22px) saturate(150%);
border-radius: var(--radius);
padding: 18px 20px;
position: relative;
overflow: hidden;
}
.metric .ic {
width: 34px;
height: 34px;
border-radius: 11px;
display: grid;
place-items: center;
background: var(--accent-soft);
color: var(--accent);
margin-bottom: 14px;
}
.metric .ic svg {
width: 18px;
height: 18px;
}
.metric .v {
font-family: 'Outfit';
font-size: 27px;
font-weight: 600;
letter-spacing: -0.02em;
}
.metric .l {
color: var(--ink-dim);
font-size: 13px;
margin-top: 2px;
}
.metric .trend {
position: absolute;
top: 18px;
right: 18px;
font-size: 12px;
font-weight: 600;
color: var(--ok);
background: rgba(52, 224, 161, 0.12);
padding: 3px 9px;
border-radius: 20px;
}
.metric .trend.dn {
color: var(--bad);
background: rgba(255, 93, 115, 0.12);
}
.sectitle {
display: flex;
align-items: center;
justify-content: space-between;
margin: 8px 0 16px;
}
.sectitle h2 {
font-family: 'Outfit';
font-weight: 600;
font-size: 18px;
letter-spacing: -0.01em;
}
.sectitle a {
font-size: 13px;
color: var(--accent);
font-weight: 600;
text-decoration: none;
}
.apps {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(225px, 1fr));
gap: 16px;
}
.card {
background: var(--glass);
border: 1px solid var(--glass-line);
backdrop-filter: blur(22px) saturate(150%);
-webkit-backdrop-filter: blur(22px) saturate(150%);
border-radius: var(--radius);
padding: 18px;
cursor: pointer;
position: relative;
overflow: hidden;
transition:
transform 0.22s cubic-bezier(0.2, 0.7, 0.2, 1),
box-shadow 0.22s,
border-color 0.22s;
}
.card::after {
content: '';
position: absolute;
inset: 0;
border-radius: var(--radius);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), transparent 40%);
pointer-events: none;
}
.card:hover {
transform: translateY(-5px);
border-color: hsl(var(--accent-h) 90% 66% / 0.5);
box-shadow: 0 24px 50px -20px hsl(var(--accent-h) 90% 50% / 0.55);
}
.card .row {
display: flex;
align-items: center;
gap: 13px;
}
.ico {
width: 46px;
height: 46px;
border-radius: 13px;
display: grid;
place-items: center;
font-size: 22px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--glass-line);
flex-shrink: 0;
}
.nm {
font-family: 'Outfit';
font-weight: 600;
font-size: 15.5px;
letter-spacing: -0.01em;
}
.ct {
font-size: 12px;
color: var(--ink-faint);
margin-top: 1px;
}
.pill {
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 600;
padding: 5px 10px;
border-radius: 20px;
}
.pill.ok {
color: var(--ok);
background: rgba(52, 224, 161, 0.13);
}
.pill.warn {
color: var(--warn);
background: rgba(255, 194, 75, 0.13);
}
.pill.bad {
color: var(--bad);
background: rgba(255, 93, 115, 0.13);
}
.pill .b {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 8px currentColor;
}
.meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
}
.meta .up {
font-size: 12.5px;
color: var(--ink-dim);
}
.meta .up b {
color: var(--ink);
font-weight: 600;
}
.spark {
height: 24px;
width: 84px;
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: none;
}
}
.metric,
.card {
animation: rise 0.55s both;
}
.metric:nth-child(2) {
animation-delay: 0.06s;
}
.metric:nth-child(3) {
animation-delay: 0.12s;
}
.metric:nth-child(4) {
animation-delay: 0.18s;
}
.apps .card:nth-child(1) {
animation-delay: 0.1s;
}
.apps .card:nth-child(2) {
animation-delay: 0.16s;
}
.apps .card:nth-child(3) {
animation-delay: 0.22s;
}
.apps .card:nth-child(4) {
animation-delay: 0.28s;
}
.apps .card:nth-child(5) {
animation-delay: 0.34s;
}
.apps .card:nth-child(6) {
animation-delay: 0.4s;
}
.apps .card:nth-child(7) {
animation-delay: 0.46s;
}
.apps .card:nth-child(8) {
animation-delay: 0.52s;
}
.swatches {
display: flex;
gap: 8px;
align-items: center;
margin-top: 30px;
justify-content: center;
color: var(--ink-faint);
font-size: 12px;
}
.sw {
width: 18px;
height: 18px;
border-radius: 6px;
cursor: pointer;
border: 1px solid var(--glass-line);
}
</style>
</head>
<body>
<div class="shell">
<!-- Sidebar -->
<aside class="side">
<div class="brand">
<div class="mark">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
</svg>
</div>
<div class="name">Launcher<span>home cloud</span></div>
</div>
<div class="nav-grp">Workspace</div>
<div class="nav-i on">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<rect x="3" y="3" width="18" height="18" rx="3" />
<path d="M3 9h18M9 21V9" />
</svg>
Overview
</div>
<div class="nav-i">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18" />
</svg>
All Apps <span class="dot">10</span>
</div>
<div class="nav-i">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
Status
</div>
<div class="nav-grp">Boards</div>
<div class="nav-i">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<rect x="3" y="3" width="18" height="18" rx="3" />
</svg>
Media Center
</div>
<div class="nav-i">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<rect x="3" y="3" width="18" height="18" rx="3" />
</svg>
Infrastructure
</div>
<div class="nav-i">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M12 5v14M5 12h14" />
</svg>
New board…
</div>
<div class="side-foot">
<div class="av">AD</div>
<div class="who">Alexei<span>Administrator</span></div>
</div>
</aside>
<!-- Main -->
<main class="main">
<div class="head">
<div>
<div class="hello">Good evening, <em>Alexei</em></div>
<div class="sub">All systems nominal — 8 of 10 services responding</div>
</div>
<div class="searchwrap">
<div class="search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
Search… <span class="k">⌘K</span>
</div>
<div class="gbtn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.7 21a2 2 0 0 1-3.4 0" />
</svg>
</div>
<div class="gbtn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="4" />
<path
d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"
/>
</svg>
</div>
</div>
</div>
<!-- metrics -->
<div class="metrics">
<div class="metric">
<div class="ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 6 9 17l-5-5" />
</svg>
</div>
<div class="v">8<span style="color: var(--ink-faint); font-size: 18px">/10</span></div>
<div class="l">Services online</div>
<span class="trend">+2</span>
</div>
<div class="metric">
<div class="ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</svg>
</div>
<div class="v">142<span style="color: var(--ink-faint); font-size: 16px">ms</span></div>
<div class="l">Avg response</div>
<span class="trend dn">+18ms</span>
</div>
<div class="metric">
<div class="ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12h4l3 8 4-16 3 8h4" />
</svg>
</div>
<div class="v">99.4%</div>
<div class="l">Uptime · 30d</div>
<span class="trend">+0.2</span>
</div>
<div class="metric">
<div class="ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 2 3 14h7l-1 8 10-12h-7z" />
</svg>
</div>
<div class="v">61%</div>
<div class="l">UPS load · 38m</div>
<span class="trend dn">batt</span>
</div>
</div>
<div class="sectitle">
<h2>Favorites</h2>
<a href="#">View all apps →</a>
</div>
<div class="apps">
<div class="card">
<div class="row">
<div class="ico">🎬</div>
<div>
<div class="nm">Jellyfin</div>
<div class="ct">Media</div>
</div>
<div class="pill ok"><span class="b"></span>Up</div>
</div>
<div class="meta">
<span class="up"><b>99.9%</b> uptime</span
><svg class="spark" viewBox="0 0 84 24" preserveAspectRatio="none">
<polyline
fill="none"
stroke="var(--accent)"
stroke-width="2"
points="0,18 11,15 22,17 33,9 44,12 55,7 66,11 76,5 84,8"
/>
</svg>
</div>
</div>
<div class="card">
<div class="row">
<div class="ico">📷</div>
<div>
<div class="nm">Immich</div>
<div class="ct">Photos</div>
</div>
<div class="pill ok"><span class="b"></span>Up</div>
</div>
<div class="meta">
<span class="up"><b>100%</b> uptime</span
><svg class="spark" viewBox="0 0 84 24" preserveAspectRatio="none">
<polyline
fill="none"
stroke="var(--accent)"
stroke-width="2"
points="0,13 11,14 22,12 33,13 44,11 55,12 66,10 76,12 84,11"
/>
</svg>
</div>
</div>
<div class="card">
<div class="row">
<div class="ico">🌿</div>
<div>
<div class="nm">Gitea</div>
<div class="ct">Git server</div>
</div>
<div class="pill ok"><span class="b"></span>Up</div>
</div>
<div class="meta">
<span class="up"><b>99.8%</b> uptime</span
><svg class="spark" viewBox="0 0 84 24" preserveAspectRatio="none">
<polyline
fill="none"
stroke="var(--accent)"
stroke-width="2"
points="0,15 11,10 22,13 33,8 44,12 55,9 66,14 76,8 84,10"
/>
</svg>
</div>
</div>
<div class="card">
<div class="row">
<div class="ico">🐳</div>
<div>
<div class="nm">Portainer</div>
<div class="ct">Containers</div>
</div>
<div class="pill warn"><span class="b"></span>Slow</div>
</div>
<div class="meta">
<span class="up"><b>98.1%</b> uptime</span
><svg class="spark" viewBox="0 0 84 24" preserveAspectRatio="none">
<polyline
fill="none"
stroke="var(--warn)"
stroke-width="2"
points="0,11 11,13 22,9 33,16 44,11 55,19 66,12 76,17 84,13"
/>
</svg>
</div>
</div>
<div class="card">
<div class="row">
<div class="ico">🛡️</div>
<div>
<div class="nm">Pi-hole</div>
<div class="ct">DNS · Ads</div>
</div>
<div class="pill ok"><span class="b"></span>Up</div>
</div>
<div class="meta">
<span class="up"><b>100%</b> uptime</span
><svg class="spark" viewBox="0 0 84 24" preserveAspectRatio="none">
<polyline
fill="none"
stroke="var(--accent)"
stroke-width="2"
points="0,14 11,12 22,13 33,11 44,12 55,10 66,11 76,9 84,10"
/>
</svg>
</div>
</div>
<div class="card">
<div class="row">
<div class="ico">📋</div>
<div>
<div class="nm">Planka</div>
<div class="ct">Kanban</div>
</div>
<div class="pill ok"><span class="b"></span>Up</div>
</div>
<div class="meta">
<span class="up"><b>99.5%</b> uptime</span
><svg class="spark" viewBox="0 0 84 24" preserveAspectRatio="none">
<polyline
fill="none"
stroke="var(--accent)"
stroke-width="2"
points="0,16 11,13 22,15 33,12 44,13 55,11 66,13 76,10 84,12"
/>
</svg>
</div>
</div>
<div class="card">
<div class="row">
<div class="ico">⬇️</div>
<div>
<div class="nm">Deluge</div>
<div class="ct">Downloads</div>
</div>
<div class="pill bad"><span class="b"></span>Down</div>
</div>
<div class="meta">
<span class="up" style="color: var(--bad)"
><b style="color: var(--bad)">offline</b> · 4m</span
><svg class="spark" viewBox="0 0 84 24" preserveAspectRatio="none">
<polyline
fill="none"
stroke="var(--bad)"
stroke-width="2"
points="0,10 11,12 22,15 33,13 44,19 55,17 66,21 76,20 84,22"
/>
</svg>
</div>
</div>
<div class="card">
<div class="row">
<div class="ico">🔀</div>
<div>
<div class="nm">Proxy Mgr</div>
<div class="ct">Network</div>
</div>
<div class="pill ok"><span class="b"></span>Up</div>
</div>
<div class="meta">
<span class="up"><b>99.9%</b> uptime</span
><svg class="spark" viewBox="0 0 84 24" preserveAspectRatio="none">
<polyline
fill="none"
stroke="var(--accent)"
stroke-width="2"
points="0,13 11,12 22,13 33,11 44,12 55,12 66,10 76,11 84,10"
/>
</svg>
</div>
</div>
</div>
<div class="swatches">
Accent (user-tunable):
<span
class="sw"
style="background: hsl(265 90% 66%)"
onclick="document.documentElement.style.setProperty('--accent-h', '265')"
></span>
<span
class="sw"
style="background: hsl(210 90% 60%)"
onclick="document.documentElement.style.setProperty('--accent-h', '210')"
></span>
<span
class="sw"
style="background: hsl(150 80% 55%)"
onclick="document.documentElement.style.setProperty('--accent-h', '150')"
></span>
<span
class="sw"
style="background: hsl(20 90% 62%)"
onclick="document.documentElement.style.setProperty('--accent-h', '20')"
></span>
<span
class="sw"
style="background: hsl(330 85% 65%)"
onclick="document.documentElement.style.setProperty('--accent-h', '330')"
></span>
— try clicking; the whole UI + aurora retints live
</div>
</main>
</div>
</body>
</html>
+643
View File
@@ -0,0 +1,643 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Web App Launcher — Editorial</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=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,600;12..96,700;12..96,800&family=Instrument+Serif:ital@0;1&family=Hanken+Grotesk:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<style>
:root {
--paper: #f4f1ea; /* warm paper */
--paper-2: #ece7db;
--card: #fbfaf6;
--ink: #191712;
--ink-2: #5a554a;
--ink-faint: #9b9484;
--line: #1a1712;
--line-soft: #d8d2c4;
--accent: #ff5436; /* vermilion */
--accent-ink: #cf3a1f;
--blue: #1f4ae0;
--ok: #1f8a4c;
--warn: #b8730a;
--bad: #cf2020;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
height: 100%;
}
body {
background: var(--paper);
color: var(--ink);
font-family: 'Hanken Grotesk', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
background-image: radial-gradient(rgba(0, 0, 0, 0.022) 1px, transparent 1px);
background-size: 5px 5px;
}
.wrap {
max-width: 1180px;
margin: 0 auto;
padding: 0 26px;
}
/* top bar */
.masthead {
display: flex;
align-items: center;
gap: 20px;
padding: 22px 0 18px;
border-bottom: 2.5px solid var(--line);
}
.logo {
display: flex;
align-items: center;
gap: 12px;
}
.logo .glyph {
width: 42px;
height: 42px;
background: var(--ink);
color: var(--paper);
display: grid;
place-items: center;
border-radius: 3px;
}
.logo .glyph svg {
width: 22px;
height: 22px;
}
.logo .tt {
font-family: 'Bricolage Grotesque';
font-weight: 800;
font-size: 23px;
letter-spacing: -0.03em;
line-height: 0.9;
}
.logo .tt small {
display: block;
font-family: 'Instrument Serif';
font-style: italic;
font-weight: 400;
font-size: 14px;
letter-spacing: 0;
color: var(--ink-2);
}
.nav {
display: flex;
gap: 4px;
margin-left: 18px;
}
.nav a {
font-weight: 600;
font-size: 14px;
color: var(--ink);
text-decoration: none;
padding: 8px 14px;
border-radius: 2px;
transition: 0.15s;
}
.nav a:hover {
background: var(--paper-2);
}
.nav a.on {
background: var(--ink);
color: var(--paper);
}
.tools {
margin-left: auto;
display: flex;
align-items: center;
gap: 10px;
}
.search {
display: flex;
align-items: center;
gap: 9px;
border: 2px solid var(--line);
border-radius: 2px;
padding: 9px 13px;
font-size: 13px;
color: var(--ink-2);
cursor: text;
background: var(--card);
}
.search svg {
width: 15px;
height: 15px;
}
.search .k {
margin-left: 8px;
font-family: 'Hanken Grotesk';
font-weight: 700;
font-size: 10px;
border: 1.5px solid var(--line-soft);
border-radius: 3px;
padding: 1px 6px;
}
.ib {
width: 40px;
height: 40px;
border: 2px solid var(--line);
border-radius: 2px;
display: grid;
place-items: center;
cursor: pointer;
background: var(--card);
transition: 0.15s;
}
.ib:hover {
background: var(--ink);
color: var(--paper);
}
.ib svg {
width: 17px;
height: 17px;
}
/* hero */
.hero {
display: grid;
grid-template-columns: 1.45fr 1fr;
gap: 0;
border-bottom: 2.5px solid var(--line);
}
.hero-l {
padding: 46px 40px 46px 0;
border-right: 2.5px solid var(--line);
}
.kicker {
font-family: 'Hanken Grotesk';
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.22em;
font-size: 12px;
color: var(--accent-ink);
}
.hero h1 {
font-family: 'Bricolage Grotesque';
font-weight: 800;
font-size: 62px;
line-height: 0.95;
letter-spacing: -0.035em;
margin: 14px 0 0;
}
.hero h1 em {
font-family: 'Instrument Serif';
font-style: italic;
font-weight: 400;
color: var(--accent);
}
.hero p {
font-size: 16px;
color: var(--ink-2);
max-width: 30ch;
margin-top: 18px;
line-height: 1.5;
}
.hero-cta {
display: flex;
gap: 10px;
margin-top: 24px;
}
.btn {
font-weight: 700;
font-size: 14px;
padding: 12px 20px;
border-radius: 2px;
border: 2px solid var(--line);
cursor: pointer;
text-decoration: none;
}
.btn.solid {
background: var(--ink);
color: var(--paper);
}
.btn.solid:hover {
background: var(--accent);
border-color: var(--accent);
}
.btn.ghost {
background: transparent;
color: var(--ink);
}
.btn.ghost:hover {
background: var(--paper-2);
}
.hero-r {
display: grid;
grid-template-rows: 1fr 1fr 1fr;
}
.figure {
padding: 20px 0 20px 36px;
border-bottom: 1.5px solid var(--line-soft);
display: flex;
align-items: baseline;
gap: 14px;
}
.figure:last-child {
border-bottom: 0;
}
.figure .num {
font-family: 'Bricolage Grotesque';
font-weight: 800;
font-size: 46px;
letter-spacing: -0.04em;
line-height: 0.85;
min-width: 120px;
}
.figure .num.acc {
color: var(--accent);
}
.figure .num.bl {
color: var(--blue);
}
.figure .desc {
font-size: 13px;
color: var(--ink-2);
line-height: 1.35;
}
.figure .desc b {
display: block;
font-family: 'Bricolage Grotesque';
font-weight: 700;
color: var(--ink);
font-size: 15px;
letter-spacing: -0.01em;
}
/* section label */
.slab {
display: flex;
align-items: center;
gap: 16px;
margin: 36px 0 20px;
}
.slab h2 {
font-family: 'Bricolage Grotesque';
font-weight: 700;
font-size: 22px;
letter-spacing: -0.02em;
}
.slab .ln {
flex: 1;
height: 2px;
background: var(--line);
}
.slab .meta {
font-family: 'Instrument Serif';
font-style: italic;
font-size: 16px;
color: var(--ink-2);
}
/* apps — asymmetric editorial grid */
.grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 14px;
}
.tile {
grid-column: span 3;
background: var(--card);
border: 2px solid var(--line);
border-radius: 3px;
padding: 18px;
cursor: pointer;
transition:
transform 0.15s,
box-shadow 0.15s;
position: relative;
box-shadow: 4px 4px 0 var(--line);
}
.tile:hover {
transform: translate(-2px, -2px);
box-shadow: 7px 7px 0 var(--accent);
}
.tile.wide {
grid-column: span 6;
}
.tile.tall {
grid-column: span 3;
}
.t-top {
display: flex;
align-items: flex-start;
gap: 12px;
}
.t-ico {
width: 46px;
height: 46px;
border: 2px solid var(--line);
border-radius: 3px;
display: grid;
place-items: center;
font-size: 22px;
background: var(--paper);
flex-shrink: 0;
}
.t-name {
font-family: 'Bricolage Grotesque';
font-weight: 700;
font-size: 18px;
letter-spacing: -0.02em;
line-height: 1;
}
.t-cat {
font-family: 'Instrument Serif';
font-style: italic;
font-size: 14px;
color: var(--ink-2);
margin-top: 3px;
}
.tag {
margin-left: auto;
font-weight: 700;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 4px 8px;
border-radius: 2px;
border: 1.5px solid currentColor;
}
.tag.ok {
color: var(--ok);
}
.tag.warn {
color: var(--warn);
}
.tag.bad {
color: var(--bad);
background: var(--bad);
color: #fff;
border-color: var(--bad);
}
.t-foot {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 18px;
padding-top: 13px;
border-top: 1.5px solid var(--line-soft);
}
.t-foot .up {
font-weight: 700;
font-size: 13px;
}
.t-foot .up small {
font-weight: 500;
color: var(--ink-faint);
}
.spark {
height: 24px;
width: 90px;
}
.tile.wide .blurb {
font-size: 14px;
color: var(--ink-2);
line-height: 1.5;
margin-top: 14px;
max-width: 42ch;
}
@keyframes pop {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: none;
}
}
.tile,
.figure {
animation: pop 0.5s both;
}
.grid .tile:nth-child(1) {
animation-delay: 0.05s;
}
.grid .tile:nth-child(2) {
animation-delay: 0.11s;
}
.grid .tile:nth-child(3) {
animation-delay: 0.17s;
}
.grid .tile:nth-child(4) {
animation-delay: 0.23s;
}
.grid .tile:nth-child(5) {
animation-delay: 0.29s;
}
.grid .tile:nth-child(6) {
animation-delay: 0.35s;
}
.grid .tile:nth-child(7) {
animation-delay: 0.41s;
}
.colophon {
margin: 46px 0 30px;
padding-top: 18px;
border-top: 2.5px solid var(--line);
font-family: 'Instrument Serif';
font-style: italic;
font-size: 15px;
color: var(--ink-2);
display: flex;
justify-content: space-between;
}
</style>
</head>
<body>
<div class="wrap">
<!-- Masthead -->
<div class="masthead">
<div class="logo">
<div class="glyph">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
</div>
<div class="tt">LAUNCHER<small>the home cloud edition</small></div>
</div>
<nav class="nav"><a class="on">Overview</a><a>Apps</a><a>Boards</a><a>Status</a></nav>
<div class="tools">
<div class="search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
Search<span class="k">⌘K</span>
</div>
<div class="ib">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="4" />
<path
d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"
/>
</svg>
</div>
</div>
</div>
<!-- Hero -->
<section class="hero">
<div class="hero-l">
<div class="kicker">Tuesday · 27 May · 19:42</div>
<h1>Your stack,<br />all in <em>one place.</em></h1>
<p>
Ten services under one roof. Eight humming, two asking for attention. Everything
launches from here.
</p>
<div class="hero-cta">
<a class="btn solid">Open a board →</a><a class="btn ghost">Add an app</a>
</div>
</div>
<div class="hero-r">
<div class="figure">
<div class="num acc">8/10</div>
<div class="desc"><b>Services online</b>Deluge offline · Portainer slow to respond</div>
</div>
<div class="figure">
<div class="num bl">99.4%</div>
<div class="desc"><b>Fleet uptime</b>Rolling 30-day average across all monitors</div>
</div>
<div class="figure">
<div class="num">142<span style="font-size: 20px">ms</span></div>
<div class="desc"><b>Median response</b>p95 latency over the last 24 hours</div>
</div>
</div>
</section>
<!-- Apps -->
<div class="slab">
<h2>Favorites</h2>
<div class="ln"></div>
<div class="meta">eight pinned</div>
</div>
<div class="grid">
<div class="tile wide">
<div class="t-top">
<div class="t-ico">🎬</div>
<div>
<div class="t-name">Jellyfin</div>
<div class="t-cat">Media server · the crown jewel</div>
</div>
<span class="tag ok">Online</span>
</div>
<p class="blurb">
Streaming to 3 devices right now. Library scan completed 2 hours ago — 4,212 movies, 318
shows indexed and healthy.
</p>
<div class="t-foot">
<div class="up">99.9% <small>uptime · 24h</small></div>
<svg class="spark" viewBox="0 0 90 24" preserveAspectRatio="none">
<polyline
fill="none"
stroke="#ff5436"
stroke-width="2.2"
points="0,18 12,15 24,17 36,9 48,12 60,7 72,11 82,5 90,8"
/>
</svg>
</div>
</div>
<div class="tile">
<div class="t-top">
<div class="t-ico">📷</div>
<div>
<div class="t-name">Immich</div>
<div class="t-cat">Photos</div>
</div>
</div>
<div class="t-foot">
<div class="up">100% <small>24h</small></div>
<span class="tag ok">Up</span>
</div>
</div>
<div class="tile">
<div class="t-top">
<div class="t-ico">🌿</div>
<div>
<div class="t-name">Gitea</div>
<div class="t-cat">Git</div>
</div>
</div>
<div class="t-foot">
<div class="up">99.8% <small>24h</small></div>
<span class="tag ok">Up</span>
</div>
</div>
<div class="tile">
<div class="t-top">
<div class="t-ico">🐳</div>
<div>
<div class="t-name">Portainer</div>
<div class="t-cat">Containers</div>
</div>
</div>
<div class="t-foot">
<div class="up">98.1% <small>24h</small></div>
<span class="tag warn">Slow</span>
</div>
</div>
<div class="tile">
<div class="t-top">
<div class="t-ico">🛡️</div>
<div>
<div class="t-name">Pi-hole</div>
<div class="t-cat">DNS</div>
</div>
</div>
<div class="t-foot">
<div class="up">100% <small>24h</small></div>
<span class="tag ok">Up</span>
</div>
</div>
<div class="tile">
<div class="t-top">
<div class="t-ico">📋</div>
<div>
<div class="t-name">Planka</div>
<div class="t-cat">Kanban</div>
</div>
</div>
<div class="t-foot">
<div class="up">99.5% <small>24h</small></div>
<span class="tag ok">Up</span>
</div>
</div>
<div class="tile">
<div class="t-top">
<div class="t-ico">⬇️</div>
<div>
<div class="t-name">Deluge</div>
<div class="t-cat">Downloads</div>
</div>
</div>
<div class="t-foot">
<div class="up" style="color: var(--bad)"></div>
<span class="tag bad">Down</span>
</div>
</div>
</div>
<div class="colophon">
<span>Editorial — Bricolage Grotesque + Instrument Serif</span
><span>warm paper · ink rules · hard shadows · asymmetric grid</span>
</div>
</div>
</body>
</html>
+723
View File
@@ -0,0 +1,723 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Web App Launcher — Cozy Home</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=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=Figtree:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<style>
:root {
--bg: #fdf8f2; /* warm cream */
--bg-2: #f6efe4;
--card: #fffdfa;
--ink: #3a322b;
--ink-2: #857a6d;
--ink-faint: #b3a899;
--line: #ece2d3;
--peach: #ff9a76;
--terra: #e8754f;
--sage: #7fb069;
--sky: #6ca9d6;
--butter: #f3c969;
--lav: #b09fd6;
--ok: #5fa86c;
--warn: #d99a2b;
--bad: #e0685f;
--radius: 22px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
height: 100%;
}
body {
background: var(--bg);
color: var(--ink);
font-family: 'Figtree', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
body::before {
content: '';
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background:
radial-gradient(50% 40% at 12% 0%, rgba(255, 154, 118, 0.16), transparent 70%),
radial-gradient(45% 40% at 95% 8%, rgba(108, 169, 214, 0.14), transparent 70%),
radial-gradient(50% 45% at 85% 100%, rgba(127, 176, 105, 0.12), transparent 70%);
}
.shell {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 236px 1fr;
min-height: 100vh;
}
/* sidebar */
.side {
padding: 24px 18px;
display: flex;
flex-direction: column;
}
.brand {
display: flex;
align-items: center;
gap: 11px;
padding: 6px 8px;
margin-bottom: 24px;
}
.brand .m {
width: 40px;
height: 40px;
border-radius: 14px;
background: linear-gradient(135deg, var(--peach), var(--terra));
display: grid;
place-items: center;
color: #fff;
box-shadow: 0 10px 22px -8px var(--terra);
}
.brand .m svg {
width: 21px;
height: 21px;
}
.brand .t {
font-family: 'Fraunces';
font-weight: 600;
font-size: 19px;
letter-spacing: -0.01em;
}
.brand .t span {
display: block;
font-family: 'Figtree';
font-weight: 500;
font-size: 11px;
color: var(--ink-faint);
}
.nlabel {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 10.5px;
color: var(--ink-faint);
margin: 16px 10px 8px;
}
.ni {
display: flex;
align-items: center;
gap: 12px;
padding: 11px 13px;
border-radius: 14px;
color: var(--ink-2);
font-weight: 600;
font-size: 14.5px;
cursor: pointer;
transition: 0.16s;
}
.ni svg {
width: 19px;
height: 19px;
}
.ni:hover {
background: var(--bg-2);
color: var(--ink);
}
.ni.on {
background: var(--card);
color: var(--terra);
box-shadow:
0 6px 16px -8px rgba(0, 0, 0, 0.18),
inset 0 0 0 1px var(--line);
}
.ni .c {
margin-left: auto;
font-size: 11px;
background: var(--bg-2);
color: var(--ink-2);
padding: 2px 8px;
border-radius: 10px;
}
.side-card {
margin-top: auto;
background: linear-gradient(135deg, rgba(127, 176, 105, 0.16), rgba(108, 169, 214, 0.14));
border-radius: 18px;
padding: 16px;
border: 1px solid var(--line);
}
.side-card p {
font-size: 12.5px;
color: var(--ink-2);
line-height: 1.4;
}
.side-card .who {
display: flex;
align-items: center;
gap: 10px;
margin-top: 12px;
}
.side-card .av {
width: 32px;
height: 32px;
border-radius: 11px;
background: linear-gradient(135deg, var(--lav), var(--sky));
color: #fff;
display: grid;
place-items: center;
font-weight: 700;
font-size: 13px;
}
/* main */
.main {
padding: 30px 36px 40px;
min-width: 0;
}
.top {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 8px;
}
.top .search {
display: flex;
align-items: center;
gap: 10px;
background: var(--card);
border: 1px solid var(--line);
border-radius: 16px;
padding: 12px 16px;
color: var(--ink-faint);
font-size: 14px;
width: 320px;
cursor: text;
box-shadow: 0 4px 14px -10px rgba(0, 0, 0, 0.2);
}
.top .search svg {
width: 16px;
height: 16px;
}
.top .search .k {
margin-left: auto;
font-size: 11px;
font-weight: 700;
background: var(--bg-2);
padding: 2px 8px;
border-radius: 8px;
}
.rbtn {
margin-left: auto;
width: 44px;
height: 44px;
border-radius: 15px;
background: var(--card);
border: 1px solid var(--line);
display: grid;
place-items: center;
color: var(--ink-2);
cursor: pointer;
box-shadow: 0 4px 14px -10px rgba(0, 0, 0, 0.2);
}
.rbtn + .rbtn {
margin-left: 0;
}
.rbtn:hover {
color: var(--terra);
}
.rbtn svg {
width: 19px;
height: 19px;
}
.greet {
font-family: 'Fraunces';
font-weight: 600;
font-size: 34px;
letter-spacing: -0.02em;
margin: 18px 0 4px;
}
.greet .wave {
display: inline-block;
animation: wave 2.4s ease-in-out infinite;
transform-origin: 70% 70%;
}
@keyframes wave {
0%,
60%,
100% {
transform: rotate(0);
}
10% {
transform: rotate(16deg);
}
20% {
transform: rotate(-8deg);
}
30% {
transform: rotate(14deg);
}
40% {
transform: rotate(-4deg);
}
}
.gsub {
color: var(--ink-2);
font-size: 15px;
margin-bottom: 26px;
}
.gsub b {
color: var(--sage);
font-weight: 700;
}
/* summary chips */
.chips {
display: flex;
gap: 12px;
margin-bottom: 30px;
flex-wrap: wrap;
}
.chip {
display: flex;
align-items: center;
gap: 12px;
background: var(--card);
border: 1px solid var(--line);
border-radius: 18px;
padding: 14px 18px;
box-shadow: 0 8px 20px -16px rgba(0, 0, 0, 0.3);
}
.chip .ic {
width: 38px;
height: 38px;
border-radius: 12px;
display: grid;
place-items: center;
}
.chip .ic svg {
width: 19px;
height: 19px;
color: #fff;
}
.chip .v {
font-family: 'Fraunces';
font-weight: 600;
font-size: 21px;
line-height: 1;
}
.chip .l {
font-size: 12.5px;
color: var(--ink-2);
margin-top: 2px;
}
.sec {
display: flex;
align-items: baseline;
gap: 12px;
margin: 6px 0 18px;
}
.sec h2 {
font-family: 'Fraunces';
font-weight: 600;
font-size: 22px;
letter-spacing: -0.01em;
}
.sec .more {
margin-left: auto;
font-size: 13.5px;
color: var(--terra);
font-weight: 600;
text-decoration: none;
}
.apps {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 18px;
}
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 20px;
cursor: pointer;
transition:
transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1),
box-shadow 0.2s;
position: relative;
overflow: hidden;
box-shadow: 0 10px 26px -20px rgba(0, 0, 0, 0.4);
}
.card:hover {
transform: translateY(-6px) rotate(-0.4deg);
box-shadow: 0 22px 40px -22px rgba(0, 0, 0, 0.4);
}
.blob {
position: absolute;
width: 120px;
height: 120px;
border-radius: 50%;
filter: blur(30px);
opacity: 0.5;
top: -50px;
right: -40px;
}
.ico {
width: 54px;
height: 54px;
border-radius: 18px;
display: grid;
place-items: center;
font-size: 26px;
position: relative;
}
.nm {
font-family: 'Fraunces';
font-weight: 600;
font-size: 17px;
margin-top: 16px;
letter-spacing: -0.01em;
}
.ct {
font-size: 13px;
color: var(--ink-2);
margin-top: 1px;
}
.foot {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 18px;
}
.dot {
display: flex;
align-items: center;
gap: 7px;
font-size: 12.5px;
font-weight: 600;
}
.dot .b {
width: 9px;
height: 9px;
border-radius: 50%;
}
.dot.ok {
color: var(--ok);
}
.dot.ok .b {
background: var(--ok);
box-shadow: 0 0 0 4px rgba(95, 168, 108, 0.18);
}
.dot.warn {
color: var(--warn);
}
.dot.warn .b {
background: var(--warn);
box-shadow: 0 0 0 4px rgba(217, 154, 43, 0.18);
}
.dot.bad {
color: var(--bad);
}
.dot.bad .b {
background: var(--bad);
box-shadow: 0 0 0 4px rgba(224, 104, 95, 0.18);
}
.up {
font-size: 12px;
color: var(--ink-faint);
font-weight: 600;
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: none;
}
}
.card,
.chip {
animation: rise 0.55s both;
}
.chip:nth-child(2) {
animation-delay: 0.06s;
}
.chip:nth-child(3) {
animation-delay: 0.12s;
}
.chip:nth-child(4) {
animation-delay: 0.18s;
}
.apps .card:nth-child(1) {
animation-delay: 0.1s;
}
.apps .card:nth-child(2) {
animation-delay: 0.16s;
}
.apps .card:nth-child(3) {
animation-delay: 0.22s;
}
.apps .card:nth-child(4) {
animation-delay: 0.28s;
}
.apps .card:nth-child(5) {
animation-delay: 0.34s;
}
.apps .card:nth-child(6) {
animation-delay: 0.4s;
}
.apps .card:nth-child(7) {
animation-delay: 0.46s;
}
.apps .card:nth-child(8) {
animation-delay: 0.52s;
}
.note {
text-align: center;
color: var(--ink-faint);
font-family: 'Fraunces';
font-style: italic;
font-size: 14px;
margin-top: 36px;
}
</style>
</head>
<body>
<div class="shell">
<!-- Sidebar -->
<aside class="side">
<div class="brand">
<div class="m">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7" rx="2" />
<rect x="14" y="3" width="7" height="7" rx="2" />
<rect x="14" y="14" width="7" height="7" rx="2" />
<rect x="3" y="14" width="7" height="7" rx="2" />
</svg>
</div>
<div class="t">Launcher<span>our home cloud</span></div>
</div>
<div class="nlabel">Menu</div>
<div class="ni on">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<path d="M3 11l9-8 9 8M5 10v10h14V10" />
</svg>
Home
</div>
<div class="ni">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18" />
</svg>
All apps <span class="c">10</span>
</div>
<div class="ni">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<path d="M20 6 9 17l-5-5" />
</svg>
Status
</div>
<div class="nlabel">Rooms</div>
<div class="ni">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<rect x="3" y="3" width="18" height="18" rx="4" />
</svg>
Movie night
</div>
<div class="ni">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<rect x="3" y="3" width="18" height="18" rx="4" />
</svg>
The basement rack
</div>
<div class="side-card">
<p>“Everythings running smoothly today. ☕ Two apps want a peek when you get a sec.”</p>
<div class="who">
<div class="av">AD</div>
<div>
<div style="font-weight: 700; font-size: 13px">Alexei</div>
<div style="font-size: 11px; color: var(--ink-faint)">Admin</div>
</div>
</div>
</div>
</aside>
<!-- Main -->
<main class="main">
<div class="top">
<div class="search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
Search your apps… <span class="k">⌘K</span>
</div>
<div class="rbtn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.7 21a2 2 0 0 1-3.4 0" />
</svg>
</div>
<div class="rbtn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<circle cx="12" cy="12" r="4" />
<path
d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"
/>
</svg>
</div>
</div>
<h1 class="greet">Hi Alexei <span class="wave">👋</span></h1>
<p class="gsub">Its a calm evening — <b>8 of your 10 apps</b> are happy and online.</p>
<div class="chips">
<div class="chip">
<div class="ic" style="background: var(--sage)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<path d="M20 6 9 17l-5-5" />
</svg>
</div>
<div>
<div class="v">8/10</div>
<div class="l">Apps online</div>
</div>
</div>
<div class="chip">
<div class="ic" style="background: var(--sky)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</svg>
</div>
<div>
<div class="v">142ms</div>
<div class="l">Avg speed</div>
</div>
</div>
<div class="chip">
<div class="ic" style="background: var(--butter)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<path d="M3 12h4l3 8 4-16 3 8h4" />
</svg>
</div>
<div>
<div class="v">99.4%</div>
<div class="l">Uptime · 30d</div>
</div>
</div>
<div class="chip">
<div class="ic" style="background: var(--lav)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<path d="M13 2 3 14h7l-1 8 10-12h-7z" />
</svg>
</div>
<div>
<div class="v">38 min</div>
<div class="l">Battery left</div>
</div>
</div>
</div>
<div class="sec">
<h2>Your favorites</h2>
<a class="more" href="#">See all →</a>
</div>
<div class="apps">
<div class="card">
<div class="blob" style="background: var(--terra)"></div>
<div class="ico" style="background: rgba(232, 117, 79, 0.16)">🎬</div>
<div class="nm">Jellyfin</div>
<div class="ct">Movies &amp; shows</div>
<div class="foot">
<span class="dot ok"><span class="b"></span>Online</span><span class="up">99.9%</span>
</div>
</div>
<div class="card">
<div class="blob" style="background: var(--sky)"></div>
<div class="ico" style="background: rgba(108, 169, 214, 0.18)">📷</div>
<div class="nm">Immich</div>
<div class="ct">Photos</div>
<div class="foot">
<span class="dot ok"><span class="b"></span>Online</span><span class="up">100%</span>
</div>
</div>
<div class="card">
<div class="blob" style="background: var(--sage)"></div>
<div class="ico" style="background: rgba(127, 176, 105, 0.18)">🌿</div>
<div class="nm">Gitea</div>
<div class="ct">Code</div>
<div class="foot">
<span class="dot ok"><span class="b"></span>Online</span><span class="up">99.8%</span>
</div>
</div>
<div class="card">
<div class="blob" style="background: var(--butter)"></div>
<div class="ico" style="background: rgba(243, 201, 105, 0.22)">🐳</div>
<div class="nm">Portainer</div>
<div class="ct">Containers</div>
<div class="foot">
<span class="dot warn"><span class="b"></span>A bit slow</span
><span class="up">98.1%</span>
</div>
</div>
<div class="card">
<div class="blob" style="background: var(--lav)"></div>
<div class="ico" style="background: rgba(176, 159, 214, 0.2)">🛡️</div>
<div class="nm">Pi-hole</div>
<div class="ct">Ad blocker</div>
<div class="foot">
<span class="dot ok"><span class="b"></span>Online</span><span class="up">100%</span>
</div>
</div>
<div class="card">
<div class="blob" style="background: var(--peach)"></div>
<div class="ico" style="background: rgba(255, 154, 118, 0.2)">📋</div>
<div class="nm">Planka</div>
<div class="ct">To-dos</div>
<div class="foot">
<span class="dot ok"><span class="b"></span>Online</span><span class="up">99.5%</span>
</div>
</div>
<div class="card">
<div class="blob" style="background: var(--bad)"></div>
<div class="ico" style="background: rgba(224, 104, 95, 0.16)">⬇️</div>
<div class="nm">Deluge</div>
<div class="ct">Downloads</div>
<div class="foot">
<span class="dot bad"><span class="b"></span>Asleep</span><span class="up"></span>
</div>
</div>
<div class="card">
<div class="blob" style="background: var(--sky)"></div>
<div class="ico" style="background: rgba(108, 169, 214, 0.18)">🔀</div>
<div class="nm">Proxy</div>
<div class="ct">Networking</div>
<div class="foot">
<span class="dot ok"><span class="b"></span>Online</span><span class="up">99.9%</span>
</div>
</div>
</div>
<p class="note">
Cozy Home — Fraunces + Figtree · warm cream · soft pastel rooms · gentle motion
</p>
</main>
</div>
</body>
</html>
+639
View File
@@ -0,0 +1,639 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Cozy Home — Design System Reference</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=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=Figtree:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<style>
/* === The exact tokens now living in src/app.css (light/cream) === */
:root {
--background: hsl(35 56% 97%);
--foreground: hsl(33 18% 18%);
--muted: hsl(36 42% 93%);
--muted-foreground: hsl(34 12% 47%);
--card: hsl(40 60% 99%);
--border: hsl(36 35% 88%);
--primary: hsl(16 72% 56%);
--primary-foreground: hsl(40 60% 99%);
--accent: hsl(34 44% 90%);
--status-online: #5fa86c;
--status-offline: #e0685f;
--status-degraded: #d99a2b;
--status-unknown: #b3a899;
--room-sage: #7fb069;
--room-sky: #6ca9d6;
--room-butter: #f3c969;
--room-lav: #b09fd6;
--room-peach: #ff9a76;
--room-terra: #e8754f;
--radius: 1rem;
--shadow-soft: 0 10px 26px -20px rgba(80, 50, 20, 0.45);
--shadow-lift: 0 22px 40px -22px rgba(80, 50, 20, 0.4);
--font-sans: 'Figtree', system-ui, sans-serif;
--font-display: 'Fraunces', serif;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
padding: 40px;
line-height: 1.5;
}
h1,
h2,
h3 {
font-family: var(--font-display);
letter-spacing: -0.01em;
}
.wrap {
max-width: 1080px;
margin: 0 auto;
}
.title {
font-size: 34px;
font-weight: 600;
}
.lede {
color: var(--muted-foreground);
margin: 6px 0 8px;
max-width: 64ch;
}
.path {
font-family: ui-monospace, monospace;
font-size: 12px;
color: var(--room-terra);
background: color-mix(in srgb, var(--room-terra) 12%, transparent);
padding: 3px 9px;
border-radius: 8px;
display: inline-block;
}
section {
margin-top: 42px;
}
.h {
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted-foreground);
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.row {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: center;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 14px;
}
/* tokens */
.swatch {
border-radius: var(--radius);
border: 1px solid var(--border);
overflow: hidden;
background: var(--card);
box-shadow: var(--shadow-soft);
}
.swatch .c {
height: 56px;
}
.swatch .n {
padding: 9px 12px;
font-size: 12px;
}
.swatch .n b {
display: block;
font-weight: 600;
}
.swatch .n span {
color: var(--muted-foreground);
font-family: ui-monospace, monospace;
font-size: 10.5px;
}
/* buttons */
.btn {
font-family: var(--font-sans);
font-weight: 600;
font-size: 14px;
padding: 11px 18px;
border-radius: 14px;
border: 1px solid transparent;
cursor: pointer;
transition: 0.18s;
}
.btn-primary {
background: var(--primary);
color: var(--primary-foreground);
box-shadow: var(--shadow-soft);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lift);
}
.btn-secondary {
background: var(--card);
color: var(--foreground);
border-color: var(--border);
box-shadow: var(--shadow-soft);
}
.btn-secondary:hover {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--primary) 40%, transparent);
}
.btn-ghost {
background: transparent;
color: var(--foreground);
}
.btn-ghost:hover {
background: var(--accent);
}
.btn-icon {
width: 42px;
height: 42px;
border-radius: 14px;
display: grid;
place-items: center;
padding: 0;
}
/* inputs */
.field {
display: flex;
flex-direction: column;
gap: 7px;
max-width: 340px;
}
.field label {
font-size: 13px;
font-weight: 600;
}
.input {
font-family: var(--font-sans);
font-size: 14px;
padding: 11px 14px;
border-radius: 14px;
border: 1px solid var(--border);
background: var(--card);
color: var(--foreground);
box-shadow: var(--shadow-soft);
transition: 0.16s;
}
.input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--primary) 18%, transparent);
}
.hint {
font-size: 12px;
color: var(--muted-foreground);
}
/* badges / status pills */
.pill {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 12.5px;
font-weight: 600;
padding: 5px 11px;
border-radius: 999px;
}
.pill .b {
width: 8px;
height: 8px;
border-radius: 50%;
}
.pill.ok {
color: var(--status-online);
background: color-mix(in srgb, var(--status-online) 14%, transparent);
}
.pill.warn {
color: var(--status-degraded);
background: color-mix(in srgb, var(--status-degraded) 14%, transparent);
}
.pill.bad {
color: var(--status-offline);
background: color-mix(in srgb, var(--status-offline) 14%, transparent);
}
.pill .b {
background: currentColor;
}
.room-pill {
font-size: 11px;
font-weight: 700;
padding: 4px 10px;
border-radius: 999px;
}
/* tabs */
.tabs {
display: inline-flex;
gap: 4px;
background: var(--muted);
padding: 5px;
border-radius: 16px;
}
.tab {
font-weight: 600;
font-size: 13.5px;
padding: 8px 16px;
border-radius: 11px;
cursor: pointer;
color: var(--muted-foreground);
}
.tab.on {
background: var(--card);
color: var(--foreground);
box-shadow: var(--shadow-soft);
}
/* card */
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 1.4rem;
padding: 20px;
box-shadow: var(--shadow-soft);
max-width: 300px;
position: relative;
overflow: hidden;
}
.card .blob {
position: absolute;
width: 120px;
height: 120px;
border-radius: 50%;
filter: blur(30px);
opacity: 0.45;
top: -50px;
right: -40px;
}
.card .ic {
width: 52px;
height: 52px;
border-radius: 18px;
display: grid;
place-items: center;
font-size: 25px;
position: relative;
}
.card h3 {
font-size: 17px;
font-weight: 600;
margin-top: 14px;
}
.card .ct {
font-size: 13px;
color: var(--muted-foreground);
}
.card .f {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 16px;
}
/* dialog */
.dialog {
background: var(--card);
border: 1px solid var(--border);
border-radius: 1.4rem;
padding: 24px;
box-shadow: var(--shadow-lift);
max-width: 380px;
}
.dialog h3 {
font-size: 20px;
font-weight: 600;
}
.dialog p {
color: var(--muted-foreground);
font-size: 14px;
margin: 8px 0 20px;
}
/* table */
table {
width: 100%;
border-collapse: collapse;
background: var(--card);
border: 1px solid var(--border);
border-radius: 1.2rem;
overflow: hidden;
box-shadow: var(--shadow-soft);
}
th {
text-align: left;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted-foreground);
padding: 14px 18px;
border-bottom: 1px solid var(--border);
}
td {
padding: 14px 18px;
font-size: 14px;
border-bottom: 1px solid var(--border);
}
tr:last-child td {
border-bottom: 0;
}
tr:hover td {
background: var(--accent);
}
/* empty state */
.empty {
text-align: center;
border: 2px dashed var(--border);
border-radius: 1.4rem;
padding: 48px 24px;
background: color-mix(in srgb, var(--card) 50%, transparent);
}
.empty .e-ic {
width: 64px;
height: 64px;
border-radius: 22px;
display: grid;
place-items: center;
margin: 0 auto 16px;
background: color-mix(in srgb, var(--room-peach) 20%, transparent);
color: var(--room-terra);
}
.empty h3 {
font-size: 19px;
font-weight: 600;
}
.empty p {
color: var(--muted-foreground);
font-size: 14px;
margin: 6px auto 18px;
max-width: 36ch;
}
</style>
</head>
<body>
<div class="wrap">
<h1 class="title">Cozy Home — Design System</h1>
<p class="lede">
The component pattern sheet for the migration. Every phase styles its components to match
these primitives. Tokens here mirror what now lives in
<span class="path">src/app.css</span> — change them there and the whole app follows.
</p>
<section>
<div class="h">Color tokens</div>
<div class="grid">
<div class="swatch">
<div class="c" style="background: var(--background)"></div>
<div class="n"><b>background</b><span>cream #fdf8f2</span></div>
</div>
<div class="swatch">
<div class="c" style="background: var(--card)"></div>
<div class="n"><b>card</b><span>#fffdfa</span></div>
</div>
<div class="swatch">
<div class="c" style="background: var(--primary)"></div>
<div class="n"><b>primary</b><span>terracotta · tunable</span></div>
</div>
<div class="swatch">
<div class="c" style="background: var(--foreground)"></div>
<div class="n"><b>foreground</b><span>warm ink</span></div>
</div>
<div class="swatch">
<div class="c" style="background: var(--muted)"></div>
<div class="n"><b>muted</b><span>#f3ecde</span></div>
</div>
<div class="swatch">
<div class="c" style="background: var(--border)"></div>
<div class="n"><b>border</b><span>#ece2d3</span></div>
</div>
</div>
<div style="margin-top: 14px" class="grid">
<div class="swatch">
<div class="c" style="background: var(--room-terra)"></div>
<div class="n"><b>room · terra</b></div>
</div>
<div class="swatch">
<div class="c" style="background: var(--room-peach)"></div>
<div class="n"><b>room · peach</b></div>
</div>
<div class="swatch">
<div class="c" style="background: var(--room-butter)"></div>
<div class="n"><b>room · butter</b></div>
</div>
<div class="swatch">
<div class="c" style="background: var(--room-sage)"></div>
<div class="n"><b>room · sage</b></div>
</div>
<div class="swatch">
<div class="c" style="background: var(--room-sky)"></div>
<div class="n"><b>room · sky</b></div>
</div>
<div class="swatch">
<div class="c" style="background: var(--room-lav)"></div>
<div class="n"><b>room · lav</b></div>
</div>
</div>
</section>
<section>
<div class="h">Typography — Fraunces (display) · Figtree (body)</div>
<h1 style="font-size: 46px; font-weight: 600">Good evening, Alexei</h1>
<h2 style="font-size: 28px; font-weight: 600; margin-top: 10px">Your favorites</h2>
<h3 style="font-size: 19px; font-weight: 600; margin-top: 10px">Jellyfin</h3>
<p style="margin-top: 8px; max-width: 60ch">
Body copy is Figtree — friendly, legible, rounded. It carries descriptions, hints, and
plain-language status like “a bit slow” or “asleep”.
</p>
</section>
<section>
<div class="h">Buttons</div>
<div class="row">
<button class="btn btn-primary">Open a board</button>
<button class="btn btn-secondary">Add an app</button>
<button class="btn btn-ghost">Cancel</button>
<button class="btn btn-secondary btn-icon" title="icon">🔔</button>
</div>
</section>
<section>
<div class="h">Form fields</div>
<div class="row" style="align-items: flex-start">
<div class="field">
<label>App name</label><input class="input" value="Jellyfin" /><span class="hint"
>Shown on the card and in search.</span
>
</div>
<div class="field"><label>URL</label><input class="input" placeholder="https://…" /></div>
</div>
</section>
<section>
<div class="h">Status pills &amp; room tags</div>
<div class="row">
<span class="pill ok"><span class="b"></span>Online</span>
<span class="pill warn"><span class="b"></span>A bit slow</span>
<span class="pill bad"><span class="b"></span>Asleep</span>
<span
class="room-pill"
style="
color: var(--room-terra);
background: color-mix(in srgb, var(--room-terra) 16%, transparent);
"
>Media</span
>
<span
class="room-pill"
style="
color: var(--room-sky);
background: color-mix(in srgb, var(--room-sky) 16%, transparent);
"
>Network</span
>
<span
class="room-pill"
style="
color: var(--room-sage);
background: color-mix(in srgb, var(--room-sage) 16%, transparent);
"
>Git</span
>
</div>
</section>
<section>
<div class="h">Tabs</div>
<div class="tabs">
<div class="tab on">Overview</div>
<div class="tab">Activity</div>
<div class="tab">Settings</div>
</div>
</section>
<section>
<div class="h">App card</div>
<div class="card">
<div class="blob" style="background: var(--room-terra)"></div>
<div
class="ic"
style="
background: color-mix(in srgb, var(--room-terra) 18%, transparent);
color: var(--room-terra);
"
>
🎬
</div>
<h3>Jellyfin</h3>
<div class="ct">Movies &amp; shows</div>
<div class="f">
<span class="pill ok"><span class="b"></span>Online</span
><span class="hint">99.9%</span>
</div>
</div>
</section>
<section>
<div class="h">Dialog</div>
<div class="dialog">
<h3>Remove Deluge?</h3>
<p>This deletes the app and its uptime history. This cant be undone.</p>
<div class="row" style="justify-content: flex-end">
<button class="btn btn-ghost">Keep it</button
><button class="btn btn-primary" style="background: var(--status-offline)">
Remove
</button>
</div>
</div>
</section>
<section>
<div class="h">Table (admin)</div>
<table>
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Status</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
<tr>
<td>Alexei</td>
<td>Admin</td>
<td>
<span class="pill ok"><span class="b"></span>Active</span>
</td>
<td>just now</td>
</tr>
<tr>
<td>Guest</td>
<td>Viewer</td>
<td>
<span class="pill warn"><span class="b"></span>Idle</span>
</td>
<td>2h ago</td>
</tr>
</tbody>
</table>
</section>
<section>
<div class="h">Empty state</div>
<div class="empty">
<div class="e-ic">
<svg
width="30"
height="30"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
>
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18z" />
</svg>
</div>
<h3>No apps yet</h3>
<p>Add your first service and itll show up here with live status.</p>
<button class="btn btn-primary">+ Add an app</button>
</div>
</section>
<p
style="
margin: 48px 0 20px;
text-align: center;
font-family: var(--font-display);
font-style: italic;
color: var(--muted-foreground);
"
>
Cozy Home design system · mirrors src/app.css · use as the pattern for every migrated
component
</p>
</div>
</body>
</html>
+160
View File
@@ -0,0 +1,160 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Launcher — Redesign Mockups</title>
<link
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Figtree:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Figtree', system-ui, sans-serif;
background: #0e0e12;
color: #eee;
min-height: 100vh;
padding: 48px 24px;
}
.wrap {
max-width: 1000px;
margin: 0 auto;
}
h1 {
font-family: 'Fraunces';
font-weight: 600;
font-size: 34px;
letter-spacing: -0.02em;
}
p.sub {
color: #9a99a6;
margin-top: 8px;
font-size: 15px;
line-height: 1.5;
max-width: 62ch;
}
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 18px;
margin-top: 34px;
}
a.card {
display: block;
text-decoration: none;
color: inherit;
border: 1px solid #24242e;
border-radius: 16px;
padding: 24px;
background: #15151c;
transition: 0.2s;
}
a.card:hover {
transform: translateY(-4px);
border-color: #3a3a4a;
background: #191920;
}
.badge {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
padding: 4px 10px;
border-radius: 20px;
display: inline-block;
}
.nm {
font-family: 'Fraunces';
font-weight: 600;
font-size: 21px;
margin: 14px 0 6px;
}
.ds {
color: #9a99a6;
font-size: 13.5px;
line-height: 1.5;
}
.swatch {
display: flex;
gap: 6px;
margin-top: 14px;
}
.swatch span {
width: 22px;
height: 22px;
border-radius: 6px;
}
</style>
</head>
<body>
<div class="wrap">
<h1>Web App Launcher — Redesign Directions</h1>
<p class="sub">
Four aesthetic directions for the same launcher, built as theme presets of one modernized
design system. Open each, resize, hover the cards, and try the live accent swatches in
Aurora Glass. Pick the one that fits — or mix and match.
</p>
<div class="grid">
<a class="card" href="01-command-deck.html">
<span class="badge" style="background: #0d1f18; color: #36e0a4"
>01 · Dark · Power-user</span
>
<div class="nm">Command Deck</div>
<div class="ds">
Mission-control / terminal. Dense, glanceable telemetry, LED status, monospace data.
Saira + JetBrains Mono.
</div>
<div class="swatch">
<span style="background: #070a0d"></span><span style="background: #36e0a4"></span
><span style="background: #ffb020"></span><span style="background: #ff4d5e"></span>
</div>
</a>
<a class="card" href="02-aurora-glass.html">
<span class="badge" style="background: #1d1340; color: #b69cff">02 · Dark · Premium</span>
<div class="nm">Aurora Glass</div>
<div class="ds">
Frosted glass over a living gradient mesh. Soft glows, generous space, fully retintable
accent. Outfit + Manrope.
</div>
<div class="swatch">
<span style="background: #0a0a14"></span
><span style="background: hsl(265 90% 66%)"></span
><span style="background: hsl(325 85% 65%)"></span
><span style="background: #34e0a1"></span>
</div>
</a>
<a class="card" href="03-editorial.html">
<span class="badge" style="background: #2a1109; color: #ff5436">03 · Light · Bold</span>
<div class="nm">Editorial</div>
<div class="ds">
Magazine masthead, big display type, ink rules, hard shadows, asymmetric grid. Bricolage
Grotesque + Instrument Serif.
</div>
<div class="swatch">
<span style="background: #f4f1ea"></span><span style="background: #191712"></span
><span style="background: #ff5436"></span><span style="background: #1f4ae0"></span>
</div>
</a>
<a class="card" href="04-cozy-home.html">
<span class="badge" style="background: #2a1c12; color: #ff9a76"
>04 · Light · Friendly</span
>
<div class="nm">Cozy Home</div>
<div class="ds">
Warm cream, soft rounded cards, pastel “rooms”, gentle motion. Friendly for the whole
household. Fraunces + Figtree.
</div>
<div class="swatch">
<span style="background: #fdf8f2"></span><span style="background: #e8754f"></span
><span style="background: #7fb069"></span><span style="background: #6ca9d6"></span>
</div>
</a>
</div>
</div>
</body>
</html>
+124 -9
View File
@@ -1,18 +1,16 @@
{
"name": "web-app-launcher",
"version": "0.0.1",
"version": "0.1.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "web-app-launcher",
"version": "0.0.1",
"version": "0.1.0",
"dependencies": {
"@prisma/client": "^6.2.0",
"@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/typography": "^0.5.19",
"bcryptjs": "^2.4.3",
"bits-ui": "^1.3.0",
"clsx": "^2.1.0",
@@ -30,11 +28,14 @@
"svelte-i18n": "^4.0.1",
"sveltekit-superforms": "^2.22.0",
"tailwind-merge": "^2.6.0",
"tar": "^7.5.15",
"zod": "^3.24.0"
},
"devDependencies": {
"@eslint/js": "^9.18.0",
"@sveltejs/package": "^2.3.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.0.0",
"@testing-library/svelte": "^5.2.0",
"@types/bcryptjs": "^2.4.6",
@@ -976,6 +977,17 @@
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
"dependencies": {
"minipass": "^7.0.4"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1888,6 +1900,7 @@
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
"dev": true,
"dependencies": {
"postcss-selector-parser": "6.0.10"
},
@@ -1899,6 +1912,7 @@
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"dev": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -2840,6 +2854,14 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
"engines": {
"node": ">=18"
}
},
"node_modules/citty": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
@@ -2967,6 +2989,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true,
"bin": {
"cssesc": "bin/cssesc"
},
@@ -4497,6 +4520,25 @@
"node": "*"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/minizlib": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
"dependencies": {
"minipass": "^7.1.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -6130,7 +6172,8 @@
"node_modules/tailwindcss": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
"dev": true
},
"node_modules/tapable": {
"version": "2.3.2",
@@ -6145,6 +6188,21 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tar": {
"version": "7.5.15",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz",
"integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
"minipass": "^7.1.2",
"minizlib": "^3.1.0",
"yallist": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/timers-ext": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.8.tgz",
@@ -6867,7 +6925,8 @@
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true
},
"node_modules/uuid": {
"version": "8.3.2",
@@ -7179,6 +7238,14 @@
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="
},
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
"engines": {
"node": ">=18"
}
},
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
@@ -7741,6 +7808,14 @@
"@swc/helpers": "^0.5.0"
}
},
"@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
"requires": {
"minipass": "^7.0.4"
}
},
"@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -8274,6 +8349,7 @@
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
"dev": true,
"requires": {
"postcss-selector-parser": "6.0.10"
},
@@ -8282,6 +8358,7 @@
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"dev": true,
"requires": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -8944,6 +9021,11 @@
"readdirp": "^5.0.0"
}
},
"chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="
},
"citty": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
@@ -9049,7 +9131,8 @@
"cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true
},
"d": {
"version": "1.0.2",
@@ -10121,6 +10204,19 @@
"brace-expansion": "^1.1.7"
}
},
"minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="
},
"minizlib": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
"requires": {
"minipass": "^7.1.2"
}
},
"mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -11017,7 +11113,8 @@
"tailwindcss": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
"dev": true
},
"tapable": {
"version": "2.3.2",
@@ -11025,6 +11122,18 @@
"integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==",
"dev": true
},
"tar": {
"version": "7.5.15",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz",
"integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==",
"requires": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
"minipass": "^7.1.2",
"minizlib": "^3.1.0",
"yallist": "^5.0.0"
}
},
"timers-ext": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.8.tgz",
@@ -11426,7 +11535,8 @@
"util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true
},
"uuid": {
"version": "8.3.2",
@@ -11581,6 +11691,11 @@
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="
},
"yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="
},
"yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
+1
View File
@@ -42,6 +42,7 @@
"svelte-i18n": "^4.0.1",
"sveltekit-superforms": "^2.22.0",
"tailwind-merge": "^2.6.0",
"tar": "^7.5.15",
"zod": "^3.24.0"
},
"prisma": {
+279 -83
View File
@@ -4,83 +4,140 @@
@custom-variant dark (&:is(.dark *));
:root {
/* HSL-based primary color (overridden by theme store via JS) */
--primary-h: 220;
--primary-s: 70%;
--primary-l: 50%;
/* =====================================================================
COZY HOME design system
---------------------------------------------------------------------
Tokens are intentionally organised as a single swappable "bundle":
the neutral ramp + accent + shape + type live here in :root / .dark.
Swapping these blocks for another set (e.g. Command Deck / Aurora /
Editorial) is all a future theme-preset system needs to do — no
component edits required, because the whole app reads these vars.
Accent stays user-tunable via --primary-h / --primary-s.
===================================================================== */
--background: hsl(0 0% 100%);
--foreground: hsl(240 10% 3.9%);
--muted: hsl(240 4.8% 95.9%);
--muted-foreground: hsl(240 3.8% 46.1%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(240 10% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(240 10% 3.9%);
--border: hsl(240 5.9% 90%);
--input: hsl(240 5.9% 90%);
:root {
/* Accent — terracotta by default, still user-tunable from settings */
--primary-h: 16;
--primary-s: 72%;
--primary-l: 56%;
/* Neutrals — warm cream "paper" ramp */
--background: hsl(35 56% 97%); /* #fdf8f2 warm cream */
--foreground: hsl(33 18% 18%); /* #3a322b warm ink */
--muted: hsl(36 42% 93%); /* #f3ecde */
--muted-foreground: hsl(34 12% 47%); /* #857a6d */
--popover: hsl(40 60% 99%);
--popover-foreground: hsl(33 18% 18%);
--card: hsl(40 60% 99%); /* #fffdfa */
--card-foreground: hsl(33 18% 18%);
--border: hsl(36 35% 88%); /* #ece2d3 */
--input: hsl(36 35% 88%);
--primary: hsl(var(--primary-h) var(--primary-s) var(--primary-l));
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(240 4.8% 95.9%);
--secondary-foreground: hsl(240 5.9% 10%);
--accent: hsl(240 4.8% 95.9%);
--accent-foreground: hsl(240 5.9% 10%);
--destructive: hsl(0 72.2% 50.6%);
--destructive-foreground: hsl(0 0% 98%);
--primary-foreground: hsl(40 60% 99%);
--secondary: hsl(36 42% 93%);
--secondary-foreground: hsl(33 18% 22%);
--accent: hsl(34 44% 90%); /* hover wash */
--accent-foreground: hsl(33 18% 20%);
--destructive: hsl(6 68% 56%);
--destructive-foreground: hsl(40 60% 99%);
--ring: hsl(var(--primary-h) var(--primary-s) var(--primary-l));
--status-online: #22c55e;
--status-offline: #ef4444;
--status-degraded: #eab308;
--status-unknown: #6b7280;
--radius: 0.5rem;
--sidebar: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%);
/* Status — vivid values for dots / bars / rings / sparklines */
--status-online: #5fa86c;
--status-offline: #e0685f;
--status-degraded: #d99a2b;
--status-unknown: #b3a899;
/* Status "ink" — darker, AA-legible as small text on cream + tinted washes */
--status-online-ink: #2c723f;
--status-offline-ink: #bd382e;
--status-degraded-ink: #785406;
--status-unknown-ink: #6b5f50;
/* Pastel "rooms" — category / board accents */
--room-sage: #7fb069;
--room-sky: #6ca9d6;
--room-butter: #f3c969;
--room-lav: #b09fd6;
--room-peach: #ff9a76;
--room-terra: #e8754f;
/* Shape — cozy rounding */
--radius: 1rem;
/* Soft warm shadows */
--shadow-soft: 0 10px 26px -20px rgba(80, 50, 20, 0.45);
--shadow-lift: 0 22px 40px -22px rgba(80, 50, 20, 0.4);
/* Typography */
--font-sans: 'Figtree', system-ui, -apple-system, sans-serif;
--font-display: 'Fraunces', 'Figtree', Georgia, serif;
/* Sidebar */
--sidebar: hsl(36 48% 95%);
--sidebar-foreground: hsl(34 14% 32%);
--sidebar-primary: hsl(var(--primary-h) var(--primary-s) var(--primary-l));
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(240 4.8% 95.9%);
--sidebar-accent-foreground: hsl(240 5.9% 10%);
--sidebar-border: hsl(220 13% 91%);
--sidebar-primary-foreground: hsl(40 60% 99%);
--sidebar-accent: hsl(34 44% 90%);
--sidebar-accent-foreground: hsl(33 18% 20%);
--sidebar-border: hsl(36 35% 87%);
--sidebar-ring: hsl(var(--primary-h) calc(var(--primary-s) * 1.2) 60%);
}
.dark {
--primary-l: 60%;
/* "Dusk" — warm charcoal, not cold black */
--primary-l: 62%;
--background: hsl(240 10% 3.9%);
--foreground: hsl(0 0% 98%);
--muted: hsl(240 3.7% 15.9%);
--muted-foreground: hsl(240 5% 64.9%);
--popover: hsl(240 10% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--card: hsl(240 6% 7%);
--card-foreground: hsl(0 0% 98%);
--border: hsl(240 3.7% 15.9%);
--input: hsl(240 3.7% 15.9%);
--background: hsl(30 14% 9%); /* #1a1714 */
--foreground: hsl(35 30% 90%); /* #f0e9df */
--muted: hsl(30 14% 16%); /* #2b2520 */
--muted-foreground: hsl(35 14% 64%); /* #b3a899 */
--popover: hsl(30 16% 12%);
--popover-foreground: hsl(35 30% 90%);
--card: hsl(30 16% 13%); /* #262019 */
--card-foreground: hsl(35 30% 90%);
--border: hsl(31 16% 19%); /* #352d24 */
--input: hsl(31 16% 19%);
--primary: hsl(var(--primary-h) var(--primary-s) var(--primary-l));
--primary-foreground: hsl(240 5.9% 10%);
--secondary: hsl(240 3.7% 15.9%);
--secondary-foreground: hsl(0 0% 98%);
--accent: hsl(240 3.7% 15.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(0 0% 98%);
--primary-foreground: hsl(30 18% 10%);
--secondary: hsl(30 14% 16%);
--secondary-foreground: hsl(35 30% 90%);
--accent: hsl(30 14% 18%);
--accent-foreground: hsl(35 30% 90%);
--destructive: hsl(6 58% 46%);
--destructive-foreground: hsl(40 60% 99%);
--ring: hsl(var(--primary-h) var(--primary-s) var(--primary-l));
--sidebar: hsl(240 5.9% 6%);
--sidebar-foreground: hsl(240 4.8% 95.9%);
--status-online: #6dba79;
--status-offline: #ea7a72;
--status-degraded: #e3ab4a;
--status-unknown: #9a8f80;
/* On dusk charcoal the vivid values already clear AA — ink == vivid */
--status-online-ink: #6dba79;
--status-offline-ink: #ea7a72;
--status-degraded-ink: #e3ab4a;
--status-unknown-ink: #9a8f80;
--shadow-soft: 0 12px 30px -20px rgba(0, 0, 0, 0.65);
--shadow-lift: 0 26px 46px -22px rgba(0, 0, 0, 0.6);
--sidebar: hsl(30 16% 11%);
--sidebar-foreground: hsl(35 22% 82%);
--sidebar-primary: hsl(var(--primary-h) var(--primary-s) var(--primary-l));
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(240 3.7% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(240 3.7% 15.9%);
--sidebar-primary-foreground: hsl(30 18% 10%);
--sidebar-accent: hsl(30 14% 18%);
--sidebar-accent-foreground: hsl(35 30% 90%);
--sidebar-border: hsl(31 16% 19%);
--sidebar-ring: hsl(var(--primary-h) calc(var(--primary-s) * 1.2) 60%);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 8px);
--radius-md: calc(var(--radius) - 4px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-xl: calc(var(--radius) + 6px);
--font-sans: var(--font-sans);
--font-display: var(--font-display);
--color-background: var(--background);
--color-foreground: var(--foreground);
@@ -101,6 +158,23 @@
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-ring: var(--ring);
--color-status-online: var(--status-online);
--color-status-offline: var(--status-offline);
--color-status-degraded: var(--status-degraded);
--color-status-unknown: var(--status-unknown);
--color-status-online-ink: var(--status-online-ink);
--color-status-offline-ink: var(--status-offline-ink);
--color-status-degraded-ink: var(--status-degraded-ink);
--color-status-unknown-ink: var(--status-unknown-ink);
--color-room-sage: var(--room-sage);
--color-room-sky: var(--room-sky);
--color-room-butter: var(--room-butter);
--color-room-lav: var(--room-lav);
--color-room-peach: var(--room-peach);
--color-room-terra: var(--room-terra);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
@@ -117,10 +191,21 @@
}
body {
@apply bg-background text-foreground;
font-family: var(--font-sans);
font-feature-settings: 'ss01', 'cv01';
transition:
background-color 0.3s ease,
color 0.3s ease;
}
/* Display face for headings — gives the cozy/editorial warmth */
h1,
h2,
h3,
.font-display {
font-family: var(--font-display);
font-optical-sizing: auto;
letter-spacing: -0.01em;
}
}
/* ===== Status Indicator Pulse ===== */
@@ -136,29 +221,64 @@
}
}
@keyframes status-breathe {
0%,
100% {
opacity: 0.85;
}
50% {
opacity: 1;
}
}
@keyframes status-flash {
0% {
transform: scale(1);
opacity: 0.6;
}
30% {
transform: scale(1.25);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.status-online {
animation: status-pulse 2s ease-in-out infinite;
color: hsl(142 71% 45%);
color: var(--status-online);
}
.status-degraded {
animation: status-breathe 2.6s ease-in-out infinite;
color: var(--status-degraded);
}
.status-offline {
animation: status-flash 0.6s ease-out 1;
color: var(--status-offline);
}
/* ===== Card Style Variants ===== */
.card-solid {
background: var(--card);
border: 1px solid var(--border);
box-shadow: var(--shadow-soft);
}
.card-glass {
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
background: color-mix(in srgb, var(--card) 60%, transparent);
border: 1px solid color-mix(in srgb, var(--border) 30%, transparent);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.05);
background: color-mix(in srgb, var(--card) 70%, transparent);
border: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
box-shadow: var(--shadow-soft);
}
.dark .card-glass {
background: color-mix(in srgb, var(--card) 50%, transparent);
border: 1px solid color-mix(in srgb, var(--border) 25%, transparent);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.15);
background: color-mix(in srgb, var(--card) 55%, transparent);
border: 1px solid color-mix(in srgb, var(--border) 35%, transparent);
}
.card-outline {
@@ -170,24 +290,17 @@
border-color: var(--border);
}
/* ===== Card Hover Effects ===== */
/* ===== Card Hover Effects — gentle cozy lift + micro-tilt ===== */
.card-hover {
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1),
box-shadow 0.2s ease,
border-color 0.2s ease;
}
.card-hover:hover {
transform: scale(1.02);
box-shadow:
0 10px 25px -5px rgba(0, 0, 0, 0.15),
0 4px 10px -5px rgba(0, 0, 0, 0.1);
}
.dark .card-hover:hover {
box-shadow:
0 10px 25px -5px rgba(0, 0, 0, 0.4),
0 4px 10px -5px rgba(0, 0, 0, 0.3);
transform: translateY(-5px) rotate(-0.35deg);
box-shadow: var(--shadow-lift);
}
/* ===== Skeleton Loading ===== */
@@ -201,14 +314,14 @@
}
.skeleton {
background: linear-gradient(90deg, var(--muted) 25%, hsl(240 4.8% 85%) 50%, var(--muted) 75%);
background: linear-gradient(90deg, var(--muted) 25%, hsl(36 30% 86%) 50%, var(--muted) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: var(--radius);
}
.dark .skeleton {
background: linear-gradient(90deg, var(--muted) 25%, hsl(240 3.7% 22%) 50%, var(--muted) 75%);
background: linear-gradient(90deg, var(--muted) 25%, hsl(30 12% 22%) 50%, var(--muted) 75%);
background-size: 200% 100%;
}
@@ -236,7 +349,7 @@
[data-keyboard-selected='true'] {
outline: 2px solid hsl(var(--primary-h) var(--primary-s) var(--primary-l));
outline-offset: 2px;
border-radius: var(--radius, 0.5rem);
border-radius: var(--radius, 1rem);
}
/* ===== Aurora Keyframes ===== */
@@ -251,3 +364,86 @@
background-position: 0% 50%;
}
}
/* ===== Cozy entrance reveal ===== */
@keyframes cozy-rise {
0% {
opacity: 0;
transform: translateY(12px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.cozy-rise {
animation: cozy-rise 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
/* For staggered grid reveals — set --i as 0,1,2,... per item */
.cozy-rise-stagger {
animation: cozy-rise 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) both;
animation-delay: calc(var(--i, 0) * 55ms);
}
/* ===== Cozy accordion (height slide for show/hide) ===== */
@keyframes cozy-expand {
0% {
opacity: 0;
transform: translateY(-4px);
max-height: 0;
}
100% {
opacity: 1;
transform: translateY(0);
max-height: 1200px;
}
}
.cozy-expand {
overflow: hidden;
animation: cozy-expand 0.32s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
/* ===== Cozy greeting wave ===== */
@keyframes cozy-wave {
0%,
60%,
100% {
transform: rotate(0);
}
10% {
transform: rotate(16deg);
}
20% {
transform: rotate(-8deg);
}
30% {
transform: rotate(14deg);
}
40% {
transform: rotate(-4deg);
}
}
.cozy-wave {
display: inline-block;
transform-origin: 70% 70%;
animation: cozy-wave 2.6s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.cozy-wave,
.status-online,
.status-degraded,
.status-offline,
.cozy-rise,
.cozy-rise-stagger,
.cozy-expand {
animation: none;
}
.card-hover:hover {
transform: none;
}
}
+4 -1
View File
@@ -5,11 +5,14 @@
<link rel="icon" href="%sveltekit.assets%/icon.svg" type="image/svg+xml" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
<meta name="theme-color" content="#6366f1" />
<meta name="theme-color" content="#e8754f" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Launcher" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/icon.svg" />
<!-- Cozy Home typography: Fraunces (display) + Figtree (body).
Self-hosted from /static/fonts so offline/LAN installs work with no external calls. -->
<link rel="stylesheet" href="%sveltekit.assets%/fonts/fonts.css" />
<script>
// Inline script to prevent FOUC — set theme class before first paint
(function () {
+52
View File
@@ -6,6 +6,7 @@ import * as apiTokenService from '$lib/server/services/apiTokenService.js';
import { extractBearerToken } from '$lib/server/middleware/authenticate.js';
import { isBoardGuestAccessible } from '$lib/server/middleware/guestAccess.js';
import { initBackupScheduler } from '$lib/server/jobs/backupScheduler.js';
import { isRestoring, isDegraded, getDegradedReason } from '$lib/server/services/backupService.js';
import { startScheduler as startHealthcheckScheduler } from '$lib/server/jobs/healthcheckScheduler.js';
import {
clearSessionCookies,
@@ -52,6 +53,57 @@ function isPublicPath(pathname: string): boolean {
}
export const handle: Handle = async ({ event, resolve }) => {
const reqPath = event.url.pathname;
// While a restore is mid-flight, Prisma is disconnected and the live DB
// file (and uploads tree) is being swapped. Any other request that
// touches the DB or the uploads dir would crash; return 503 instead.
//
// Whitelist: bundled SvelteKit assets (immutable, served from disk paths
// that are not affected by restore) and /api/health (so liveness probes
// can still observe the degraded state). /uploads/ is NOT whitelisted —
// uploaded files live in the dir being renamed and concurrent reads on
// Windows can block the rename outright.
if (isRestoring()) {
const isBundledAsset = reqPath.startsWith('/_app/') || reqPath.startsWith('/favicon');
if (!(isBundledAsset || reqPath === '/api/health')) {
return new Response(
JSON.stringify({
success: false,
data: null,
error: 'Database restore in progress. Please retry in a moment.'
}),
{
status: 503,
headers: {
'Content-Type': 'application/json',
'Retry-After': '15'
}
}
);
}
}
// After a failed restore + failed rollback the process is in an unknown
// state. Return 503 for everything except the health endpoint so the
// orchestrator can observe and recycle the container.
if (isDegraded() && reqPath !== '/api/health') {
return new Response(
JSON.stringify({
success: false,
data: null,
error: `Service degraded: ${getDegradedReason() ?? 'unknown reason'}. Restart required.`
}),
{
status: 503,
headers: {
'Content-Type': 'application/json',
'Retry-After': '60'
}
}
);
}
event.locals.user = null;
event.locals.session = null;
event.locals.apiTokenScope = null;
+12 -12
View File
@@ -96,11 +96,11 @@
}
function actionBadgeClass(action: string): string {
if (action.includes('deleted')) return 'bg-red-500/10 text-red-500';
if (action.includes('created')) return 'bg-green-500/10 text-green-500';
if (action.includes('updated')) return 'bg-blue-500/10 text-blue-500';
if (action === 'import') return 'bg-purple-500/10 text-purple-500';
if (action === 'export') return 'bg-yellow-500/10 text-yellow-500';
if (action.includes('deleted')) return 'bg-destructive/10 text-destructive';
if (action.includes('created')) return 'bg-status-online/10 text-status-online-ink';
if (action.includes('updated')) return 'bg-room-sky/15 text-room-sky';
if (action === 'import') return 'bg-room-lav/15 text-room-lav';
if (action === 'export') return 'bg-status-degraded/10 text-status-degraded-ink';
return 'bg-muted text-muted-foreground';
}
@@ -138,7 +138,7 @@
<select
id="filter-action"
bind:value={filterAction}
class="rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground"
class="rounded-xl border border-input bg-background px-3 py-1.5 text-sm text-foreground"
>
{#each actionOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
@@ -151,7 +151,7 @@
<select
id="filter-entity"
bind:value={filterEntityType}
class="rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground"
class="rounded-xl border border-input bg-background px-3 py-1.5 text-sm text-foreground"
>
{#each entityTypeOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
@@ -165,7 +165,7 @@
id="filter-from"
type="date"
bind:value={filterDateFrom}
class="rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground"
class="rounded-xl border border-input bg-background px-3 py-1.5 text-sm text-foreground"
/>
</div>
@@ -175,14 +175,14 @@
id="filter-to"
type="date"
bind:value={filterDateTo}
class="rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground"
class="rounded-xl border border-input bg-background px-3 py-1.5 text-sm text-foreground"
/>
</div>
<button
type="button"
onclick={applyFilters}
class="rounded-md bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
class="rounded-xl bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Apply
</button>
@@ -190,7 +190,7 @@
<button
type="button"
onclick={exportCsv}
class="ml-auto rounded-md border border-input px-4 py-1.5 text-sm font-medium text-foreground hover:bg-accent"
class="ml-auto rounded-xl border border-input px-4 py-1.5 text-sm font-medium text-foreground hover:bg-accent"
>
Export CSV
</button>
@@ -202,7 +202,7 @@
<p class="text-muted-foreground">No audit log entries found</p>
</div>
{:else}
<div class="overflow-x-auto rounded-lg border border-border">
<div class="overflow-x-auto rounded-2xl border border-border bg-card shadow-[var(--shadow-soft)]">
<table class="w-full text-left text-sm">
<thead class="border-b border-border bg-muted/50">
<tr>
+171 -44
View File
@@ -1,11 +1,15 @@
<script lang="ts">
import { untrack } from 'svelte';
import { t } from 'svelte-i18n';
import Switch from '$lib/components/ui/Switch.svelte';
import Select from '$lib/components/ui/Select.svelte';
import Button from '$lib/components/ui/Button.svelte';
interface BackupInfo {
filename: string;
size: number;
createdAt: string;
format: 'tar.gz' | 'db';
}
interface BackupSchedule {
@@ -14,6 +18,14 @@
backupMaxCount: number;
}
interface SchedulerStats {
successCount: number;
failureCount: number;
lastSuccessAt: string | null;
lastFailureAt: string | null;
lastFailureReason: string | null;
}
type CronPreset = 'daily' | 'twice_daily' | 'weekly' | 'custom';
let backups: BackupInfo[] = $state([]);
@@ -22,6 +34,13 @@
backupCronExpression: '0 3 * * *',
backupMaxCount: 10
});
let stats: SchedulerStats = $state({
successCount: 0,
failureCount: 0,
lastSuccessAt: null,
lastFailureAt: null,
lastFailureReason: null
});
let creating = $state(false);
let savingSchedule = $state(false);
@@ -29,6 +48,9 @@
let deletingFilename: string | null = $state(null);
let confirmRestore: string | null = $state(null);
let confirmDelete: string | null = $state(null);
let confirmSchemaMismatch = $state(false);
let pendingSchemaMismatchFile: string | null = $state(null);
let pendingSchemaMismatchMessage = $state('');
let statusMessage = $state('');
let statusType: 'success' | 'error' | '' = $state('');
let customCron = $state('');
@@ -48,7 +70,8 @@
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function formatDate(iso: string): string {
@@ -69,6 +92,7 @@
if (result.success) {
backups = result.data.backups;
schedule = result.data.schedule;
if (result.data.stats) stats = result.data.stats;
cronPreset = detectPreset(schedule.backupCronExpression);
if (cronPreset === 'custom') {
customCron = schedule.backupCronExpression;
@@ -111,23 +135,62 @@
document.body.removeChild(a);
}
async function handleRestore(filename: string) {
clearStatus();
confirmRestore = null;
restoringFilename = filename;
try {
async function performRestore(filename: string, allowSchemaMismatch: boolean): Promise<void> {
const response = await fetch(`/api/admin/backups/${encodeURIComponent(filename)}/restore`, {
method: 'POST'
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ allowSchemaMismatch })
});
const result = await response.json();
if (response.status === 409) {
const errBody = await response.json().catch(() => ({}));
pendingSchemaMismatchFile = filename;
pendingSchemaMismatchMessage = errBody.error || $t('admin.backup_restore_schema_mismatch');
confirmSchemaMismatch = true;
return;
}
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Failed to restore backup');
}
statusMessage = $t('admin.backup_restore_success');
statusType = 'success';
if (result.data?.forceLogout) {
setTimeout(() => {
window.location.href = '/login';
}, 2500);
}
}
async function handleRestore(filename: string) {
clearStatus();
confirmRestore = null;
restoringFilename = filename;
try {
await performRestore(filename, false);
} catch (err) {
statusMessage = err instanceof Error ? err.message : 'Failed to restore backup';
statusType = 'error';
} finally {
restoringFilename = null;
}
}
async function handleSchemaMismatchConfirm() {
const filename = pendingSchemaMismatchFile;
confirmSchemaMismatch = false;
pendingSchemaMismatchFile = null;
pendingSchemaMismatchMessage = '';
if (!filename) return;
clearStatus();
restoringFilename = filename;
try {
await performRestore(filename, true);
} catch (err) {
statusMessage = err instanceof Error ? err.message : 'Failed to restore backup';
statusType = 'error';
@@ -195,26 +258,20 @@
}
}
// Load backups on mount (untrack to avoid infinite re-trigger)
$effect(() => {
untrack(() => loadBackups());
});
</script>
<section class="rounded-lg border border-border bg-card p-6">
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-1 text-lg font-semibold text-card-foreground">{$t('admin.backup_title')}</h2>
<p class="mb-6 text-xs text-muted-foreground">{$t('admin.backup_description')}</p>
<!-- Create Backup -->
<div class="mb-6">
<button
type="button"
onclick={handleCreate}
disabled={creating}
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
<Button onclick={handleCreate} disabled={creating} loading={creating}>
{creating ? $t('admin.backup_creating') : $t('admin.backup_create')}
</button>
</Button>
</div>
<!-- Backup List -->
@@ -229,6 +286,7 @@
<thead>
<tr class="border-b border-border text-xs uppercase text-muted-foreground">
<th class="pb-2 pr-4 font-medium">{$t('admin.backup_filename')}</th>
<th class="pb-2 pr-4 font-medium">{$t('admin.backup_format')}</th>
<th class="pb-2 pr-4 font-medium">{$t('admin.backup_size')}</th>
<th class="pb-2 pr-4 font-medium">{$t('admin.backup_date')}</th>
<th class="pb-2 font-medium">{$t('admin.backup_actions')}</th>
@@ -238,6 +296,20 @@
{#each backups as backup (backup.filename)}
<tr class="border-b border-border/50">
<td class="py-2.5 pr-4 font-mono text-xs text-foreground">{backup.filename}</td>
<td class="py-2.5 pr-4 text-xs text-muted-foreground">
{#if backup.format === 'tar.gz'}
<span class="rounded-md bg-status-online/10 px-2 py-0.5 text-status-online-ink">
{$t('admin.backup_format_full')}
</span>
{:else}
<span
class="rounded-md bg-status-degraded/10 px-2 py-0.5 text-status-degraded-ink"
title={$t('admin.backup_format_legacy_tooltip')}
>
{$t('admin.backup_format_legacy')}
</span>
{/if}
</td>
<td class="py-2.5 pr-4 text-muted-foreground">{formatBytes(backup.size)}</td>
<td class="py-2.5 pr-4 text-muted-foreground">{formatDate(backup.createdAt)}</td>
<td class="py-2.5">
@@ -253,7 +325,7 @@
type="button"
onclick={() => (confirmRestore = backup.filename)}
disabled={restoringFilename === backup.filename}
class="rounded px-2 py-1 text-xs font-medium text-amber-600 hover:bg-amber-600/10 disabled:opacity-50 dark:text-amber-400"
class="rounded-lg px-2 py-1 text-xs font-medium text-status-degraded-ink hover:bg-status-degraded/10 disabled:opacity-50"
>
{restoringFilename === backup.filename
? '...'
@@ -281,14 +353,23 @@
<!-- Restore Confirmation Dialog -->
{#if confirmRestore}
{@const target = backups.find((b) => b.filename === confirmRestore)}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="mx-4 w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
<div class="mx-4 w-full max-w-md rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-lift)]">
<h3 class="mb-2 text-lg font-semibold text-card-foreground">
{$t('admin.backup_restore_confirm_title')}
</h3>
<p class="mb-4 text-sm text-muted-foreground">
{$t('admin.backup_restore_confirm')}
</p>
{#if target?.format === 'db'}
<p class="mb-3 rounded-md border border-status-degraded/30 bg-status-degraded/10 p-3 text-xs text-status-degraded-ink">
{$t('admin.backup_restore_legacy_warning')}
</p>
{/if}
<p class="mb-3 rounded-md border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-700 dark:text-amber-300">
{$t('admin.backup_restore_logout_warning')}
</p>
<p class="mb-4 font-mono text-xs text-foreground">{confirmRestore}</p>
<div class="flex justify-end gap-3">
<button
@@ -301,7 +382,8 @@
<button
type="button"
onclick={() => confirmRestore && handleRestore(confirmRestore)}
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700"
class="rounded-xl px-4 py-2 text-sm font-semibold text-white shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5"
style="background: var(--status-degraded);"
>
{$t('admin.backup_restore')}
</button>
@@ -310,10 +392,44 @@
</div>
{/if}
<!-- Schema-mismatch follow-up confirmation -->
{#if confirmSchemaMismatch}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="mx-4 w-full max-w-md rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-lift)]">
<h3 class="mb-2 text-lg font-semibold text-card-foreground">
{$t('admin.backup_restore_schema_mismatch_title')}
</h3>
<p class="mb-3 text-sm text-muted-foreground">
{$t('admin.backup_restore_schema_mismatch_intro')}
</p>
<pre class="mb-4 max-h-32 overflow-auto whitespace-pre-wrap rounded-md bg-muted p-3 font-mono text-[10px] text-foreground">{pendingSchemaMismatchMessage}</pre>
<div class="flex justify-end gap-3">
<button
type="button"
onclick={() => {
confirmSchemaMismatch = false;
pendingSchemaMismatchFile = null;
}}
class="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted"
>
Cancel
</button>
<button
type="button"
onclick={handleSchemaMismatchConfirm}
class="rounded-xl bg-destructive px-4 py-2 text-sm font-semibold text-destructive-foreground hover:bg-destructive/90"
>
{$t('admin.backup_restore_schema_mismatch_force')}
</button>
</div>
</div>
</div>
{/if}
<!-- Delete Confirmation Dialog -->
{#if confirmDelete}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="mx-4 w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
<div class="mx-4 w-full max-w-md rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-lift)]">
<h3 class="mb-2 text-lg font-semibold text-card-foreground">
{$t('admin.backup_delete_confirm_title')}
</h3>
@@ -350,11 +466,10 @@
<div class="space-y-4">
<!-- Enable toggle -->
<label class="flex items-center gap-3">
<input
type="checkbox"
<label class="flex cursor-pointer items-center gap-3">
<Switch
bind:checked={schedule.backupEnabled}
class="h-4 w-4 rounded border-border text-primary focus:ring-ring"
ariaLabel={$t('admin.backup_schedule_enabled')}
/>
<span class="text-sm text-foreground">{$t('admin.backup_schedule_enabled')}</span>
</label>
@@ -365,16 +480,12 @@
<label for="cron-preset" class="mb-1 block text-sm text-muted-foreground">
{$t('admin.backup_schedule_cron')}
</label>
<select
id="cron-preset"
bind:value={cronPreset}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground sm:w-auto"
>
<Select id="cron-preset" bind:value={cronPreset} class="sm:w-auto">
<option value="daily">{$t('admin.backup_schedule_preset_daily')}</option>
<option value="twice_daily">{$t('admin.backup_schedule_preset_twice_daily')}</option>
<option value="weekly">{$t('admin.backup_schedule_preset_weekly')}</option>
<option value="custom">{$t('admin.backup_schedule_preset_custom')}</option>
</select>
</Select>
</div>
{#if cronPreset === 'custom'}
@@ -383,7 +494,7 @@
type="text"
bind:value={customCron}
placeholder="0 3 * * *"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground sm:w-64"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground sm:w-64"
/>
</div>
{/if}
@@ -399,28 +510,44 @@
bind:value={schedule.backupMaxCount}
min="1"
max="100"
class="w-24 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
class="w-24 rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
/>
</div>
<!-- Scheduler stats -->
<div class="rounded-lg border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
<div class="grid grid-cols-2 gap-1">
<span>{$t('admin.backup_stats_success_count')}</span>
<span class="text-right font-mono text-foreground">{stats.successCount}</span>
<span>{$t('admin.backup_stats_failure_count')}</span>
<span class="text-right font-mono {stats.failureCount > 0 ? 'text-destructive' : 'text-foreground'}">{stats.failureCount}</span>
{#if stats.lastSuccessAt}
<span>{$t('admin.backup_stats_last_success')}</span>
<span class="text-right font-mono text-foreground">{formatDate(stats.lastSuccessAt)}</span>
{/if}
{#if stats.lastFailureAt}
<span>{$t('admin.backup_stats_last_failure')}</span>
<span class="text-right font-mono text-destructive">{formatDate(stats.lastFailureAt)}</span>
{/if}
</div>
{#if stats.lastFailureReason}
<p class="mt-2 break-words font-mono text-[10px] text-destructive">{stats.lastFailureReason}</p>
{/if}
</div>
{/if}
<button
type="button"
onclick={handleSaveSchedule}
disabled={savingSchedule}
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
<Button onclick={handleSaveSchedule} disabled={savingSchedule} loading={savingSchedule}>
{savingSchedule ? $t('admin.backup_schedule_saving') : $t('admin.backup_schedule_save')}
</button>
</Button>
</div>
</div>
<!-- Status message -->
{#if statusMessage}
<div
class="mt-4 rounded-md p-3 text-sm {statusType === 'success'
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'}"
class="mt-4 rounded-xl border p-3 text-sm {statusType === 'success'
? 'border-status-online/30 bg-status-online/10 text-status-online-ink'
: 'border-destructive/30 bg-destructive/10 text-destructive'}"
>
{statusMessage}
</div>
+19 -20
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
import Checkbox from '$lib/components/ui/Checkbox.svelte';
import Button from '$lib/components/ui/Button.svelte';
interface DiscoveredService {
name: string;
@@ -137,25 +139,24 @@
const selectableCount = $derived(services.filter((s) => !s.alreadyRegistered).length);
</script>
<section class="rounded-lg border border-border bg-card p-6">
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.discovery_title')}</h2>
<p class="mb-6 text-xs text-muted-foreground">{$t('admin.discovery_description')}</p>
<!-- Scan Button -->
<div class="mb-6">
<button
type="button"
<Button
onclick={handleScan}
disabled={scanning || (!dockerSocketPath && !traefikApiUrl)}
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
loading={scanning}
>
{scanning ? $t('admin.discovery_scanning') : $t('admin.discovery_scan')}
</button>
</Button>
</div>
<!-- Scan Errors -->
{#if scanErrors.length > 0}
<div class="mb-4 rounded-md bg-yellow-100 p-3 text-sm text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
<div class="mb-4 rounded-xl border border-status-degraded/30 bg-status-degraded/10 p-3 text-sm text-status-degraded-ink">
{#each scanErrors as scanError, idx (idx)}
<p>{scanError}</p>
{/each}
@@ -169,12 +170,12 @@
<thead>
<tr class="border-b border-border">
<th class="px-2 py-2 text-left">
<input
type="checkbox"
<Checkbox
checked={selected.size === selectableCount && selectableCount > 0}
indeterminate={selected.size > 0 && selected.size < selectableCount}
onchange={toggleSelectAll}
disabled={selectableCount === 0}
class="h-4 w-4 rounded border-input"
ariaLabel={$t('admin.discovery_select_all') ?? 'Select all'}
/>
</th>
<th class="px-2 py-2 text-left font-medium text-muted-foreground">{$t('common.name')}</th>
@@ -187,12 +188,11 @@
{#each services as service, i (service.url)}
<tr class="border-b border-border/50 hover:bg-muted/50">
<td class="px-2 py-2">
<input
type="checkbox"
<Checkbox
checked={selected.has(i)}
onchange={() => toggleSelect(i)}
disabled={service.alreadyRegistered}
class="h-4 w-4 rounded border-input"
ariaLabel={`Select ${service.name}`}
/>
</td>
<td class="px-2 py-2 font-medium text-foreground">{service.name}</td>
@@ -204,8 +204,8 @@
<td class="px-2 py-2">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{service.source === 'docker'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
? 'bg-room-sky/15 text-room-sky'
: 'bg-room-lav/15 text-room-lav'
}"
>
{service.source === 'docker' ? $t('admin.discovery_source_docker') : $t('admin.discovery_source_traefik')}
@@ -215,7 +215,7 @@
{#if service.alreadyRegistered}
<span class="text-xs text-muted-foreground">{$t('admin.discovery_already_registered')}</span>
{:else}
<span class="text-xs font-medium text-green-600 dark:text-green-400">{$t('admin.discovery_new')}</span>
<span class="text-xs font-medium text-status-online-ink dark:text-status-online-ink">{$t('admin.discovery_new')}</span>
{/if}
</td>
</tr>
@@ -227,21 +227,20 @@
<!-- Approve button -->
{#if selectableCount > 0}
<div class="mt-4">
<button
type="button"
<Button
onclick={handleApprove}
disabled={approving || selected.size === 0}
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
loading={approving}
>
{approving ? $t('admin.discovery_approving') : $t('admin.discovery_approve')} ({selected.size})
</button>
</Button>
</div>
{/if}
{/if}
<!-- Status Message -->
{#if statusMessage}
<div class="mt-4 rounded-md p-3 text-sm {statusType === 'success' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'}">
<div class="mt-4 rounded-xl border p-3 text-sm {statusType === 'success' ? 'border-status-online/30 bg-status-online/10 text-status-online-ink' : 'border-destructive/30 bg-destructive/10 text-destructive'}">
{statusMessage}
</div>
{/if}
+9 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { enhance } from '$app/forms';
import Switch from '$lib/components/ui/Switch.svelte';
interface GroupWithCount {
id: string;
@@ -27,7 +28,7 @@
}
</script>
<div class="overflow-x-auto rounded-lg border border-border">
<div class="overflow-x-auto rounded-2xl border border-border bg-card shadow-[var(--shadow-soft)]">
<table class="w-full text-left text-sm">
<thead class="border-b border-border bg-muted/50">
<tr>
@@ -64,8 +65,13 @@
placeholder={$t('common.description')}
class="rounded border border-input bg-background px-2 py-1 text-sm text-foreground"
/>
<label class="flex items-center gap-1 text-xs text-foreground">
<input name="isDefault" type="checkbox" bind:checked={editIsDefault} class="h-3.5 w-3.5" />
<label class="flex cursor-pointer items-center gap-2 text-xs text-foreground">
<Switch
name="isDefault"
bind:checked={editIsDefault}
size="sm"
ariaLabel={$t('admin.default_column')}
/>
{$t('admin.default_column')}
</label>
<button type="submit" class="text-xs text-primary hover:underline">{$t('common.save')}</button>
@@ -129,7 +129,7 @@
<div class="space-y-4">
<!-- Grant form -->
<div class="rounded-lg border border-border bg-card p-4">
<div class="rounded-xl border border-border bg-card p-4">
<h3 class="mb-3 text-sm font-semibold text-card-foreground">{$t('admin.perm_title')}</h3>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-5">
<div>
@@ -198,7 +198,7 @@
<!-- Existing permissions list -->
{#if permissions.length > 0}
<div class="overflow-x-auto rounded-lg border border-border">
<div class="overflow-x-auto rounded-2xl border border-border bg-card shadow-[var(--shadow-soft)]">
<table class="w-full text-left text-sm">
<thead class="border-b border-border bg-muted/50">
<tr>
+36 -36
View File
@@ -4,6 +4,9 @@
import type { updateSystemSettingsSchema } from '$lib/utils/validators.js';
import type { z } from 'zod';
import CustomCssEditor from '$lib/components/settings/CustomCssEditor.svelte';
import Switch from '$lib/components/ui/Switch.svelte';
import Select from '$lib/components/ui/Select.svelte';
import Button from '$lib/components/ui/Button.svelte';
let {
form: formData,
@@ -46,32 +49,34 @@
<form method="POST" action="?/update" use:enhance class="space-y-8">
<!-- Authentication -->
<section class="rounded-lg border border-border bg-card p-6">
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.authentication')}</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="authMode" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.auth_mode')}</label>
<select
<Select
id="authMode"
name="authMode"
bind:value={$form.authMode}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<option value="local">{$t('admin.auth_local')}</option>
<option value="oauth">{$t('admin.auth_oauth')}</option>
<option value="both">{$t('admin.auth_both')}</option>
</select>
</Select>
{#if $errors.authMode}<span class="text-xs text-destructive">{$errors.authMode}</span>{/if}
</div>
<div class="flex items-center gap-2 pt-6">
<input
<div class="flex items-center gap-3 pt-6">
<Switch
id="registrationEnabled"
name="registrationEnabled"
type="checkbox"
bind:checked={$form.registrationEnabled}
class="h-4 w-4 rounded border-input"
ariaLabelledby="registrationEnabledLabel"
/>
<label for="registrationEnabled" class="text-sm font-medium text-foreground">
<label
id="registrationEnabledLabel"
for="registrationEnabled"
class="cursor-pointer text-sm font-medium text-foreground"
>
{$t('admin.registration_enabled')}
</label>
</div>
@@ -79,7 +84,7 @@
</section>
<!-- OAuth Configuration -->
<section class="rounded-lg border border-border bg-card p-6">
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.oauth_config')}</h2>
<p class="mb-4 text-xs text-muted-foreground">
{$t('admin.oauth_description')}
@@ -92,7 +97,7 @@
name="oauthClientId"
type="text"
bind:value={$form.oauthClientId}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
placeholder={$t('admin.oauth_client_id_placeholder')}
/>
</div>
@@ -103,7 +108,7 @@
name="oauthClientSecret"
type="password"
bind:value={$form.oauthClientSecret}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
placeholder={$t('admin.oauth_client_secret_placeholder')}
/>
</div>
@@ -114,22 +119,22 @@
name="oauthDiscoveryUrl"
type="url"
bind:value={$form.oauthDiscoveryUrl}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
placeholder={$t('admin.oauth_discovery_url_placeholder')}
/>
{#if $errors.oauthDiscoveryUrl}<span class="text-xs text-destructive">{$errors.oauthDiscoveryUrl}</span>{/if}
</div>
<div class="sm:col-span-2">
<button
type="button"
<Button
variant="outline"
onclick={testOAuthConnection}
disabled={oauthTesting}
class="rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
loading={oauthTesting}
>
{oauthTesting ? $t('admin.oauth_testing') : $t('admin.oauth_test')}
</button>
</Button>
{#if oauthTestResult}
<span class="ml-3 text-sm {oauthTestSuccess ? 'text-green-600 dark:text-green-400' : 'text-destructive'}">
<span class="ml-3 text-sm {oauthTestSuccess ? 'text-status-online-ink dark:text-status-online-ink' : 'text-destructive'}">
{oauthTestResult}
</span>
{/if}
@@ -138,20 +143,19 @@
</section>
<!-- Theme Defaults -->
<section class="rounded-lg border border-border bg-card p-6">
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.theme_defaults')}</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="defaultTheme" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.default_theme')}</label>
<select
<Select
id="defaultTheme"
name="defaultTheme"
bind:value={$form.defaultTheme}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<option value="dark">{$t('theme.dark')}</option>
<option value="light">{$t('theme.light')}</option>
</select>
</Select>
</div>
<div>
<label for="defaultPrimaryColor" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.default_primary_color')}</label>
@@ -161,8 +165,8 @@
name="defaultPrimaryColor"
type="text"
bind:value={$form.defaultPrimaryColor}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
placeholder="#6366f1"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
placeholder="#e8754f"
pattern="^#[0-9a-fA-F]{6}$"
/>
{#if $form.defaultPrimaryColor}
@@ -178,7 +182,7 @@
</section>
<!-- Healthcheck Defaults -->
<section class="rounded-lg border border-border bg-card p-6">
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.healthcheck_defaults')}</h2>
<p class="mb-4 text-xs text-muted-foreground">{$t('admin.healthcheck_defaults_description')}</p>
<div>
@@ -188,7 +192,7 @@
name="healthcheckDefaults"
bind:value={$form.healthcheckDefaults}
rows="4"
class="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground"
class="w-full rounded-xl border border-input bg-background px-3 py-2 font-mono text-sm text-foreground"
placeholder={'{"interval": 300, "timeout": 5000, "method": "GET"}'}
></textarea>
{#if $errors.healthcheckDefaults}<span class="text-xs text-destructive">{$errors.healthcheckDefaults}</span>{/if}
@@ -196,7 +200,7 @@
</section>
<!-- Service Discovery Configuration -->
<section class="rounded-lg border border-border bg-card p-6">
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.discovery_config')}</h2>
<p class="mb-4 text-xs text-muted-foreground">{$t('admin.discovery_config_description')}</p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -206,7 +210,7 @@
id="dockerSocketPath"
type="text"
bind:value={dockerSocketPath}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
placeholder="/var/run/docker.sock"
/>
<p class="mt-1 text-xs text-muted-foreground">{$t('admin.discovery_docker_socket_hint')}</p>
@@ -217,7 +221,7 @@
id="traefikApiUrl"
type="url"
bind:value={traefikApiUrl}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
placeholder="http://traefik:8080"
/>
<p class="mt-1 text-xs text-muted-foreground">{$t('admin.discovery_traefik_url_hint')}</p>
@@ -226,7 +230,7 @@
</section>
<!-- System Custom CSS -->
<section class="rounded-lg border border-border bg-card p-6">
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.custom_css') ?? 'Custom CSS'}</h2>
<p class="mb-4 text-xs text-muted-foreground">{$t('admin.custom_css_description') ?? 'System-wide custom CSS applied to all pages. Scoped to .custom-css-scope to prevent breaking core UI.'}</p>
<input type="hidden" name="customCss" value={$form.customCss ?? ''} />
@@ -242,12 +246,8 @@
{/if}
<div class="flex items-center gap-3">
<button
type="submit"
class="rounded-md bg-primary px-6 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring"
disabled={$delayed}
>
<Button type="submit" size="lg" disabled={$delayed} loading={$delayed}>
{$delayed ? $t('admin.saving_settings') : $t('admin.save_settings')}
</button>
</Button>
</div>
</form>
+10 -18
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import Button from '$lib/components/ui/Button.svelte';
interface Tag {
id: string;
@@ -14,13 +15,13 @@
// Create form
let newName = $state('');
let newColor = $state('#6366f1');
let newColor = $state('#e8754f');
let showCreateForm = $state(false);
// Edit form
let editingTag = $state<Tag | null>(null);
let editName = $state('');
let editColor = $state('#6366f1');
let editColor = $state('#e8754f');
// Delete confirmation
let confirmDeleteId = $state<string | null>(null);
@@ -56,7 +57,7 @@
});
if (res.ok) {
newName = '';
newColor = '#6366f1';
newColor = '#e8754f';
showCreateForm = false;
await loadTags();
} else {
@@ -71,7 +72,7 @@
function startEdit(tag: Tag) {
editingTag = tag;
editName = tag.name;
editColor = tag.color ?? '#6366f1';
editColor = tag.color ?? '#e8754f';
}
async function saveEdit() {
@@ -115,13 +116,9 @@
<div>
<div class="mb-6 flex items-center justify-between">
<h2 class="text-xl font-bold text-card-foreground">Tag Management</h2>
<button
type="button"
onclick={() => (showCreateForm = !showCreateForm)}
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
<Button onclick={() => (showCreateForm = !showCreateForm)}>
{showCreateForm ? 'Cancel' : 'New Tag'}
</button>
</Button>
</div>
{#if error}
@@ -132,7 +129,7 @@
<!-- Create Form -->
{#if showCreateForm}
<div class="mb-6 rounded-lg border border-border bg-card p-4">
<div class="cozy-expand mb-6 rounded-xl border border-border bg-card p-4">
<form onsubmit={(e) => { e.preventDefault(); createTag(); }} class="flex flex-wrap items-end gap-3">
<div>
<label for="tag-name" class="mb-1 block text-sm font-medium text-foreground">Name</label>
@@ -141,7 +138,7 @@
type="text"
bind:value={newName}
placeholder="Tag name"
class="rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
class="rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
required
/>
</div>
@@ -157,12 +154,7 @@
<span class="text-xs text-muted-foreground">{newColor}</span>
</div>
</div>
<button
type="submit"
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Create Tag
</button>
<Button type="submit">Create Tag</Button>
</form>
</div>
{/if}
+1 -1
View File
@@ -37,7 +37,7 @@
let selectedGroupId = $state('');
</script>
<div class="overflow-x-auto rounded-lg border border-border">
<div class="overflow-x-auto rounded-2xl border border-border bg-card shadow-[var(--shadow-soft)]">
<table class="w-full text-left text-sm">
<thead class="border-b border-border bg-muted/50">
<tr>
@@ -126,4 +126,13 @@
.status-ring-unknown {
animation: ring-rotate-dash 8s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.status-ring-online,
.status-ring-offline,
.status-ring-degraded,
.status-ring-unknown {
animation: none;
}
}
</style>
+27 -11
View File
@@ -58,6 +58,14 @@
});
}
// Cozy "room" pastel tint — stable per app, derived from its name
const roomTints = ['var(--room-terra)', 'var(--room-sky)', 'var(--room-sage)', 'var(--room-butter)', 'var(--room-lav)', 'var(--room-peach)'];
const tint = $derived.by(() => {
let h = 0;
for (const ch of app.name) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
return roomTints[h % roomTints.length];
});
const iconDisplay = $derived.by(() => {
if (!app.icon) return null;
@@ -82,32 +90,39 @@
tabindex="0"
onclick={() => window.open(app.url, '_blank', 'noopener,noreferrer')}
onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') window.open(app.url, '_blank', 'noopener,noreferrer'); }}
class="card-hover group flex cursor-pointer flex-col rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/50"
class="card-hover group relative flex cursor-pointer flex-col overflow-hidden rounded-[1.4rem] border border-border bg-card p-5 shadow-[var(--shadow-soft)]"
title={app.description ?? app.name}
>
<!-- soft blob accent -->
<span
class="pointer-events-none absolute -right-10 -top-12 h-28 w-28 rounded-full opacity-40 blur-2xl transition-opacity duration-300 group-hover:opacity-70"
style="background: {tint};"
aria-hidden="true"
></span>
<div class="mb-3 flex items-start justify-between">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-muted text-lg text-muted-foreground"
class="flex h-12 w-12 items-center justify-center rounded-2xl text-lg"
style="background: color-mix(in srgb, {tint} 18%, transparent); color: {tint};"
>
{#if iconDisplay?.kind === 'emoji'}
<span class="text-xl">{iconDisplay.value}</span>
<span class="text-2xl">{iconDisplay.value}</span>
{:else if iconDisplay?.kind === 'image'}
<img
src={iconDisplay.src}
alt="{app.name} icon"
class="h-6 w-6 rounded object-contain"
class="h-7 w-7 rounded-lg object-contain"
/>
{:else if iconDisplay?.kind === 'text'}
<span class="text-xs font-medium">{iconDisplay.value}</span>
<span class="text-sm font-bold">{iconDisplay.value}</span>
{:else}
<span class="text-xs font-bold">{app.name.charAt(0).toUpperCase()}</span>
<span class="text-sm font-bold">{app.name.charAt(0).toUpperCase()}</span>
{/if}
</div>
<div class="flex items-center gap-1.5">
<a
href="/apps/{app.id}/edit"
onclick={(e: MouseEvent) => e.stopPropagation()}
class="rounded-md p-1 text-muted-foreground opacity-0 transition-all hover:bg-accent hover:text-foreground group-hover:opacity-100"
class="rounded-xl p-1.5 text-muted-foreground opacity-0 transition-all hover:bg-accent hover:text-foreground group-hover:opacity-100"
title={$t('app.edit')}
>
<svg
@@ -128,12 +143,12 @@
</div>
</div>
<h3 class="truncate text-sm font-semibold text-card-foreground transition-colors group-hover:text-primary">
<h3 class="truncate font-display text-base font-semibold text-card-foreground transition-colors group-hover:text-primary">
{app.name}
</h3>
{#if app.description}
<p class="mt-1 line-clamp-2 text-xs text-muted-foreground">{app.description}</p>
<p class="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">{app.description}</p>
{/if}
<!-- Sparkline -->
@@ -143,14 +158,15 @@
<div class="mt-2 flex items-center gap-1.5">
<SparklineChart data={historyData} />
{#if uptimePercent !== null}
<span class="text-[10px] text-muted-foreground">{uptimePercent}% {$t('app.uptime')}</span>
<span class="text-[11px] font-medium text-muted-foreground">{uptimePercent}% {$t('app.uptime')}</span>
{/if}
</div>
{/if}
{#if app.category}
<span
class="mt-2 inline-block self-start rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"
class="mt-3 inline-block self-start rounded-full px-2.5 py-0.5 text-[11px] font-semibold"
style="background: color-mix(in srgb, {tint} 18%, transparent); color: color-mix(in srgb, {tint} 68%, var(--foreground));"
>
{app.category}
</span>
+52 -30
View File
@@ -10,6 +10,8 @@
import TagsInput from '$lib/components/ui/TagsInput.svelte';
import IconGrid from '$lib/components/ui/IconGrid.svelte';
import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte';
import Switch from '$lib/components/ui/Switch.svelte';
import Button from '$lib/components/ui/Button.svelte';
type AppSchema = z.infer<typeof createAppSchema>;
@@ -121,7 +123,7 @@
name="name"
type="text"
bind:value={$form.name}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
placeholder={$t('app.name_placeholder')}
/>
{#if $errors.name}
@@ -138,7 +140,7 @@
name="url"
type="url"
bind:value={$form.url}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
placeholder={$t('app.url_placeholder')}
/>
{#if $errors.url}
@@ -170,7 +172,7 @@
name="description"
type="text"
bind:value={$form.description}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
placeholder={$t('app.description_placeholder')}
/>
</div>
@@ -186,7 +188,7 @@
bind:value={$form.category}
suggestions={categorySuggestions}
placeholder={$t('app.category_placeholder')}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
/>
</div>
@@ -200,7 +202,7 @@
bind:value={$form.tags}
suggestions={tagSuggestions}
placeholder={$t('app.tags_placeholder')}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
/>
</div>
</div>
@@ -219,22 +221,34 @@
<button
type="button"
onclick={() => (showAdvanced = !showAdvanced)}
class="text-sm text-muted-foreground hover:text-foreground"
aria-expanded={showAdvanced}
class="group inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<svg
class="h-3.5 w-3.5 transition-transform duration-200 {showAdvanced ? 'rotate-90' : ''}"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="6 4 10 8 6 12" />
</svg>
{showAdvanced ? $t('app.healthcheck_hide') : $t('app.healthcheck_show')} {$t('app.healthcheck_toggle')}
</button>
{#if showAdvanced}
<div class="space-y-4 rounded-md border border-border p-4">
<div class="flex items-center gap-2">
<input
<div class="cozy-expand space-y-4 rounded-xl border border-border p-4">
<div class="flex items-center gap-3">
<Switch
id="healthcheckEnabled"
name="healthcheckEnabled"
type="checkbox"
bind:checked={$form.healthcheckEnabled}
class="rounded border-input"
ariaLabelledby="healthcheckEnabledLabel"
/>
<label for="healthcheckEnabled" class="text-sm text-card-foreground">
<label id="healthcheckEnabledLabel" for="healthcheckEnabled" class="cursor-pointer text-sm text-card-foreground">
{$t('app.healthcheck_enabled')}
</label>
</div>
@@ -269,7 +283,7 @@
name="healthcheckExpectedStatus"
type="number"
bind:value={$form.healthcheckExpectedStatus}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
min="100"
max="599"
/>
@@ -287,7 +301,7 @@
name="healthcheckTimeout"
type="number"
bind:value={$form.healthcheckTimeout}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
min="1000"
max="30000"
step="1000"
@@ -307,7 +321,7 @@
name="healthcheckInterval"
type="number"
bind:value={$form.healthcheckInterval}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
min="30"
max="86400"
/>
@@ -320,22 +334,34 @@
<button
type="button"
onclick={() => (showIntegration = !showIntegration)}
class="text-sm text-muted-foreground hover:text-foreground"
aria-expanded={showIntegration}
class="group inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<svg
class="h-3.5 w-3.5 transition-transform duration-200 {showIntegration ? 'rotate-90' : ''}"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="6 4 10 8 6 12" />
</svg>
{showIntegration ? 'Hide' : 'Show'} Integration Settings
</button>
{#if showIntegration}
<div class="space-y-4 rounded-md border border-border p-4">
<div class="flex items-center gap-2">
<input
<div class="cozy-expand space-y-4 rounded-xl border border-border p-4">
<div class="flex items-center gap-3">
<Switch
id="integrationEnabled"
name="integrationEnabled"
type="checkbox"
bind:checked={$form.integrationEnabled}
class="rounded border-input"
ariaLabelledby="integrationEnabledLabel"
/>
<label for="integrationEnabled" class="text-sm text-card-foreground">
<label id="integrationEnabledLabel" for="integrationEnabled" class="cursor-pointer text-sm text-card-foreground">
Enable Integration
</label>
</div>
@@ -349,7 +375,7 @@
id="integrationType"
name="integrationType"
bind:value={$form.integrationType}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
>
<option value="">None</option>
{#each availableIntegrations as integration (integration.id)}
@@ -395,7 +421,7 @@
{testingConnection ? 'Testing...' : 'Test Connection'}
</button>
{#if testResult}
<span class="text-sm {testResult.success ? 'text-green-500' : 'text-destructive'}">
<span class="text-sm {testResult.success ? 'text-status-online-ink' : 'text-destructive'}">
{testResult.message}
</span>
{/if}
@@ -409,16 +435,12 @@
{/if}
<div class="flex justify-end">
<button
type="submit"
disabled={$submitting}
class="rounded-md bg-primary px-6 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
<Button type="submit" size="lg" disabled={$submitting} loading={$submitting}>
{#if $submitting}
{$t('app.saving')}
{:else}
{mode === 'edit' ? $t('app.update') : $t('app.save')}
{/if}
</button>
</Button>
</div>
</form>
+16 -7
View File
@@ -10,18 +10,27 @@
const config = $derived.by(() => {
switch (status) {
case 'online':
return { color: 'bg-green-500', cssClass: 'status-online', textKey: 'status.online' };
return { color: 'var(--status-online)', ink: 'var(--status-online-ink)', cssClass: 'status-online', textKey: 'status.online' };
case 'offline':
return { color: 'bg-red-500', cssClass: '', textKey: 'status.offline' };
return { color: 'var(--status-offline)', ink: 'var(--status-offline-ink)', cssClass: 'status-offline', textKey: 'status.offline' };
case 'degraded':
return { color: 'bg-yellow-500', cssClass: '', textKey: 'status.degraded' };
return { color: 'var(--status-degraded)', ink: 'var(--status-degraded-ink)', cssClass: 'status-degraded', textKey: 'status.degraded' };
default:
return { color: 'bg-gray-500', cssClass: '', textKey: 'status.unknown' };
return { color: 'var(--status-unknown)', ink: 'var(--status-unknown-ink)', cssClass: '', textKey: 'status.unknown' };
}
});
</script>
<span class="inline-flex items-center gap-1.5 text-xs">
<span class="inline-block h-2 w-2 rounded-full {config.color} {config.cssClass}"></span>
<span class="text-muted-foreground">{$t(config.textKey)}</span>
<span
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold"
style="color: {config.ink}; background: color-mix(in srgb, {config.color} 14%, transparent);"
role="status"
aria-live="polite"
>
<span
class="inline-block h-2 w-2 rounded-full {config.cssClass}"
style="background: {config.color};"
aria-hidden="true"
></span>
<span>{$t(config.textKey)}</span>
</span>
+1 -1
View File
@@ -70,7 +70,7 @@
: iconType === 'url'
? $t('app.icon_url_placeholder')
: $t('app.icon_emoji_placeholder')}
class="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
class="flex-1 rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
/>
{/if}
</div>
+7 -11
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { dndzone } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import Button from '$lib/components/ui/Button.svelte';
interface LinkItem {
id: string;
@@ -164,13 +165,13 @@
type="text"
bind:value={newLabel}
placeholder="Link label"
class="rounded-md border border-input bg-background px-2 py-1.5 text-sm text-foreground"
class="rounded-xl border border-input bg-background px-2 py-1.5 text-sm text-foreground"
/>
<input
type="url"
bind:value={newUrl}
placeholder="https://..."
class="rounded-md border border-input bg-background px-2 py-1.5 text-sm text-foreground"
class="rounded-xl border border-input bg-background px-2 py-1.5 text-sm text-foreground"
/>
</div>
<div class="mt-2 flex items-center gap-2">
@@ -178,13 +179,13 @@
type="text"
bind:value={newIcon}
placeholder="Icon (optional)"
class="flex-1 rounded-md border border-input bg-background px-2 py-1.5 text-sm text-foreground"
class="flex-1 rounded-xl border border-input bg-background px-2 py-1.5 text-sm text-foreground"
/>
<button
type="button"
onclick={addLink}
disabled={!newLabel.trim() || !newUrl.trim()}
class="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
class="rounded-xl bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Add
</button>
@@ -192,12 +193,7 @@
</div>
<!-- Save Button -->
<button
type="button"
onclick={saveLinks}
disabled={saving}
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<Button onclick={saveLinks} disabled={saving} loading={saving}>
{saving ? 'Saving...' : 'Save Links'}
</button>
</Button>
</div>
+2 -2
View File
@@ -21,8 +21,8 @@
const statusColor = $derived(() => {
if (!result) return '';
if (result.error) return 'text-destructive';
if (result.status >= 200 && result.status < 300) return 'text-green-500';
if (result.status >= 300 && result.status < 400) return 'text-yellow-500';
if (result.status >= 200 && result.status < 300) return 'text-status-online-ink';
if (result.status >= 300 && result.status < 400) return 'text-status-degraded-ink';
return 'text-destructive';
});
@@ -1,5 +1,6 @@
<script lang="ts">
import type { IntegrationFieldDescriptor } from '$lib/server/integrations/types.js';
import Switch from '$lib/components/ui/Switch.svelte';
interface Props {
fields: IntegrationFieldDescriptor[];
@@ -10,7 +11,7 @@
let { fields, values, onchange, idPrefix = 'int' }: Props = $props();
const inputClass = 'w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30';
const inputClass = 'w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30';
</script>
<div class="space-y-3">
@@ -23,16 +24,15 @@
{/if}
</label>
{#if field.type === 'boolean'}
<label class="flex items-center gap-2">
<input
<div class="flex items-center gap-3">
<Switch
id="{idPrefix}-{field.name}"
type="checkbox"
checked={!!values[field.name]}
onchange={(e) => onchange(field.name, e.currentTarget.checked)}
class="h-4 w-4 rounded border-input accent-primary"
onchange={(checked) => onchange(field.name, checked)}
ariaLabel={field.label}
/>
<span class="text-sm text-muted-foreground">{field.description ?? ''}</span>
</label>
</div>
{:else if field.type === 'number'}
<input
id="{idPrefix}-{field.name}"
+4 -4
View File
@@ -13,10 +13,10 @@
let { data, width = 80, height = 20 }: Props = $props();
const STATUS_COLORS: Record<string, string> = {
online: '#22c55e',
offline: '#ef4444',
degraded: '#eab308',
unknown: '#6b7280'
online: 'var(--status-online)',
offline: 'var(--status-offline)',
degraded: 'var(--status-degraded)',
unknown: 'var(--status-unknown)'
};
const barWidth = $derived(data.length > 0 ? Math.max(1, (width - 2) / data.length) : 1);
@@ -1,5 +1,6 @@
<script lang="ts">
import { theme } from '$lib/stores/theme.svelte.js';
import CozyAmbient from './CozyAmbient.svelte';
import MeshGradient from './MeshGradient.svelte';
import ParticleField from './ParticleField.svelte';
import AuroraEffect from './AuroraEffect.svelte';
@@ -16,7 +17,9 @@
{#if theme.backgroundType !== 'none'}
<div class="pointer-events-none fixed inset-0 z-0 overflow-hidden" aria-hidden="true">
{#if theme.backgroundType === 'mesh'}
{#if theme.backgroundType === 'cozy'}
<CozyAmbient />
{:else if theme.backgroundType === 'mesh'}
<MeshGradient />
{:else if theme.backgroundType === 'particles'}
<ParticleField />
@@ -0,0 +1,23 @@
<!--
Cozy Home ambient backdrop — static, soft warm-corner radial gradients.
Calm "lit room" atmosphere (no animation), retints with the accent hue.
-->
<div class="cozy-ambient absolute inset-0"></div>
<style>
.cozy-ambient {
background:
radial-gradient(50% 42% at 12% 0%, color-mix(in srgb, var(--room-peach) 26%, transparent), transparent 70%),
radial-gradient(45% 40% at 95% 6%, color-mix(in srgb, var(--room-sky) 22%, transparent), transparent 70%),
radial-gradient(52% 46% at 85% 100%, color-mix(in srgb, var(--room-sage) 20%, transparent), transparent 72%),
radial-gradient(46% 42% at 8% 96%, color-mix(in srgb, var(--room-lav) 16%, transparent), transparent 72%);
}
:global(.dark) .cozy-ambient {
background:
radial-gradient(52% 44% at 12% 0%, color-mix(in srgb, var(--room-terra) 20%, transparent), transparent 70%),
radial-gradient(46% 42% at 95% 6%, color-mix(in srgb, var(--room-sky) 16%, transparent), transparent 70%),
radial-gradient(54% 48% at 85% 100%, color-mix(in srgb, var(--room-sage) 14%, transparent), transparent 72%);
opacity: 0.7;
}
</style>
@@ -42,6 +42,13 @@
$effect(() => {
blobs = initBlobs();
// Respect reduced-motion: render a static mesh, skip the rAF loop.
const prefersReducedMotion =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) return;
animFrame = requestAnimationFrame(animate);
return () => {
@@ -34,21 +34,21 @@
}
</script>
<div class="flex items-center gap-2 rounded-xl border border-border bg-card p-3 shadow-sm">
<div class="flex items-center gap-2 rounded-xl border border-border bg-card p-3 shadow-[var(--shadow-soft)]">
<input
bind:this={inputEl}
type="text"
bind:value={title}
onkeydown={handleKeydown}
placeholder={$t('board.section_title') ?? 'Section title...'}
class="flex-1 rounded-lg border border-input bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
class="flex-1 rounded-xl border border-input bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
/>
<IconPickerButton value={icon} onchange={(v) => { icon = v; }} size="sm" />
<button
type="button"
onclick={handleSubmit}
disabled={!title.trim()}
class="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
class="rounded-xl bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
{$t('common.add') ?? 'Add'}
</button>
@@ -105,7 +105,7 @@
<div class="space-y-4">
<!-- Grant form -->
<div class="rounded-lg border border-border bg-card p-4">
<div class="rounded-xl border border-border bg-card p-4">
<h3 class="mb-3 text-sm font-semibold text-card-foreground">{$t('board.access_grant')}</h3>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-4">
<div>
@@ -131,7 +131,7 @@
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground"
/>
{#if searchQuery.length > 0 && filteredTargetOptions.length > 0}
<div class="absolute z-10 mt-1 max-h-40 w-full overflow-y-auto rounded border border-border bg-card shadow-lg">
<div class="absolute z-10 mt-1 max-h-40 w-full overflow-y-auto rounded border border-border bg-card shadow-[var(--shadow-soft)]">
{#each filteredTargetOptions as option (option.id)}
<button
type="button"
@@ -190,7 +190,7 @@
{#if loading}
<p class="text-sm text-muted-foreground">{$t('board.access_loading')}</p>
{:else if permissions.length > 0}
<div class="overflow-x-auto rounded-lg border border-border">
<div class="overflow-x-auto rounded-2xl border border-border bg-card shadow-[var(--shadow-soft)]">
<table class="w-full text-left text-sm">
<thead class="border-b border-border bg-muted/50">
<tr>
+30 -9
View File
@@ -20,32 +20,53 @@
let { board }: Props = $props();
const sectionCount = $derived(board._count?.sections ?? 0);
// Stable per-board pastel "room" tint derived from the name
const roomTints = ['var(--room-terra)', 'var(--room-sky)', 'var(--room-sage)', 'var(--room-butter)', 'var(--room-lav)', 'var(--room-peach)'];
const tint = $derived.by(() => {
let h = 0;
for (const ch of board.name) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
return roomTints[h % roomTints.length];
});
</script>
<a
href="/boards/{board.id}"
class="card-hover group block rounded-xl border border-border bg-card p-5 transition-colors hover:border-primary/50"
class="card-hover group relative block overflow-hidden rounded-[1.4rem] border border-border bg-card p-5 shadow-[var(--shadow-soft)]"
>
<div class="flex items-start gap-3">
<span
class="pointer-events-none absolute -right-10 -top-12 h-28 w-28 rounded-full opacity-40 blur-2xl transition-opacity duration-300 group-hover:opacity-70"
style="background: {tint};"
aria-hidden="true"
></span>
<div class="flex items-start gap-3.5">
{#if board.icon}
<span
class="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl"
style="background: color-mix(in srgb, {tint} 18%, transparent); color: {tint};"
>
<DynamicIcon name={board.icon} size={22} />
</span>
{:else}
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-muted text-sm text-muted-foreground">
B
<span
class="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl text-sm font-bold text-white"
style="background: {tint};"
>
{board.name.charAt(0).toUpperCase()}
</span>
{/if}
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h3 class="truncate font-semibold text-foreground transition-colors group-hover:text-primary">
<h3 class="truncate font-display text-base font-semibold text-foreground transition-colors group-hover:text-primary">
{board.name}
</h3>
{#if board.isDefault}
<span class="shrink-0 rounded bg-primary/15 px-1.5 py-0.5 text-xs text-primary">
<span class="shrink-0 rounded-full bg-primary/15 px-2 py-0.5 text-xs font-semibold text-primary">
{$t('board.default')}
</span>
{/if}
{#if board.isGuestAccessible}
<span class="shrink-0 flex items-center gap-1 rounded bg-accent px-1.5 py-0.5 text-xs text-accent-foreground" title={$t('board.guest_accessible')}>
<span class="shrink-0 flex items-center gap-1 rounded-full bg-accent px-2 py-0.5 text-xs font-medium text-accent-foreground" title={$t('board.guest_accessible')}>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
@@ -54,7 +75,7 @@
{$t('board.guest')}
</span>
{:else}
<span class="shrink-0 flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground" title={$t('board.access_private')}>
<span class="shrink-0 flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground" title={$t('board.access_private')}>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
@@ -62,7 +83,7 @@
</span>
{/if}
{#if board.hasSharedPermissions}
<span class="shrink-0 flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-xs text-blue-500" title={$t('board.access_shared')}>
<span class="shrink-0 flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium" style="background: color-mix(in srgb, var(--room-sky) 18%, transparent); color: color-mix(in srgb, var(--room-sky) 70%, var(--foreground));" title={$t('board.access_shared')}>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
+14 -9
View File
@@ -29,13 +29,18 @@
}
</script>
<div class="mb-6 flex items-start justify-between">
<div class="flex items-center gap-3">
<div class="mb-6 flex items-start justify-between gap-4">
<div class="flex items-center gap-3.5">
{#if icon}
<DynamicIcon name={icon} size={28} />
<span
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl text-primary shadow-[var(--shadow-soft)]"
style="background: color-mix(in srgb, var(--primary) 14%, transparent);"
>
<DynamicIcon name={icon} size={26} />
</span>
{/if}
<div>
<h1 class="text-3xl font-bold text-foreground">{name}</h1>
<h1 class="font-display text-3xl font-semibold text-foreground">{name}</h1>
{#if description}
<p class="mt-1 text-sm text-muted-foreground">{description}</p>
{/if}
@@ -45,7 +50,7 @@
<div class="flex items-center gap-2">
<a
href="/boards"
class="rounded-lg border border-border px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent"
class="rounded-xl border border-border bg-card px-3.5 py-2 text-sm font-medium text-foreground shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5 hover:border-primary/40"
>
{$t('board.all_boards')}
</a>
@@ -53,7 +58,7 @@
<button
type="button"
onclick={onShare}
class="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent"
class="flex items-center gap-1.5 rounded-xl border border-border bg-card px-3.5 py-2 text-sm font-medium text-foreground shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5 hover:border-primary/40"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="18" cy="5" r="3" />
@@ -69,9 +74,9 @@
<button
type="button"
onclick={handleEditToggle}
class="flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors {editMode.active
? 'bg-primary text-primary-foreground ring-2 ring-primary/30'
: 'bg-primary text-primary-foreground hover:bg-primary/90'}"
class="flex items-center gap-1.5 rounded-xl px-3.5 py-2 text-sm font-semibold text-primary-foreground shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5 {editMode.active
? 'bg-primary ring-2 ring-primary/30'
: 'bg-primary hover:shadow-[var(--shadow-lift)]'}"
>
{#if editMode.active}
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -3,6 +3,9 @@
import { editMode } from '$lib/stores/editMode.svelte.js';
import { fade, fly } from 'svelte/transition';
import IconPickerButton from '$lib/components/ui/IconPickerButton.svelte';
import Slider from '$lib/components/ui/Slider.svelte';
import Select from '$lib/components/ui/Select.svelte';
import Button from '$lib/components/ui/Button.svelte';
interface BoardData {
id: string;
@@ -82,7 +85,7 @@
<!-- Side panel -->
<div
class="fixed right-0 top-0 z-50 flex h-full w-full max-w-md flex-col border-l border-border bg-card shadow-2xl"
class="fixed right-0 top-0 z-50 flex h-full w-full max-w-md flex-col border-l border-border bg-card shadow-[var(--shadow-lift)]"
transition:fly={{ x: 400, duration: 250 }}
>
<!-- Header -->
@@ -107,7 +110,7 @@
<div>
<label for="bp-name" class="mb-1 block text-sm font-medium text-foreground">{$t('common.name') ?? 'Name'}</label>
<input id="bp-name" type="text" bind:value={name}
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30" />
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30" />
</div>
<!-- Icon -->
@@ -121,53 +124,82 @@
<div>
<label for="bp-desc" class="mb-1 block text-sm font-medium text-foreground">{$t('common.description') ?? 'Description'}</label>
<textarea id="bp-desc" rows="2" bind:value={description}
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"></textarea>
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"></textarea>
</div>
<!-- Theme preview swatch -->
<div class="flex items-center gap-3 rounded-xl border border-border bg-muted/30 p-3">
<span
class="h-12 w-12 shrink-0 rounded-2xl shadow-[var(--shadow-soft)]"
style="background: hsl({themeHue} {themeSaturation}% 56%);"
aria-hidden="true"
></span>
<div class="flex-1">
<p class="text-xs font-medium text-foreground">{$t('board.theme_preview') ?? 'Theme preview'}</p>
<p class="font-mono text-xs text-muted-foreground">hsl({themeHue}°, {themeSaturation}%, 56%)</p>
</div>
<button
type="button"
class="rounded-xl px-3 py-1.5 text-xs font-medium text-primary-foreground shadow-[var(--shadow-soft)]"
style="background: hsl({themeHue} {themeSaturation}% 56%);"
tabindex="-1"
aria-hidden="true"
>
{$t('common.sample') ?? 'Sample'}
</button>
</div>
<!-- Theme Hue -->
<div>
<label for="bp-hue" class="mb-1 block text-sm font-medium text-foreground">{$t('board.theme_hue') ?? 'Theme Hue'}</label>
<input id="bp-hue" type="range" min="0" max="360" bind:value={themeHue}
class="w-full accent-primary" />
<span class="text-xs text-muted-foreground">{themeHue}°</span>
<label for="bp-hue" class="mb-1 flex items-center justify-between text-sm font-medium text-foreground">
<span>{$t('board.theme_hue') ?? 'Theme Hue'}</span>
<span class="tabular-nums text-xs text-muted-foreground">{themeHue}°</span>
</label>
<Slider id="bp-hue" min={0} max={360} bind:value={themeHue} ariaLabel={$t('board.theme_hue') ?? 'Theme Hue'} />
</div>
<!-- Theme Saturation -->
<div>
<label for="bp-sat" class="mb-1 block text-sm font-medium text-foreground">{$t('board.theme_saturation') ?? 'Saturation'}</label>
<input id="bp-sat" type="range" min="0" max="100" bind:value={themeSaturation}
class="w-full accent-primary" />
<span class="text-xs text-muted-foreground">{themeSaturation}%</span>
<label for="bp-sat" class="mb-1 flex items-center justify-between text-sm font-medium text-foreground">
<span>{$t('board.theme_saturation') ?? 'Saturation'}</span>
<span class="tabular-nums text-xs text-muted-foreground">{themeSaturation}%</span>
</label>
<Slider id="bp-sat" min={0} max={100} bind:value={themeSaturation} ariaLabel={$t('board.theme_saturation') ?? 'Saturation'} />
</div>
<!-- Background Type -->
<div>
<label for="bp-bg" class="mb-1 block text-sm font-medium text-foreground">{$t('board.background') ?? 'Background'}</label>
<select id="bp-bg" bind:value={backgroundType}
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30">
<Select id="bp-bg" bind:value={backgroundType}>
<option value="none">None</option>
<option value="mesh">Mesh Gradient</option>
<option value="particles">Particles</option>
<option value="aurora">Aurora</option>
<option value="wallpaper">Wallpaper</option>
</select>
</Select>
</div>
<!-- Wallpaper settings (conditional) -->
{#if backgroundType === 'wallpaper'}
<div class="space-y-3 rounded-lg border border-border bg-background/50 p-3">
<div class="cozy-expand space-y-3 rounded-xl border border-border bg-background/50 p-3">
<div>
<label for="bp-wp-url" class="mb-1 block text-sm font-medium text-foreground">Wallpaper URL</label>
<input id="bp-wp-url" type="text" bind:value={wallpaperUrl} placeholder="https://..."
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30" />
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30" />
</div>
<div>
<label for="bp-wp-blur" class="mb-1 block text-sm font-medium text-foreground">Blur ({wallpaperBlur}px)</label>
<input id="bp-wp-blur" type="range" min="0" max="20" bind:value={wallpaperBlur} class="w-full accent-primary" />
<label for="bp-wp-blur" class="mb-1 flex items-center justify-between text-sm font-medium text-foreground">
<span>Blur</span>
<span class="tabular-nums text-xs text-muted-foreground">{wallpaperBlur}px</span>
</label>
<Slider id="bp-wp-blur" min={0} max={20} bind:value={wallpaperBlur} ariaLabel="Blur" />
</div>
<div>
<label for="bp-wp-overlay" class="mb-1 block text-sm font-medium text-foreground">Overlay ({Math.round(wallpaperOverlay * 100)}%)</label>
<input id="bp-wp-overlay" type="range" min="0" max="1" step="0.05" bind:value={wallpaperOverlay} class="w-full accent-primary" />
<label for="bp-wp-overlay" class="mb-1 flex items-center justify-between text-sm font-medium text-foreground">
<span>Overlay</span>
<span class="tabular-nums text-xs text-muted-foreground">{Math.round(wallpaperOverlay * 100)}%</span>
</label>
<Slider id="bp-wp-overlay" min={0} max={1} step={0.05} bind:value={wallpaperOverlay} ariaLabel="Overlay" />
</div>
</div>
{/if}
@@ -175,38 +207,29 @@
<!-- Card Size -->
<div>
<label for="bp-cardsize" class="mb-1 block text-sm font-medium text-foreground">{$t('board.card_size') ?? 'Card Size'}</label>
<select id="bp-cardsize" bind:value={cardSize}
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30">
<Select id="bp-cardsize" bind:value={cardSize}>
<option value="compact">Compact</option>
<option value="medium">Medium</option>
<option value="large">Large</option>
</select>
</Select>
</div>
<!-- Custom CSS -->
<div>
<label for="bp-css" class="mb-1 block text-sm font-medium text-foreground">{$t('board.custom_css') ?? 'Custom CSS'}</label>
<textarea id="bp-css" rows="4" bind:value={customCss} placeholder={'.board { ... }'}
class="w-full rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"></textarea>
class="w-full rounded-xl border border-input bg-background px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"></textarea>
</div>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-2 border-t border-border px-5 py-3">
<button
type="button"
onclick={onClose}
class="rounded-lg border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-accent"
>
<Button variant="outline" onclick={onClose}>
{$t('common.cancel') ?? 'Cancel'}
</button>
<button
type="button"
onclick={handleSave}
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
</Button>
<Button variant="primary" onclick={handleSave}>
{$t('common.apply') ?? 'Apply'}
</button>
</Button>
</div>
</div>
@@ -1,6 +1,7 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { TargetType, PermissionLevel } from '$lib/utils/constants.js';
import Switch from '$lib/components/ui/Switch.svelte';
import {
loadBoardPermissions,
grantBoardPermission,
@@ -153,7 +154,7 @@
onclick={handleBackdropClick}
role="presentation"
>
<div class="mx-4 w-full max-w-lg rounded-xl border border-border bg-card p-6 shadow-xl" role="dialog" aria-modal="true">
<div class="mx-4 w-full max-w-lg rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-lift)]" role="dialog" aria-modal="true">
<!-- Header -->
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-card-foreground">
@@ -177,7 +178,7 @@
<button
type="button"
onclick={handleCopyLink}
class="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent"
class="flex items-center gap-2 rounded-xl border border-border px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
@@ -188,19 +189,18 @@
</div>
<!-- Guest access toggle -->
<div class="mb-4 rounded-lg border border-border bg-muted/30 p-3">
<label class="flex items-center gap-3 text-sm text-foreground">
<input
type="checkbox"
<div class="mb-4 rounded-xl border border-border bg-muted/30 p-3">
<div class="flex items-center gap-3 text-sm text-foreground">
<Switch
checked={isGuestAccessible}
onchange={(e) => onGuestToggle(e.currentTarget.checked)}
class="h-4 w-4 rounded border-input accent-primary"
onchange={onGuestToggle}
ariaLabel={$t('board.guest_accessible')}
/>
<div>
<span class="font-medium">{$t('board.guest_accessible')}</span>
<p class="text-xs text-muted-foreground">{$t('board.share_guest_description')}</p>
</div>
</label>
</div>
</div>
<!-- Quick add permission -->
@@ -210,7 +210,7 @@
<select
bind:value={selectedTargetType}
onchange={() => { selectedTargetId = ''; searchQuery = ''; }}
class="rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
class="rounded-xl border border-input bg-background px-2 py-1.5 text-sm text-foreground"
>
<option value={TargetType.USER}>{$t('admin.perm_user')}</option>
<option value={TargetType.GROUP}>{$t('admin.perm_group')}</option>
@@ -220,10 +220,10 @@
type="text"
bind:value={searchQuery}
placeholder={$t('board.access_search_placeholder')}
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground"
class="w-full rounded-xl border border-input bg-background px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground"
/>
{#if searchQuery.length > 0 && filteredTargetOptions.length > 0 && !selectedTargetId}
<div class="absolute z-10 mt-1 max-h-32 w-full overflow-y-auto rounded border border-border bg-card shadow-lg">
<div class="absolute z-10 mt-1 max-h-32 w-full overflow-y-auto rounded-xl border border-border bg-card shadow-[var(--shadow-soft)]">
{#each filteredTargetOptions as option (option.id)}
<button
type="button"
@@ -238,7 +238,7 @@
</div>
<select
bind:value={selectedLevel}
class="rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
class="rounded-xl border border-input bg-background px-2 py-1.5 text-sm text-foreground"
>
<option value={PermissionLevel.VIEW}>{$t('admin.perm_view')}</option>
<option value={PermissionLevel.EDIT}>{$t('admin.perm_edit')}</option>
@@ -248,7 +248,7 @@
type="button"
onclick={handleGrant}
disabled={!selectedTargetId}
class="shrink-0 rounded bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
class="shrink-0 rounded-xl bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{$t('common.add')}
</button>
+1 -1
View File
@@ -40,7 +40,7 @@
transition:fly={{ y: 60, duration: 250 }}
>
<!-- Toolbar pill -->
<div class="flex items-center gap-1 rounded-2xl border border-border bg-card/95 px-2 py-1.5 shadow-xl backdrop-blur-sm">
<div class="flex items-center gap-1 rounded-2xl border border-border bg-card/95 px-2 py-1.5 shadow-[var(--shadow-lift)] backdrop-blur-sm">
<!-- Save -->
<button
type="button"
@@ -136,7 +136,7 @@
onclick={() => selectTemplate(template.id)}
class="flex flex-col items-center gap-2 rounded-lg border-2 p-4 text-center transition-colors {selected === template.id ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'}"
>
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10">
{#if template.icon}
<DynamicIcon name={template.icon} size={20} />
{:else}
@@ -57,7 +57,7 @@
</script>
{#if favorites.hasFavorites}
<div class="mb-4 rounded-lg border border-border bg-card/50 px-3 py-2 backdrop-blur-sm">
<div class="mb-4 rounded-2xl border border-border bg-card/60 px-3 py-2.5 shadow-[var(--shadow-soft)] backdrop-blur-sm">
<div
class="flex flex-wrap items-center gap-2"
use:dndzone={{
@@ -75,7 +75,7 @@
href={item.app.url}
target="_blank"
rel="noopener noreferrer"
class="group relative flex items-center gap-1.5 rounded-md bg-muted/50 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
class="group relative flex items-center gap-1.5 rounded-xl bg-muted/60 px-3 py-1.5 text-xs font-semibold text-foreground transition-all hover:-translate-y-0.5 hover:bg-accent hover:text-accent-foreground hover:shadow-[var(--shadow-soft)]"
title={item.app.name}
oncontextmenu={(e) => handleRemove(e, item.appId)}
>
+15 -17
View File
@@ -8,6 +8,7 @@
import { ui } from '$lib/stores/ui.svelte.js';
import { theme, type BackgroundType } from '$lib/stores/theme.svelte.js';
import { goto } from '$app/navigation';
import { buttonClass } from '$lib/components/ui/Button.svelte';
interface Props {
user: { displayName: string; email: string; role: string; avatarUrl?: string | null } | null;
@@ -21,6 +22,7 @@
}
const bgOptions: { value: BackgroundType; labelKey: string }[] = [
{ value: 'cozy', labelKey: 'bg.cozy' },
{ value: 'mesh', labelKey: 'bg.mesh' },
{ value: 'particles', labelKey: 'bg.particles' },
{ value: 'aurora', labelKey: 'bg.aurora' },
@@ -29,14 +31,14 @@
</script>
<header
class="sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-border bg-background/80 px-4 backdrop-blur-sm"
class="sticky top-0 z-20 flex h-16 items-center gap-3 bg-background/70 px-5 backdrop-blur-md"
>
<!-- Mobile hamburger -->
{#if ui.isMobile}
<button
type="button"
onclick={() => ui.toggleSidebar()}
class="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
class="inline-flex items-center justify-center rounded-xl p-2.5 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label={$t('sidebar.toggle')}
>
<svg
@@ -64,7 +66,7 @@
<!-- Background selector -->
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
class="inline-flex items-center justify-center rounded-xl p-2.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title={$t('bg.title')}
aria-label={$t('bg.aria_label')}
>
@@ -84,13 +86,13 @@
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
class="z-50 w-44 rounded-md border border-border bg-popover p-1 shadow-lg"
class="z-50 w-44 rounded-2xl border border-border bg-popover p-1.5 shadow-[var(--shadow-lift)]"
sideOffset={4}
align="end"
>
{#each bgOptions as opt (opt.value)}
<DropdownMenu.Item
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition-colors {theme.backgroundType === opt.value
class="flex w-full cursor-pointer items-center gap-2 rounded-xl px-2.5 py-2 text-sm transition-colors {theme.backgroundType === opt.value
? 'bg-accent text-accent-foreground'
: 'text-popover-foreground hover:bg-accent/50'}"
onSelect={() => theme.setBackground(opt.value)}
@@ -131,10 +133,11 @@
{#if user}
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
class="flex items-center gap-2.5 rounded-2xl px-2 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<span
class="flex h-7 w-7 items-center justify-center rounded-full bg-primary text-xs font-medium text-primary-foreground"
class="flex h-8 w-8 items-center justify-center rounded-xl text-xs font-bold text-white shadow-[var(--shadow-soft)]"
style="background: linear-gradient(135deg, var(--room-lav), var(--room-sky));"
>
{user.displayName.charAt(0).toUpperCase()}
</span>
@@ -144,7 +147,7 @@
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
class="z-50 w-48 rounded-md border border-border bg-popover p-1 shadow-lg"
class="z-50 w-48 rounded-2xl border border-border bg-popover p-1.5 shadow-[var(--shadow-lift)]"
sideOffset={4}
align="end"
>
@@ -154,7 +157,7 @@
</div>
<DropdownMenu.Item
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
class="flex w-full cursor-pointer items-center gap-2 rounded-xl px-3 py-2 text-sm text-popover-foreground transition-colors hover:bg-accent"
onSelect={() => goto('/settings')}
>
<svg
@@ -174,7 +177,7 @@
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
class="flex w-full cursor-pointer items-center gap-2 rounded-xl px-3 py-2 text-sm text-popover-foreground transition-colors hover:bg-accent"
onSelect={() => goto('/settings/api-tokens')}
>
<svg
@@ -197,7 +200,7 @@
<form method="POST" action="/auth/logout" id="logout-form" class="contents">
<DropdownMenu.Item
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
class="flex w-full cursor-pointer items-center gap-2 rounded-xl px-3 py-2 text-sm text-popover-foreground transition-colors hover:bg-accent"
onSelect={submitLogout}
>
<svg
@@ -221,11 +224,6 @@
</DropdownMenu.Portal>
</DropdownMenu.Root>
{:else}
<a
href="/login"
class="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{$t('auth.login')}
</a>
<a href="/login" class={buttonClass()}>{$t('auth.login')}</a>
{/if}
</header>
@@ -3,6 +3,7 @@
import { browser } from '$app/environment';
import { Download, X } from 'lucide-svelte';
import { fade } from 'svelte/transition';
import Button from '$lib/components/ui/Button.svelte';
const DISMISS_KEY = 'wal-install-prompt-dismissed';
@@ -67,10 +68,10 @@
{#if visible}
<div
transition:fade={{ duration: 200 }}
class="fixed bottom-4 left-4 right-4 z-50 mx-auto flex max-w-lg items-center gap-3 rounded-xl border border-border bg-card p-4 shadow-lg"
class="fixed bottom-4 left-4 right-4 z-50 mx-auto flex max-w-lg items-center gap-3 rounded-xl border border-border bg-card p-4 shadow-[var(--shadow-soft)]"
role="alert"
>
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10">
<Download class="h-5 w-5 text-primary" />
</div>
@@ -83,13 +84,9 @@
</p>
</div>
<button
type="button"
onclick={install}
class="shrink-0 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<Button onclick={install} class="shrink-0">
{$t('install.button')}
</button>
</Button>
<button
type="button"
@@ -12,7 +12,7 @@
<button
type="button"
onclick={toggleLocale}
class="inline-flex items-center justify-center rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
class="inline-flex h-10 w-10 items-center justify-center rounded-xl text-xs font-bold text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
title={$locale === 'ru' ? 'Switch to English' : 'Переключить на русский'}
>
{$locale === 'ru' ? 'RU' : 'EN'}
+82 -85
View File
@@ -24,83 +24,83 @@
function isActive(path: string): boolean {
return $page.url.pathname.startsWith(path);
}
// Cozy "room" accent palette — board chips rotate through these
const roomColors = [
'var(--room-terra)',
'var(--room-sky)',
'var(--room-sage)',
'var(--room-butter)',
'var(--room-lav)',
'var(--room-peach)'
];
</script>
<aside
class="flex h-full flex-col border-r border-sidebar-border bg-sidebar transition-all duration-200"
class="flex h-full flex-col bg-sidebar p-3 transition-all duration-200"
class:w-64={!collapsed}
class:w-16={collapsed}
class:w-[4.75rem]={collapsed}
>
<!-- Brand -->
<div class="flex h-14 items-center border-b border-sidebar-border px-4">
<div class="mb-5 flex h-12 items-center px-1.5 {collapsed ? 'justify-center' : 'gap-3'}">
<a href="/" class="flex items-center gap-3 text-sidebar-foreground" title={$t('app_name')}>
<span
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl text-primary-foreground shadow-[var(--shadow-soft)]"
style="background: linear-gradient(135deg, var(--room-peach), var(--room-terra));"
>
<svg
class="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="7" height="7" rx="2" />
<rect x="14" y="3" width="7" height="7" rx="2" />
<rect x="14" y="14" width="7" height="7" rx="2" />
<rect x="3" y="14" width="7" height="7" rx="2" />
</svg>
</span>
{#if !collapsed}
<a href="/" class="flex items-center gap-2 text-sidebar-foreground">
<svg
class="h-6 w-6 text-sidebar-primary"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
<span class="text-sm font-semibold">{$t('app_name')}</span>
</a>
{:else}
<a href="/" class="mx-auto text-sidebar-primary" title={$t('app_name')}>
<svg
class="h-6 w-6"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
</a>
<span class="leading-tight">
<span class="block font-display text-base font-semibold">{$t('app_name')}</span>
<span class="block text-[11px] font-medium text-sidebar-foreground/50">home cloud</span>
</span>
{/if}
</a>
</div>
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto px-2 py-3">
<nav class="flex flex-1 flex-col overflow-y-auto">
<!-- Main Links -->
<div class="mb-3">
<div class="mb-2">
{#if !collapsed}
<p class="mb-1 px-2 text-xs font-medium uppercase tracking-wider text-sidebar-foreground/50">
<p class="mb-1.5 px-3 text-[10.5px] font-bold uppercase tracking-[0.1em] text-sidebar-foreground/45">
{$t('nav.navigation')}
</p>
{/if}
<a
href="/boards"
class="flex items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors {isActive('/boards')
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'}"
class="flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-semibold transition-all {isActive('/boards')
? 'bg-card text-primary shadow-[var(--shadow-soft)] ring-1 ring-sidebar-border'
: 'text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-foreground'}"
title={collapsed ? $t('nav.boards') : undefined}
>
<svg
class="h-4 w-4 shrink-0"
class="h-5 w-5 shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-width="1.9"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<rect x="3" y="3" width="18" height="18" rx="4" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="21" x2="9" y2="9" />
</svg>
@@ -109,44 +109,42 @@
<a
href="/apps"
class="flex items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors {isActive('/apps')
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'}"
class="flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-semibold transition-all {isActive('/apps')
? 'bg-card text-primary shadow-[var(--shadow-soft)] ring-1 ring-sidebar-border'
: 'text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-foreground'}"
title={collapsed ? $t('nav.apps') : undefined}
>
<svg
class="h-4 w-4 shrink-0"
class="h-5 w-5 shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-width="1.9"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
/>
<circle cx="12" cy="12" r="9" />
<line x1="3" y1="12" x2="21" y2="12" />
<path d="M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18z" />
</svg>
{#if !collapsed}<span>{$t('nav.apps')}</span>{/if}
</a>
<a
href="/status"
class="flex items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors {isActive('/status')
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'}"
class="flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-semibold transition-all {isActive('/status')
? 'bg-card text-primary shadow-[var(--shadow-soft)] ring-1 ring-sidebar-border'
: 'text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-foreground'}"
title={collapsed ? 'Status Page' : undefined}
>
<svg
class="h-4 w-4 shrink-0"
class="h-5 w-5 shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-width="1.9"
stroke-linecap="round"
stroke-linejoin="round"
>
@@ -156,18 +154,18 @@
</a>
</div>
<!-- Board List -->
<!-- Board List ("Rooms") -->
{#if boards.length > 0}
<div class="mb-3">
<div class="mb-2 mt-1">
{#if !collapsed}
<button
type="button"
onclick={() => (boardsExpanded = !boardsExpanded)}
class="mb-1 flex w-full items-center justify-between px-2 text-xs font-medium uppercase tracking-wider text-sidebar-foreground/50 transition-colors hover:text-sidebar-foreground/80"
class="mb-1.5 flex w-full items-center justify-between px-3 text-[10.5px] font-bold uppercase tracking-[0.1em] text-sidebar-foreground/45 transition-colors hover:text-sidebar-foreground/70"
>
<span>{$t('nav.boards')}</span>
<svg
class="h-3 w-3 transition-transform duration-200"
class="h-3.5 w-3.5 transition-transform duration-200"
class:rotate-180={boardsExpanded}
viewBox="0 0 24 24"
fill="none"
@@ -182,13 +180,13 @@
{/if}
{#if boardsExpanded || collapsed}
<div class="max-h-48 overflow-y-auto">
{#each boards as board (board.id)}
<div class="max-h-56 overflow-y-auto">
{#each boards as board, i (board.id)}
<a
href="/boards/{board.id}"
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors {isActive(`/boards/${board.id}`)
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'}"
class="flex items-center gap-3 rounded-2xl px-3 py-2 text-sm font-medium transition-all {isActive(`/boards/${board.id}`)
? 'bg-card text-sidebar-foreground shadow-[var(--shadow-soft)] ring-1 ring-sidebar-border'
: 'text-sidebar-foreground/75 hover:bg-sidebar-accent hover:text-sidebar-foreground'}"
title={collapsed ? board.name : undefined}
onclick={() => ui.closeMobileSidebar()}
>
@@ -196,7 +194,8 @@
<span class="shrink-0"><DynamicIcon name={board.icon} size={18} /></span>
{:else}
<span
class="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-sidebar-accent text-[10px] font-medium text-sidebar-foreground"
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg text-[11px] font-bold text-white"
style="background: {roomColors[i % roomColors.length]};"
>
{board.name.charAt(0).toUpperCase()}
</span>
@@ -213,29 +212,27 @@
<!-- Admin -->
{#if isAdmin}
<div class="mt-auto border-t border-sidebar-border pt-3">
<div class="mt-auto pt-2">
{#if !collapsed}
<p
class="mb-1 px-2 text-xs font-medium uppercase tracking-wider text-sidebar-foreground/50"
>
<p class="mb-1.5 px-3 text-[10.5px] font-bold uppercase tracking-[0.1em] text-sidebar-foreground/45">
{$t('nav.admin')}
</p>
{/if}
<a
href="/admin/users"
class="flex items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors {isActive('/admin')
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'}"
class="flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-semibold transition-all {isActive('/admin')
? 'bg-card text-primary shadow-[var(--shadow-soft)] ring-1 ring-sidebar-border'
: 'text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-foreground'}"
title={collapsed ? $t('nav.admin_panel') : undefined}
>
<svg
class="h-4 w-4 shrink-0"
class="h-5 w-5 shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-width="1.9"
stroke-linecap="round"
stroke-linejoin="round"
>
@@ -252,11 +249,11 @@
<!-- Keyboard Shortcuts Hint + Collapse Toggle -->
{#if !ui.isMobile}
<div class="border-t border-sidebar-border p-2 flex items-center {collapsed ? 'flex-col gap-1' : 'gap-1'}">
<div class="mt-2 flex items-center {collapsed ? 'flex-col gap-1.5' : 'gap-1.5'}">
<button
type="button"
onclick={() => keyboard.toggleOverlay()}
class="flex items-center justify-center rounded-md p-2 text-sidebar-foreground/50 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
class="flex items-center justify-center rounded-xl p-2.5 text-sidebar-foreground/55 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
title="Keyboard Shortcuts (?)"
>
<svg
@@ -277,7 +274,7 @@
<button
type="button"
onclick={() => ui.toggleSidebar()}
class="flex w-full items-center justify-center rounded-md p-2 text-sidebar-foreground transition-colors hover:bg-sidebar-accent"
class="flex w-full items-center justify-center rounded-xl p-2.5 text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
title={collapsed ? $t('sidebar.expand') : $t('sidebar.collapse')}
>
<svg
+1 -1
View File
@@ -23,7 +23,7 @@
<button
type="button"
onclick={() => theme.cycleMode()}
class="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
class="inline-flex items-center justify-center rounded-xl p-2.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
title={$t('theme.title', { values: { mode: $t(currentIcon.labelKey) } })}
aria-label={$t('theme.toggle', { values: { mode: $t(currentIcon.labelKey) } })}
>
@@ -47,11 +47,11 @@
function eventColor(event: string): string {
switch (event) {
case 'app_online':
return 'text-green-500';
return 'text-status-online-ink';
case 'app_offline':
return 'text-red-500';
return 'text-status-offline-ink';
case 'app_degraded':
return 'text-yellow-500';
return 'text-status-degraded-ink';
default:
return 'text-muted-foreground';
}
@@ -64,7 +64,7 @@
<button
type="button"
onclick={() => (showDropdown = !showDropdown)}
class="relative inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
class="relative inline-flex items-center justify-center rounded-xl p-2.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Notifications"
aria-label="Notifications"
>
@@ -95,7 +95,7 @@
{#if showDropdown}
<div
class="absolute right-0 top-full z-50 mt-1 w-80 rounded-lg border border-border bg-popover shadow-lg"
class="absolute right-0 top-full z-50 mt-1 w-80 rounded-xl border border-border bg-popover shadow-[var(--shadow-soft)]"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-border px-4 py-3">
@@ -1,6 +1,8 @@
<script lang="ts">
import { Eye, EyeOff } from 'lucide-svelte';
import { NotificationType } from '$lib/utils/constants.js';
import Switch from '$lib/components/ui/Switch.svelte';
import Button from '$lib/components/ui/Button.svelte';
interface ChannelData {
readonly id?: string;
@@ -112,7 +114,7 @@
}
</script>
<div class="rounded-lg border border-border bg-card p-6">
<div class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h3 class="mb-4 text-lg font-semibold text-card-foreground">
{channel ? 'Edit Channel' : 'Add Notification Channel'}
</h3>
@@ -126,7 +128,7 @@
<select
id="channel-type"
bind:value={channelType}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<option value="discord">Discord</option>
<option value="slack">Slack</option>
@@ -146,7 +148,7 @@
type="url"
bind:value={discordWebhookUrl}
placeholder="https://discord.com/api/webhooks/..."
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
required
/>
</div>
@@ -160,7 +162,7 @@
type="url"
bind:value={slackWebhookUrl}
placeholder="https://hooks.slack.com/services/..."
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
required
/>
</div>
@@ -174,7 +176,7 @@
type="text"
bind:value={telegramBotToken}
placeholder="123456:ABC-DEF..."
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
required
/>
</div>
@@ -187,7 +189,7 @@
type="text"
bind:value={telegramChatId}
placeholder="-1001234567890"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
required
/>
</div>
@@ -201,7 +203,7 @@
type="url"
bind:value={httpUrl}
placeholder="https://example.com/webhook"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
required
/>
</div>
@@ -212,7 +214,7 @@
<select
id="http-method"
bind:value={httpMethod}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
@@ -230,7 +232,7 @@
bind:value={httpSecret}
placeholder="Shared secret for HMAC-SHA256 signature"
autocomplete="off"
class="w-full rounded-md border border-input bg-background px-3 py-2 pr-10 font-mono text-sm text-foreground"
class="w-full rounded-xl border border-input bg-background px-3 py-2 pr-10 font-mono text-sm text-foreground"
/>
<button
type="button"
@@ -262,7 +264,7 @@
bind:value={httpSignatureHeader}
placeholder="X-Signature-256"
autocomplete="off"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
/>
<p class="mt-1 text-xs text-muted-foreground">
Defaults to <code class="rounded bg-muted/40 px-1">X-Signature-256</code>. Override for receivers expecting a different name (e.g. <code class="rounded bg-muted/40 px-1">X-Hub-Signature-256</code>).
@@ -271,48 +273,31 @@
{/if}
<!-- Enabled Toggle -->
<div class="flex items-center gap-2">
<input
id="channel-enabled"
type="checkbox"
bind:checked={enabled}
class="h-4 w-4 rounded border-input"
/>
<label for="channel-enabled" class="text-sm text-foreground">Enabled</label>
<div class="flex items-center gap-3">
<Switch id="channel-enabled" bind:checked={enabled} ariaLabelledby="channel-enabled-label" />
<label id="channel-enabled-label" for="channel-enabled" class="cursor-pointer text-sm text-foreground">Enabled</label>
</div>
<!-- Test Result -->
{#if testResult}
<p class="text-sm {testResult.startsWith('Failed') ? 'text-destructive' : 'text-emerald-500'}">
<p class="text-sm {testResult.startsWith('Failed') ? 'text-destructive' : 'text-status-online-ink'}">
{testResult}
</p>
{/if}
<!-- Actions -->
<div class="flex items-center gap-3">
<button
type="submit"
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
<Button type="submit">
{channel ? 'Update' : 'Create'} Channel
</button>
</Button>
{#if channel?.id}
<button
type="button"
onclick={sendTest}
disabled={testing}
class="rounded-md border border-input px-4 py-2 text-sm font-medium text-foreground hover:bg-accent disabled:opacity-50"
>
<Button variant="outline" onclick={sendTest} disabled={testing} loading={testing}>
{testing ? 'Sending...' : 'Send Test'}
</button>
</Button>
{/if}
<button
type="button"
onclick={onCancel}
class="rounded-md px-4 py-2 text-sm text-muted-foreground hover:text-foreground"
>
<Button variant="ghost" onclick={onCancel}>
Cancel
</button>
</Button>
</div>
</form>
</div>
@@ -73,9 +73,9 @@
function eventBadgeClass(event: string): string {
switch (event) {
case 'app_online': return 'bg-green-500/10 text-green-500';
case 'app_offline': return 'bg-red-500/10 text-red-500';
case 'app_degraded': return 'bg-yellow-500/10 text-yellow-500';
case 'app_online': return 'bg-status-online/15 text-status-online-ink';
case 'app_offline': return 'bg-status-offline/15 text-status-offline-ink';
case 'app_degraded': return 'bg-status-degraded/15 text-status-degraded-ink';
default: return 'bg-muted text-muted-foreground';
}
}
@@ -87,7 +87,7 @@
<select
bind:value={filterEvent}
onchange={applyFilters}
class="rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground"
class="rounded-xl border border-input bg-background px-3 py-1.5 text-sm text-foreground"
>
<option value="">All Events</option>
<option value="app_online">Online</option>
@@ -104,7 +104,7 @@
<p class="text-muted-foreground">No notifications found</p>
</div>
{:else}
<div class="overflow-x-auto rounded-lg border border-border">
<div class="overflow-x-auto rounded-2xl border border-border bg-card shadow-[var(--shadow-soft)]">
<table class="w-full text-left text-sm">
<thead class="border-b border-border bg-muted/50">
<tr>
@@ -1,4 +1,6 @@
<script lang="ts">
import Button from '$lib/components/ui/Button.svelte';
const STEPS = ['welcome', 'admin', 'authMode', 'theme', 'complete'] as const;
type Step = (typeof STEPS)[number];
@@ -20,7 +22,7 @@
// Theme form
let defaultTheme = $state<'dark' | 'light'>('dark');
let defaultPrimaryColor = $state('#6366f1');
let defaultPrimaryColor = $state('#e8754f');
// Board form
let boardName = $state('My Dashboard');
@@ -169,6 +171,7 @@
}
const primaryColorOptions = [
{ label: 'Terracotta', value: '#e8754f' },
{ label: 'Indigo', value: '#6366f1' },
{ label: 'Blue', value: '#3b82f6' },
{ label: 'Emerald', value: '#10b981' },
@@ -182,7 +185,7 @@
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm">
<div
class="mx-4 w-full max-w-lg rounded-2xl border border-border bg-card shadow-2xl"
class="mx-4 w-full max-w-lg rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-lift)]"
>
<!-- Progress bar -->
<div class="border-b border-border px-6 py-4">
@@ -227,7 +230,7 @@
{:else if currentStep === 'admin'}
<h2 class="mb-4 text-xl font-bold text-foreground">Create Admin Account</h2>
{#if adminCreated}
<div class="rounded-lg bg-green-500/10 p-4 text-sm text-green-600 dark:text-green-400">
<div class="rounded-lg bg-status-online/10 p-4 text-sm text-status-online-ink dark:text-status-online-ink">
Admin account created successfully. You can proceed to the next step.
</div>
{:else}
@@ -238,7 +241,7 @@
id="ob-display-name"
type="text"
bind:value={adminDisplayName}
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
placeholder="Admin"
/>
</div>
@@ -248,7 +251,7 @@
id="ob-email"
type="email"
bind:value={adminEmail}
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
placeholder="admin@example.com"
/>
</div>
@@ -258,7 +261,7 @@
id="ob-password"
type="password"
bind:value={adminPassword}
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
placeholder="Min. 6 characters"
/>
</div>
@@ -298,19 +301,19 @@
<input
type="text"
bind:value={oauthClientId}
class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
class="w-full rounded-xl border border-input bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
placeholder="Client ID"
/>
<input
type="password"
bind:value={oauthClientSecret}
class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
class="w-full rounded-xl border border-input bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
placeholder="Client Secret"
/>
<input
type="url"
bind:value={oauthDiscoveryUrl}
class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
class="w-full rounded-xl border border-input bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
placeholder="Discovery URL (https://.../.well-known/openid-configuration)"
/>
</div>
@@ -369,7 +372,7 @@
id="ob-board-name"
type="text"
bind:value={boardName}
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
placeholder="My Dashboard"
/>
</div>
@@ -413,12 +416,7 @@
</button>
{/if}
<button
type="button"
onclick={handleNext}
disabled={loading}
class="rounded-lg bg-primary px-6 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
<Button size="lg" onclick={handleNext} disabled={loading} loading={loading}>
{#if loading}
Processing...
{:else if isLastStep}
@@ -428,7 +426,7 @@
{:else}
Next
{/if}
</button>
</Button>
</div>
</div>
</div>
@@ -76,7 +76,7 @@
>
<!-- Dialog -->
<div
class="flex w-full max-w-lg flex-col rounded-lg border border-border bg-popover shadow-2xl"
class="flex w-full max-w-lg flex-col rounded-[1.4rem] border border-border bg-popover shadow-[var(--shadow-lift)]"
style="max-height: 60vh; animation: searchSlideDown 0.2s cubic-bezier(0.16, 1, 0.3, 1)"
role="dialog"
aria-label={$t('search.placeholder')}
@@ -10,7 +10,7 @@
<button
type="button"
onclick={() => search.toggle()}
class="flex w-full max-w-sm items-center gap-2 rounded-md border border-input bg-background/50 px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
class="flex w-full max-w-sm items-center gap-2.5 rounded-2xl border border-border bg-card px-4 py-2.5 text-sm text-muted-foreground shadow-[var(--shadow-soft)] transition-all hover:border-primary/40 hover:text-foreground"
>
<svg
class="h-4 w-4 shrink-0"
@@ -27,7 +27,7 @@
</svg>
<span class="flex-1 text-left">{$t('search.trigger')}</span>
<kbd
class="hidden rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:inline"
class="hidden rounded-lg bg-muted px-2 py-0.5 text-[10px] font-bold text-muted-foreground sm:inline"
>
{isMac ? '\u2318' : 'Ctrl'}K
</kbd>
@@ -105,7 +105,7 @@
}
</script>
<div class="rounded-xl border border-border bg-card p-4 shadow-sm">
<div class="rounded-xl border border-border bg-card p-4 shadow-[var(--shadow-soft)]">
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<!-- Section drag handle -->
@@ -142,7 +142,7 @@
<!-- Card size selector -->
<select
onchange={handleCardSizeChange}
class="rounded-md border border-input bg-background px-2 py-1 text-xs text-foreground transition-colors focus:border-primary focus:outline-none"
class="rounded-xl border border-input bg-background px-2 py-1 text-xs text-foreground transition-colors focus:border-primary focus:outline-none"
title={$t('board.card_size') ?? 'Card size'}
>
<option value="" selected={!section.cardSize}>{$t('section.inherit_size') ?? 'Inherit'}</option>
@@ -153,7 +153,7 @@
<button
type="button"
onclick={() => onToggleAddWidget(section.id)}
class="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
class="rounded-xl bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{$t('widget.add')}
</button>
+1 -1
View File
@@ -58,7 +58,7 @@
let expanded = $state(section.isExpandedByDefault);
</script>
<div class="rounded-xl border border-border bg-card/30 shadow-sm {editMode.active ? 'ring-1 ring-primary/10' : ''}">
<div class="rounded-[1.4rem] border border-border bg-card/40 shadow-[var(--shadow-soft)] backdrop-blur-sm {editMode.active ? 'ring-1 ring-primary/15' : ''}">
<SectionHeader
sectionId={section.id}
title={section.title}
@@ -117,7 +117,7 @@
bind:value={editTitle}
onkeydown={handleTitleKeydown}
onblur={handleEditBlur}
class="flex-1 rounded border border-input bg-background px-2 py-0.5 text-sm font-medium text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-ring/30"
class="flex-1 rounded border border-input bg-background px-2 py-0.5 text-sm font-medium text-foreground focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
/>
<IconPickerButton
value={editIcon}
@@ -135,7 +135,7 @@
{#if icon}
<DynamicIcon name={icon} size={18} />
{/if}
<span class="font-medium text-foreground">{title}</span>
<span class="font-display text-lg font-semibold text-foreground">{title}</span>
</button>
{/if}
@@ -1,5 +1,7 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Button from '$lib/components/ui/Button.svelte';
import Select from '$lib/components/ui/Select.svelte';
interface Props {
onCancel: () => void;
@@ -8,7 +10,7 @@
let { onCancel }: Props = $props();
</script>
<div class="rounded-lg border border-border bg-card p-6">
<div class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">Generate API Token</h2>
<form method="POST" action="?/create" use:enhance class="space-y-4">
@@ -21,7 +23,7 @@
name="name"
type="text"
placeholder="e.g., CI/CD Pipeline"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
required
/>
<p class="mt-1 text-xs text-muted-foreground">A descriptive name to identify this token</p>
@@ -31,15 +33,11 @@
<label for="token-scope" class="mb-1 block text-sm font-medium text-foreground">
Scope
</label>
<select
id="token-scope"
name="scope"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<Select id="token-scope" name="scope">
<option value="read">Read — View apps, boards, and status</option>
<option value="write">Write — Modify apps, boards, and settings</option>
<option value="admin">Admin — Full access including user management</option>
</select>
</Select>
</div>
<div>
@@ -50,25 +48,14 @@
id="token-expires"
name="expiresAt"
type="datetime-local"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
/>
<p class="mt-1 text-xs text-muted-foreground">Leave empty for a non-expiring token</p>
</div>
<div class="flex items-center gap-3">
<button
type="submit"
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Generate Token
</button>
<button
type="button"
onclick={onCancel}
class="rounded-md px-4 py-2 text-sm text-muted-foreground hover:text-foreground"
>
Cancel
</button>
<Button type="submit">Generate Token</Button>
<Button variant="ghost" onclick={onCancel}>Cancel</Button>
</div>
</form>
</div>
@@ -39,9 +39,9 @@
function scopeBadgeClass(scope: string): string {
switch (scope) {
case 'admin': return 'bg-red-500/10 text-red-500';
case 'write': return 'bg-yellow-500/10 text-yellow-500';
default: return 'bg-green-500/10 text-green-500';
case 'admin': return 'bg-destructive/10 text-destructive';
case 'write': return 'bg-status-degraded/10 text-status-degraded-ink';
default: return 'bg-status-online/10 text-status-online-ink';
}
}
</script>
@@ -54,7 +54,7 @@
</p>
</div>
{:else}
<div class="overflow-x-auto rounded-lg border border-border">
<div class="overflow-x-auto rounded-2xl border border-border bg-card shadow-[var(--shadow-soft)]">
<table class="w-full text-left text-sm">
<thead class="border-b border-border bg-muted/50">
<tr>
@@ -20,7 +20,7 @@
});
</script>
<div class="rounded-xl border border-border bg-card p-6 shadow-sm">
<div class="rounded-xl border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-2 text-lg font-semibold text-card-foreground">
{$t('settings.bookmarklet_title')}
</h2>
@@ -1,5 +1,6 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import Switch from '$lib/components/ui/Switch.svelte';
interface Props {
value: string;
@@ -88,7 +89,7 @@
value={localValue}
oninput={handleInput}
rows="8"
class="w-full rounded-lg border border-input bg-background px-3 py-2 font-mono text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
class="w-full rounded-xl border border-input bg-background px-3 py-2 font-mono text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
placeholder={`/* Custom CSS */\n.custom-css-scope .my-widget {\n background: #333;\n}`}
spellcheck="false"
></textarea>
@@ -98,11 +99,11 @@
{/if}
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
<label class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
<Switch
bind:checked={livePreview}
class="h-4 w-4 rounded border-input accent-primary"
size="sm"
ariaLabel={$t('settings.live_preview') ?? 'Live preview'}
/>
{$t('settings.live_preview') ?? 'Live preview'}
</label>
@@ -1,6 +1,7 @@
<script lang="ts">
import { t, locale as i18nLocale } from 'svelte-i18n';
import { theme, type ThemeMode, type BackgroundType, type CardStyle } from '$lib/stores/theme.svelte.js';
import Button from '$lib/components/ui/Button.svelte';
interface UserPreferences {
themeMode: string | null;
@@ -128,7 +129,7 @@
type="button"
onclick={() => setMode(opt.value)}
class="flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors {theme.mode === opt.value
? 'bg-background text-foreground shadow-sm'
? 'bg-background text-foreground shadow-[var(--shadow-soft)]'
: 'text-muted-foreground hover:text-foreground'}"
>
{$t(opt.labelKey)}
@@ -167,7 +168,7 @@
max="360"
step="1"
bind:value={theme.primaryHue}
class="absolute inset-0 h-3 w-full cursor-pointer appearance-none bg-transparent [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-white [&::-moz-range-thumb]:bg-current [&::-moz-range-thumb]:shadow-md [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white [&::-webkit-slider-thumb]:bg-current [&::-webkit-slider-thumb]:shadow-md"
class="absolute inset-0 h-3 w-full cursor-pointer appearance-none bg-transparent [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-white [&::-moz-range-thumb]:bg-current [&::-moz-range-thumb]:shadow-[var(--shadow-soft)] [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white [&::-webkit-slider-thumb]:bg-current [&::-webkit-slider-thumb]:shadow-[var(--shadow-soft)]"
style="color: {previewColor};"
/>
</div>
@@ -188,7 +189,7 @@
max="100"
step="1"
bind:value={theme.primarySaturation}
class="absolute inset-0 h-3 w-full cursor-pointer appearance-none bg-transparent [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-white [&::-moz-range-thumb]:bg-current [&::-moz-range-thumb]:shadow-md [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white [&::-webkit-slider-thumb]:bg-current [&::-webkit-slider-thumb]:shadow-md"
class="absolute inset-0 h-3 w-full cursor-pointer appearance-none bg-transparent [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-white [&::-moz-range-thumb]:bg-current [&::-moz-range-thumb]:shadow-[var(--shadow-soft)] [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white [&::-webkit-slider-thumb]:bg-current [&::-webkit-slider-thumb]:shadow-[var(--shadow-soft)]"
style="color: {previewColor};"
/>
</div>
@@ -204,7 +205,7 @@
type="button"
onclick={() => setBackground(opt.value)}
class="flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors {theme.backgroundType === opt.value
? 'bg-background text-foreground shadow-sm'
? 'bg-background text-foreground shadow-[var(--shadow-soft)]'
: 'text-muted-foreground hover:text-foreground'}"
>
{$t(opt.labelKey)}
@@ -222,7 +223,7 @@
type="button"
onclick={() => setCardStyle(opt.value)}
class="flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors {theme.cardStyle === opt.value
? 'bg-background text-foreground shadow-sm'
? 'bg-background text-foreground shadow-[var(--shadow-soft)]'
: 'text-muted-foreground hover:text-foreground'}"
>
{$t(opt.labelKey) ?? opt.value}
@@ -240,7 +241,7 @@
type="button"
onclick={() => setLocale(opt.value)}
class="flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors {$i18nLocale === opt.value
? 'bg-background text-foreground shadow-sm'
? 'bg-background text-foreground shadow-[var(--shadow-soft)]'
: 'text-muted-foreground hover:text-foreground'}"
>
{opt.label}
@@ -251,16 +252,11 @@
<!-- Save button -->
<div class="flex items-center gap-3">
<button
type="button"
onclick={savePreferences}
disabled={saving}
class="rounded-md bg-primary px-6 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
<Button size="lg" onclick={savePreferences} disabled={saving} loading={saving}>
{saving ? $t('settings.saving') : $t('settings.save')}
</button>
</Button>
{#if saved}
<span class="text-sm text-green-500">{$t('settings.saved')}</span>
<span class="text-sm text-status-online-ink">{$t('settings.saved')}</span>
{/if}
{#if errorMessage}
<span class="text-sm text-destructive">{errorMessage}</span>
@@ -91,7 +91,7 @@
/>
{#if open && filtered.length > 0}
<div class="absolute left-0 top-full z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-border bg-card shadow-lg">
<div class="absolute left-0 top-full z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-xl border border-border bg-card shadow-[var(--shadow-soft)]">
{#each filtered as item, i (item)}
<button
type="button"
+81
View File
@@ -0,0 +1,81 @@
<script lang="ts" module>
import { cn } from '$lib/utils/cn.js';
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'destructive' | 'outline';
export type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
const variantClasses: Record<ButtonVariant, string> = {
primary:
'bg-primary text-primary-foreground shadow-[var(--shadow-soft)] hover:bg-primary/90 hover:-translate-y-0.5 hover:shadow-[var(--shadow-lift)] active:translate-y-0 active:scale-[0.98]',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80 active:scale-[0.98]',
outline:
'border border-border bg-card text-foreground shadow-[var(--shadow-soft)] hover:-translate-y-0.5 hover:border-primary/40 active:translate-y-0 active:scale-[0.98]',
ghost:
'bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground active:scale-[0.98]',
destructive:
'bg-destructive text-destructive-foreground shadow-[var(--shadow-soft)] hover:bg-destructive/90 hover:-translate-y-0.5 active:translate-y-0 active:scale-[0.98]'
};
const sizeClasses: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-xs',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-2.5 text-sm',
icon: 'p-2'
};
export function buttonClass(opts: {
variant?: ButtonVariant;
size?: ButtonSize;
fullWidth?: boolean;
extra?: string;
} = {}): string {
const variant = opts.variant ?? 'primary';
const size = opts.size ?? 'md';
return cn(
'inline-flex items-center justify-center gap-2 rounded-xl font-medium whitespace-nowrap transition-all duration-150 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:shadow-[var(--shadow-soft)]',
variantClasses[variant],
sizeClasses[size],
opts.fullWidth && 'w-full',
opts.extra
);
}
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements';
interface Props extends Omit<HTMLButtonAttributes, 'class' | 'children'> {
variant?: ButtonVariant;
size?: ButtonSize;
fullWidth?: boolean;
loading?: boolean;
class?: string;
children: Snippet;
}
let {
variant = 'primary',
size = 'md',
fullWidth = false,
loading = false,
disabled,
type = 'button',
class: className = '',
children,
...rest
}: Props = $props();
</script>
<button
{type}
disabled={disabled || loading}
class={buttonClass({ variant, size, fullWidth, extra: className })}
{...rest}
>
{#if loading}
<span class="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" aria-hidden="true"></span>
{/if}
{@render children()}
</button>
+95
View File
@@ -0,0 +1,95 @@
<script lang="ts">
import { cn } from '$lib/utils/cn.js';
interface Props {
checked?: boolean | undefined;
onchange?: (checked: boolean) => void;
disabled?: boolean;
id?: string;
name?: string;
value?: string;
ariaLabel?: string;
ariaLabelledby?: string;
indeterminate?: boolean;
class?: string;
}
let {
checked = $bindable(false),
onchange,
disabled = false,
id,
name,
value,
ariaLabel,
ariaLabelledby,
indeterminate = false,
class: className = ''
}: Props = $props();
function toggle() {
if (disabled) return;
checked = !checked;
onchange?.(checked);
}
function onKeydown(e: KeyboardEvent) {
if (e.key === ' ') {
e.preventDefault();
toggle();
}
}
</script>
<button
type="button"
role="checkbox"
aria-checked={indeterminate ? 'mixed' : checked}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
{id}
{disabled}
onclick={toggle}
onkeydown={onKeydown}
class={cn(
'inline-flex h-[18px] w-[18px] shrink-0 cursor-pointer items-center justify-center rounded-md border transition-all duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
checked || indeterminate
? 'border-primary bg-primary text-primary-foreground shadow-[inset_0_1px_2px_rgba(0,0,0,0.15)]'
: 'border-input bg-background hover:border-primary/60',
className
)}
>
{#if indeterminate}
<svg viewBox="0 0 18 18" class="h-3 w-3" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" aria-hidden="true">
<line x1="4" y1="9" x2="14" y2="9" />
</svg>
{:else if checked}
<svg viewBox="0 0 18 18" class="h-3 w-3" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="3.5 9.5 7.5 13.5 14.5 5.5" class="check-draw" />
</svg>
{/if}
{#if name !== undefined}
<input type="hidden" {name} value={checked ? (value ?? 'on') : ''} />
{/if}
</button>
<style>
.check-draw {
stroke-dasharray: 24;
stroke-dashoffset: 24;
animation: check-draw-in 180ms ease-out forwards;
}
@keyframes check-draw-in {
to {
stroke-dashoffset: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.check-draw {
animation: none;
stroke-dashoffset: 0;
}
}
</style>
+5 -5
View File
@@ -40,7 +40,7 @@
>
<!-- Dialog -->
<div
class="mx-4 w-full max-w-sm rounded-2xl border border-border bg-card p-6 shadow-2xl"
class="mx-4 w-full max-w-sm rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-lift)]"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
transition:scale={{ start: 0.95, duration: 150 }}
@@ -49,21 +49,21 @@
aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-message"
>
<h2 id="confirm-dialog-title" class="mb-2 text-base font-semibold text-foreground">{title}</h2>
<p id="confirm-dialog-message" class="mb-5 text-sm text-muted-foreground">{message}</p>
<h2 id="confirm-dialog-title" class="mb-2 font-display text-lg font-semibold text-foreground">{title}</h2>
<p id="confirm-dialog-message" class="mb-5 text-sm leading-relaxed text-muted-foreground">{message}</p>
<div class="flex items-center justify-end gap-2">
<button
type="button"
onclick={onCancel}
class="rounded-lg border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-accent"
class="rounded-xl border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent"
>
{cancelLabel ?? ($t('common.cancel') ?? 'Cancel')}
</button>
<button
type="button"
onclick={onConfirm}
class="rounded-lg px-4 py-2 text-sm font-medium transition-colors
class="rounded-xl px-4 py-2 text-sm font-semibold shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5
{destructive
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
: 'bg-primary text-primary-foreground hover:bg-primary/90'}"
+2 -2
View File
@@ -130,7 +130,7 @@
<button
type="button"
class="flex w-full items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors hover:border-primary focus:outline-none focus:ring-2 focus:ring-ring"
class="flex w-full items-center gap-2 rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors hover:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
onclick={openPicker}
>
{#if selectedItem}
@@ -157,7 +157,7 @@
style="animation: epFadeIn 0.15s ease-out"
>
<div
class="flex w-full max-w-md flex-col rounded-lg border border-border bg-popover shadow-2xl"
class="flex w-full max-w-md flex-col rounded-[1.4rem] border border-border bg-popover shadow-[var(--shadow-lift)]"
style="max-height: 60vh; animation: epSlideDown 0.2s cubic-bezier(0.16, 1, 0.3, 1)"
role="dialog"
aria-label={searchPlaceholder || 'Select entity'}
+1 -1
View File
@@ -22,7 +22,7 @@
class="relative z-10 flex min-h-screen items-center justify-center bg-background/80 p-4 text-foreground"
>
<div
class="w-full max-w-lg rounded-xl border border-border bg-card/90 p-8 text-center shadow-xl backdrop-blur-sm"
class="w-full max-w-lg rounded-xl border border-border bg-card/90 p-8 text-center shadow-[var(--shadow-lift)] backdrop-blur-sm"
>
<div class="mb-2 text-xs uppercase tracking-widest text-muted-foreground">
{status}
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils/cn.js';
interface Props {
label?: string;
labelFor?: string;
hint?: string;
error?: string;
required?: boolean;
class?: string;
children: Snippet;
}
let {
label,
labelFor,
hint,
error,
required = false,
class: className = '',
children
}: Props = $props();
</script>
<div class={cn('space-y-1.5', className)}>
{#if label}
<label for={labelFor} class="block text-sm font-medium text-foreground">
{label}
{#if required}<span class="text-destructive" aria-hidden="true">*</span>{/if}
</label>
{/if}
{@render children()}
{#if error}
<p class="text-xs text-destructive" role="alert">{error}</p>
{:else if hint}
<p class="text-xs text-muted-foreground">{hint}</p>
{/if}
</div>
+2 -2
View File
@@ -112,7 +112,7 @@
<button
type="button"
class="icon-grid-trigger flex w-full items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors hover:border-primary focus:outline-none focus:ring-2 focus:ring-ring"
class="icon-grid-trigger flex w-full items-center gap-2 rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors hover:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
bind:this={triggerEl}
onclick={toggle}
>
@@ -129,7 +129,7 @@
{#if open}
<div
class="icon-grid-popup fixed z-50 overflow-y-auto rounded-lg border border-border bg-popover shadow-xl"
class="icon-grid-popup fixed z-50 overflow-y-auto rounded-xl border border-border bg-popover shadow-[var(--shadow-lift)]"
bind:this={popupEl}
style="animation: iconGridSlideIn 0.15s ease-out"
>
@@ -82,7 +82,7 @@
<button
type="button"
onclick={toggleOpen}
class="flex items-center gap-1.5 rounded-lg border border-input bg-background transition-colors hover:bg-accent
class="flex items-center gap-1.5 rounded-xl border border-input bg-background transition-colors hover:bg-accent
{size === 'sm' ? 'px-2 py-1' : 'px-3 py-2'}"
title={$t('app.icon') ?? 'Select icon'}
>
@@ -105,7 +105,7 @@
class="fixed inset-0 z-50"
onclick={(e) => { if (e.target === e.currentTarget) open = false; }}
>
<div class="fixed left-1/2 top-1/2 z-50 w-80 -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-card p-3 shadow-2xl">
<div class="fixed left-1/2 top-1/2 z-50 w-80 -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-card p-3 shadow-[var(--shadow-lift)]">
<!-- Search -->
<div class="relative mb-2">
<svg class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -116,7 +116,7 @@
type="text"
bind:value={query}
placeholder={$t('common.search') ?? 'Search icons...'}
class="w-full rounded-lg border border-input bg-background py-1.5 pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-ring/30"
class="w-full rounded-xl border border-input bg-background py-1.5 pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
/>
</div>
@@ -160,7 +160,7 @@
value={value}
oninput={(e) => { const v = (e.target as HTMLInputElement).value; value = v; onchange(v); }}
placeholder={$t('app.icon_manual') ?? 'Or type icon name...'}
class="w-full rounded-lg border border-input bg-background px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-ring/30"
class="w-full rounded-xl border border-input bg-background px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
/>
</div>
</div>
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts" module>
export const inputClass =
'w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50';
</script>
<script lang="ts">
import { cn } from '$lib/utils/cn.js';
import type { HTMLInputAttributes } from 'svelte/elements';
interface Props extends Omit<HTMLInputAttributes, 'class' | 'value'> {
value?: string | number;
class?: string;
invalid?: boolean;
}
let {
value = $bindable(''),
type = 'text',
invalid = false,
class: className = '',
...rest
}: Props = $props();
</script>
<input
{type}
bind:value
class={cn(inputClass, invalid && 'border-destructive focus:border-destructive', className)}
aria-invalid={invalid || undefined}
{...rest}
/>
@@ -48,7 +48,7 @@
onkeydown={(e) => e.key === 'Escape' && keyboard.closeOverlay()}
>
<div
class="mx-4 w-full max-w-xl rounded-2xl border border-border bg-card shadow-2xl"
class="mx-4 w-full max-w-xl rounded-2xl border border-border bg-card shadow-[var(--shadow-lift)]"
role="dialog"
aria-label="Keyboard Shortcuts"
>
@@ -127,7 +127,7 @@
<button
type="button"
class="flex w-full items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors hover:border-primary focus:outline-none focus:ring-2 focus:ring-ring"
class="flex w-full items-center gap-2 rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors hover:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
onclick={openPicker}
>
{#if selectedCount > 0}
@@ -148,7 +148,7 @@
style="animation: mepFadeIn 0.15s ease-out"
>
<div
class="flex w-full max-w-md flex-col rounded-lg border border-border bg-popover shadow-2xl"
class="flex w-full max-w-md flex-col rounded-[1.4rem] border border-border bg-popover shadow-[var(--shadow-lift)]"
style="max-height: 60vh; animation: mepSlideDown 0.2s cubic-bezier(0.16, 1, 0.3, 1)"
role="dialog"
aria-label={searchPlaceholder || 'Select items'}
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts" module>
export const selectClass =
'w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50 appearance-none bg-no-repeat bg-[right_0.75rem_center] bg-[length:0.85em] pr-9';
export const chevronBg =
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'><polyline points='4 6 8 10 12 6'/></svg>\")";
</script>
<script lang="ts">
import { cn } from '$lib/utils/cn.js';
import type { Snippet } from 'svelte';
import type { HTMLSelectAttributes } from 'svelte/elements';
interface Props extends Omit<HTMLSelectAttributes, 'class' | 'value' | 'children'> {
value?: string | number | undefined;
class?: string;
children: Snippet;
}
let {
value = $bindable<string | number | undefined>(''),
class: className = '',
children,
...rest
}: Props = $props();
</script>
<div class="relative">
<select
bind:value
class={cn(selectClass, className)}
style="background-image: {chevronBg};"
{...rest}
>
{@render children()}
</select>
</div>
+155
View File
@@ -0,0 +1,155 @@
<script lang="ts">
import { cn } from '$lib/utils/cn.js';
interface Props {
value: number;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
id?: string;
name?: string;
ariaLabel?: string;
ariaLabelledby?: string;
showValue?: boolean;
formatValue?: (v: number) => string;
class?: string;
oninput?: (value: number) => void;
onchange?: (value: number) => void;
}
let {
value = $bindable(0),
min = 0,
max = 100,
step = 1,
disabled = false,
id,
name,
ariaLabel,
ariaLabelledby,
showValue = false,
formatValue,
class: className = '',
oninput,
onchange
}: Props = $props();
const pct = $derived(((value - min) / (max - min)) * 100);
const displayValue = $derived(formatValue ? formatValue(value) : String(value));
function handleInput(e: Event) {
const target = e.currentTarget as HTMLInputElement;
value = Number(target.value);
oninput?.(value);
}
function handleChange(e: Event) {
const target = e.currentTarget as HTMLInputElement;
onchange?.(Number(target.value));
}
</script>
<div class={cn('cozy-slider relative w-full', className)} style="--pct: {pct}%;">
<input
type="range"
{id}
{name}
{min}
{max}
{step}
{disabled}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-valuetext={displayValue}
{value}
oninput={handleInput}
onchange={handleChange}
class="cozy-slider-input"
/>
{#if showValue}
<span class="mt-1 inline-block text-xs tabular-nums text-muted-foreground">{displayValue}</span>
{/if}
</div>
<style>
.cozy-slider-input {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 9999px;
background: linear-gradient(
to right,
var(--primary) 0%,
var(--primary) var(--pct, 0%),
color-mix(in srgb, var(--muted-foreground) 35%, transparent) var(--pct, 0%),
color-mix(in srgb, var(--muted-foreground) 35%, transparent) 100%
);
outline: none;
cursor: pointer;
transition: background 0.1s ease;
}
.cozy-slider-input:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.cozy-slider-input::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 9999px;
background: var(--card);
border: 2px solid var(--primary);
box-shadow:
0 2px 4px rgba(80, 50, 20, 0.3),
0 1px 2px rgba(80, 50, 20, 0.15);
cursor: pointer;
transition: transform 0.15s ease;
}
.cozy-slider-input::-webkit-slider-thumb:hover {
transform: scale(1.1);
}
.cozy-slider-input::-webkit-slider-thumb:active {
transform: scale(1.18);
}
.cozy-slider-input::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 9999px;
background: var(--card);
border: 2px solid var(--primary);
box-shadow:
0 2px 4px rgba(80, 50, 20, 0.3),
0 1px 2px rgba(80, 50, 20, 0.15);
cursor: pointer;
transition: transform 0.15s ease;
}
.cozy-slider-input::-moz-range-thumb:hover {
transform: scale(1.1);
}
.cozy-slider-input:focus-visible {
outline: 2px solid color-mix(in srgb, var(--primary) 40%, transparent);
outline-offset: 4px;
border-radius: 9999px;
}
@media (prefers-reduced-motion: reduce) {
.cozy-slider-input::-webkit-slider-thumb,
.cozy-slider-input::-moz-range-thumb {
transition: none;
}
.cozy-slider-input::-webkit-slider-thumb:hover,
.cozy-slider-input::-webkit-slider-thumb:active {
transform: none;
}
}
</style>
+86
View File
@@ -0,0 +1,86 @@
<script lang="ts">
import { cn } from '$lib/utils/cn.js';
interface Props {
checked?: boolean | undefined;
onchange?: (checked: boolean) => void;
disabled?: boolean;
id?: string;
name?: string;
label?: string;
ariaLabel?: string;
ariaLabelledby?: string;
size?: 'sm' | 'md';
class?: string;
}
let {
checked = $bindable(false),
onchange,
disabled = false,
id,
name,
label,
ariaLabel,
ariaLabelledby,
size = 'md',
class: className = ''
}: Props = $props();
function toggle() {
if (disabled) return;
checked = !checked;
onchange?.(checked);
}
function onKeydown(e: KeyboardEvent) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
toggle();
}
}
const trackBase =
'relative inline-flex shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50';
const trackSize = $derived(size === 'sm' ? 'h-5 w-9' : 'h-6 w-11');
const knobSize = $derived(size === 'sm' ? 'h-4 w-4' : 'h-5 w-5');
const knobTranslate = $derived(
size === 'sm'
? checked
? 'translate-x-4'
: 'translate-x-0.5'
: checked
? 'translate-x-[1.375rem]'
: 'translate-x-0.5'
);
</script>
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel ?? label}
aria-labelledby={ariaLabelledby}
{id}
{disabled}
onclick={toggle}
onkeydown={onKeydown}
class={cn(
trackBase,
trackSize,
checked ? 'bg-primary shadow-[inset_0_1px_2px_rgba(0,0,0,0.18)]' : 'bg-muted-foreground/35',
className
)}
>
<span
aria-hidden="true"
class={cn(
'pointer-events-none inline-block transform rounded-full bg-white shadow-[0_2px_4px_rgba(80,50,20,0.35),0_1px_2px_rgba(80,50,20,0.18)] ring-0 transition-transform duration-200 ease-[cubic-bezier(0.34,1.56,0.64,1)]',
knobSize,
knobTranslate
)}
></span>
{#if name !== undefined}
<input type="hidden" {name} value={checked ? 'on' : ''} />
{/if}
</button>
+2 -2
View File
@@ -100,7 +100,7 @@
{#if tags.length > 0}
<div class="mb-1.5 flex flex-wrap gap-1">
{#each tags as tag (tag)}
<span class="flex items-center gap-1 rounded-md bg-primary/10 px-2 py-0.5 text-xs text-primary">
<span class="flex items-center gap-1 rounded-xl bg-primary/10 px-2 py-0.5 text-xs text-primary">
{tag}
<button
type="button"
@@ -128,7 +128,7 @@
/>
{#if open && filtered.length > 0}
<div class="absolute left-0 top-full z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-border bg-card shadow-lg">
<div class="absolute left-0 top-full z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-xl border border-border bg-card shadow-[var(--shadow-soft)]">
{#each filtered as item, i (item)}
<button
type="button"
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { buttonClass } from '../Button.svelte';
describe('buttonClass', () => {
it('returns primary md by default', () => {
const cls = buttonClass();
expect(cls).toContain('bg-primary');
expect(cls).toContain('px-4');
expect(cls).toContain('py-2');
expect(cls).toContain('text-sm');
});
it('applies secondary variant', () => {
const cls = buttonClass({ variant: 'secondary' });
expect(cls).toContain('bg-secondary');
expect(cls).not.toContain('bg-primary ');
});
it('applies destructive variant', () => {
const cls = buttonClass({ variant: 'destructive' });
expect(cls).toContain('bg-destructive');
});
it('applies sm size', () => {
const cls = buttonClass({ size: 'sm' });
expect(cls).toContain('px-3');
expect(cls).toContain('text-xs');
});
it('applies lg size', () => {
const cls = buttonClass({ size: 'lg' });
expect(cls).toContain('px-6');
});
it('adds fullWidth', () => {
const cls = buttonClass({ fullWidth: true });
expect(cls).toContain('w-full');
});
it('merges extra class', () => {
const cls = buttonClass({ extra: 'custom-class' });
expect(cls).toContain('custom-class');
});
it('always includes focus-visible ring', () => {
const cls = buttonClass();
expect(cls).toContain('focus-visible:ring-2');
expect(cls).toContain('focus-visible:ring-primary/30');
});
it('always includes disabled state', () => {
const cls = buttonClass();
expect(cls).toContain('disabled:cursor-not-allowed');
expect(cls).toContain('disabled:opacity-50');
});
});
+8 -8
View File
@@ -139,14 +139,14 @@
href={app.url}
target="_blank"
rel="noopener noreferrer"
class="card-hover group flex items-center gap-2 rounded-lg {cardStyleClass} px-3 py-2 text-left transition-colors hover:border-primary/50"
class="card-hover group flex items-center gap-2 rounded-2xl {cardStyleClass} px-3 py-2 text-left transition-colors hover:border-primary/50"
data-app-widget
data-app-url={app.url}
oncontextmenu={handleContextMenu}
onclick={recordClick}
>
<div class="relative flex-shrink-0">
<div class="flex h-8 w-8 items-center justify-center rounded-md bg-muted transition-colors group-hover:bg-accent">
<div class="flex h-8 w-8 items-center justify-center rounded-xl bg-muted transition-colors group-hover:bg-accent">
{#if app.iconType === 'emoji' && app.icon}
<span class="text-base">{app.icon}</span>
{:else if iconSrc}
@@ -198,7 +198,7 @@
<!-- Large: icon + name + description + sparkline + tags + links -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="card-hover group rounded-xl {cardStyleClass} p-5 transition-colors hover:border-primary/50"
class="card-hover group rounded-[1.4rem] {cardStyleClass} p-5 transition-colors hover:border-primary/50"
data-app-widget
data-app-url={app.url}
oncontextmenu={handleContextMenu}
@@ -211,7 +211,7 @@
onclick={recordClick}
>
<div class="relative">
<div class="flex h-16 w-16 items-center justify-center rounded-xl bg-muted transition-colors group-hover:bg-accent">
<div class="flex h-16 w-16 items-center justify-center rounded-2xl bg-muted transition-colors group-hover:bg-accent">
{#if app.iconType === 'emoji' && app.icon}
<span class="text-3xl">{app.icon}</span>
{:else if iconSrc}
@@ -294,7 +294,7 @@
<!-- Medium (default): icon + name + status + sparkline on hover + links -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="card-hover group rounded-xl {cardStyleClass} p-4 transition-colors hover:border-primary/50"
class="card-hover group rounded-[1.4rem] {cardStyleClass} p-4 transition-colors hover:border-primary/50"
data-app-widget
data-app-url={app.url}
oncontextmenu={handleContextMenu}
@@ -307,7 +307,7 @@
onclick={recordClick}
>
<div class="relative">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-muted transition-colors group-hover:bg-accent">
<div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-muted transition-colors group-hover:bg-accent">
{#if app.iconType === 'emoji' && app.icon}
<span class="text-2xl">{app.icon}</span>
{:else if iconSrc}
@@ -378,12 +378,12 @@
<!-- Context Menu -->
{#if showContextMenu}
<div
class="fixed z-50 rounded-lg border border-border bg-popover p-1 shadow-lg"
class="fixed z-50 rounded-2xl border border-border bg-popover p-1 shadow-[var(--shadow-soft)]"
style="left: {contextMenuPos.x}px; top: {contextMenuPos.y}px"
>
<button
type="button"
class="flex w-full items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
class="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-sm text-popover-foreground transition-colors hover:bg-accent"
onclick={toggleFavorite}
>
{#if favorites.isFavorite(app.id)}
@@ -17,10 +17,10 @@
href={config.url}
target="_blank"
rel="noopener noreferrer"
class="card-hover group flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 text-center transition-colors hover:border-primary/50"
class="card-hover group flex flex-col items-center gap-2 rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4 text-center transition-colors hover:border-primary/50"
>
<!-- Icon -->
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-muted transition-colors group-hover:bg-accent">
<div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-muted transition-colors group-hover:bg-accent">
{#if config.icon}
<span class="text-2xl">{config.icon}</span>
{:else}
@@ -44,7 +44,7 @@
<!-- Badge -->
<span class="inline-flex items-center gap-1.5 text-xs">
<span class="inline-block h-2 w-2 rounded-full bg-blue-500"></span>
<span class="inline-block h-2 w-2 rounded-full" style="background: var(--room-sky);"></span>
<span class="text-muted-foreground">Bookmark</span>
</span>
</a>
@@ -110,7 +110,7 @@
});
</script>
<div class="flex h-full flex-col rounded-xl border border-border bg-card p-4">
<div class="flex h-full flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
<div class="mb-3 flex items-center gap-2">
<Calendar class="h-4 w-4 text-muted-foreground" />
<span class="text-sm font-medium text-foreground">Calendar</span>
@@ -154,7 +154,7 @@
}
</script>
<div class="flex h-full flex-col rounded-xl border border-border bg-card overflow-hidden">
<div class="flex h-full flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] overflow-hidden">
<!-- Stream view -->
<div
class="relative w-full bg-black"
@@ -111,7 +111,7 @@
}
</script>
<div class="flex h-full flex-col items-center justify-center rounded-xl border border-border bg-card p-4">
<div class="flex h-full flex-col items-center justify-center rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
{#if clockStyle === 'analog'}
<!-- Analog clock face -->
<svg viewBox="0 0 100 100" class="h-32 w-32">
@@ -154,7 +154,7 @@
<p class="mt-2 text-xs text-muted-foreground">{dateStr}</p>
{:else}
<!-- Digital clock -->
<p class="font-mono text-4xl font-bold tabular-nums text-foreground">{timeStr}</p>
<p class="font-display text-4xl font-semibold tabular-nums text-foreground">{timeStr}</p>
<p class="mt-1 text-sm text-muted-foreground">{dateStr}</p>
{#if config.timezone}
<p class="mt-0.5 text-xs text-muted-foreground/70">{config.timezone.replace(/_/g, ' ')}</p>
+1 -1
View File
@@ -36,7 +36,7 @@
}
</script>
<div class="flex flex-col rounded-xl border border-border bg-card">
<div class="flex flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)]">
<div class="relative" style="height: {iframeHeight}px;">
{#if !safeUrl}
<div class="flex h-full items-center justify-center text-sm text-muted-foreground">
@@ -15,7 +15,7 @@
const links = $derived(config.links ?? []);
</script>
<div class="flex h-full flex-col rounded-xl border border-border bg-card p-4">
<div class="flex h-full flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
<!-- Header -->
{#if isCollapsible}
<button
@@ -53,7 +53,7 @@
}
</script>
<div class="flex h-full flex-col rounded-xl border border-border bg-card">
<div class="flex h-full flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)]">
<!-- Toolbar -->
<div class="flex items-center justify-end border-b border-border px-3 py-1.5">
<button
@@ -64,13 +64,13 @@
});
const trendColor = $derived.by(() => {
if (trend === 'up') return 'text-green-500';
if (trend === 'down') return 'text-red-500';
return 'text-muted-foreground';
if (trend === 'up') return 'var(--status-online-ink)';
if (trend === 'down') return 'var(--status-offline-ink)';
return 'var(--muted-foreground)';
});
</script>
<div class="flex h-full flex-col items-center justify-center rounded-xl border border-border bg-card p-4">
<div class="flex h-full flex-col items-center justify-center rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
{#if loading}
<div class="flex flex-col items-center gap-2">
<div class="h-10 w-24 animate-pulse rounded bg-muted"></div>
@@ -80,7 +80,7 @@
<span class="text-xs text-muted-foreground">Failed to load metric</span>
{:else if currentValue !== null}
<!-- Trend arrow -->
<div class="mb-1 {trendColor}">
<div class="mb-1" style="color: {trendColor};">
{#if trend === 'up'}
<TrendingUp class="h-5 w-5" />
{:else if trend === 'down'}
@@ -92,7 +92,7 @@
<!-- Big number -->
<div class="flex items-baseline gap-1">
<span class="text-4xl font-bold tabular-nums text-foreground">
<span class="font-display text-4xl font-semibold tabular-nums text-foreground">
{formatNumber(currentValue)}
</span>
{#if config.unit}
+1 -1
View File
@@ -36,7 +36,7 @@
});
</script>
<div class="flex h-full flex-col rounded-xl border border-border bg-card p-4">
<div class="flex h-full flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
<div class="prose prose-sm prose-invert max-w-none flex-1 overflow-auto text-foreground">
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized with DOMPurify -->
{@html renderedContent}
@@ -80,7 +80,7 @@
}
</script>
<div class="flex h-full flex-col rounded-xl border border-border bg-card p-4">
<div class="flex h-full flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
<div class="mb-3 flex items-center gap-2">
<Rss class="h-4 w-4 text-muted-foreground" />
<span class="text-sm font-medium text-foreground">RSS Feed</span>
+13 -13
View File
@@ -46,7 +46,7 @@
let expanded = $state(false);
</script>
<div class="flex flex-col rounded-xl border border-border bg-card p-4">
<div class="flex flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
<!-- Header -->
<button
type="button"
@@ -63,28 +63,28 @@
<div class="mt-3 flex gap-1">
{#if statusCounts.online > 0}
<div
class="h-2 rounded-full bg-green-500"
class="h-2 rounded-full bg-status-online"
style="flex: {statusCounts.online}"
title="{statusCounts.online} online"
></div>
{/if}
{#if statusCounts.degraded > 0}
<div
class="h-2 rounded-full bg-yellow-500"
class="h-2 rounded-full bg-status-degraded"
style="flex: {statusCounts.degraded}"
title="{statusCounts.degraded} degraded"
></div>
{/if}
{#if statusCounts.offline > 0}
<div
class="h-2 rounded-full bg-red-500"
class="h-2 rounded-full bg-status-offline"
style="flex: {statusCounts.offline}"
title="{statusCounts.offline} offline"
></div>
{/if}
{#if statusCounts.unknown > 0}
<div
class="h-2 rounded-full bg-gray-500"
class="h-2 rounded-full bg-status-unknown"
style="flex: {statusCounts.unknown}"
title="{statusCounts.unknown} unknown"
></div>
@@ -95,25 +95,25 @@
<div class="mt-2 flex flex-wrap gap-3 text-xs">
{#if statusCounts.online > 0}
<span class="flex items-center gap-1">
<span class="inline-block h-2 w-2 rounded-full bg-green-500"></span>
<span class="inline-block h-2 w-2 rounded-full bg-status-online"></span>
{statusCounts.online} online
</span>
{/if}
{#if statusCounts.degraded > 0}
<span class="flex items-center gap-1">
<span class="inline-block h-2 w-2 rounded-full bg-yellow-500"></span>
<span class="inline-block h-2 w-2 rounded-full bg-status-degraded"></span>
{statusCounts.degraded} degraded
</span>
{/if}
{#if statusCounts.offline > 0}
<span class="flex items-center gap-1">
<span class="inline-block h-2 w-2 rounded-full bg-red-500"></span>
<span class="inline-block h-2 w-2 rounded-full bg-status-offline"></span>
{statusCounts.offline} offline
</span>
{/if}
{#if statusCounts.unknown > 0}
<span class="flex items-center gap-1">
<span class="inline-block h-2 w-2 rounded-full bg-gray-500"></span>
<span class="inline-block h-2 w-2 rounded-full bg-status-unknown"></span>
{statusCounts.unknown} unknown
</span>
{/if}
@@ -126,12 +126,12 @@
{@const status = app.statuses[0]?.status ?? 'unknown'}
{@const statusColor =
status === 'online'
? 'bg-green-500'
? 'bg-status-online'
: status === 'offline'
? 'bg-red-500'
? 'bg-status-offline'
: status === 'degraded'
? 'bg-yellow-500'
: 'bg-gray-500'}
? 'bg-status-degraded'
: 'bg-status-unknown'}
<div class="flex items-center justify-between text-xs">
<span class="text-foreground">{app.name}</span>
<span class="flex items-center gap-1">
@@ -21,15 +21,15 @@
const refreshMs = $derived((config.refreshInterval ?? 30) * 1000);
function thresholdColor(value: number): string {
if (value >= 85) return 'text-red-500';
if (value >= 60) return 'text-yellow-500';
return 'text-green-500';
if (value >= 85) return 'text-status-offline-ink';
if (value >= 60) return 'text-status-degraded-ink';
return 'text-status-online-ink';
}
function thresholdStroke(value: number): string {
if (value >= 85) return 'stroke-red-500';
if (value >= 60) return 'stroke-yellow-500';
return 'stroke-green-500';
if (value >= 85) return 'stroke-status-offline';
if (value >= 60) return 'stroke-status-degraded';
return 'stroke-status-online';
}
function thresholdTrack(_value: number): string {
@@ -80,7 +80,7 @@
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
</script>
<div class="flex h-full flex-col rounded-xl border border-border bg-card p-4">
<div class="flex h-full flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
<span class="mb-3 text-sm font-medium text-foreground">System Stats</span>
{#if loading}
@@ -4,6 +4,9 @@
import { tick } from 'svelte';
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
import MultiEntityPicker from '$lib/components/ui/MultiEntityPicker.svelte';
import Switch from '$lib/components/ui/Switch.svelte';
import Slider from '$lib/components/ui/Slider.svelte';
import Select from '$lib/components/ui/Select.svelte';
interface AppInfo {
id: string;
@@ -72,7 +75,7 @@
let rssShowSummary = $state((initialConfig.showSummary as boolean) ?? true);
// Calendar
let calendarUrlsRaw = $state((initialConfig.icalUrls as Array<{ url: string; color: string; label: string }>) ?? [{ url: '', color: '#6366f1', label: '' }]);
let calendarUrlsRaw = $state((initialConfig.icalUrls as Array<{ url: string; color: string; label: string }>) ?? [{ url: '', color: '#e8754f', label: '' }]);
let calendarDaysAhead = $state((initialConfig.daysAhead as number) ?? 7);
// Markdown
@@ -155,7 +158,7 @@
}
function addCalendarUrl() {
calendarUrlsRaw = [...calendarUrlsRaw, { url: '', color: '#6366f1', label: '' }];
calendarUrlsRaw = [...calendarUrlsRaw, { url: '', color: '#e8754f', label: '' }];
}
function removeCalendarUrl(index: number) {
@@ -163,7 +166,7 @@
}
// Helper for input styling
const inputClass = 'w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30';
const inputClass = 'w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30';
const labelClass = 'mb-1 block text-sm font-medium text-foreground';
let firstInput: HTMLElement | undefined = $state();
@@ -171,7 +174,7 @@
</script>
<div
class="rounded-xl border border-border bg-card p-4 shadow-lg"
class="rounded-xl border border-border bg-card p-4 shadow-[var(--shadow-soft)]"
transition:fade={{ duration: 100 }}
onkeydown={handleKeydown}
role="dialog"
@@ -201,11 +204,11 @@
bind:value={appSearchQuery}
bind:this={firstInput}
placeholder={$t('common.search') ?? 'Search apps...'}
class="w-full rounded-lg border border-input bg-background py-1.5 pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-ring/30"
class="w-full rounded-xl border border-input bg-background py-1.5 pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
/>
</div>
<!-- App grid -->
<div class="max-h-48 overflow-y-auto rounded-lg border border-input bg-background p-1">
<div class="max-h-48 overflow-y-auto rounded-xl border border-input bg-background p-1">
{#if filteredApps.length === 0}
<p class="py-4 text-center text-xs text-muted-foreground">{$t('common.no_results') ?? 'No apps found'}</p>
{:else}
@@ -269,13 +272,12 @@
</label>
</div>
<div>
<label class={labelClass}>{$t('widget.format') ?? 'Format'}
<select bind:value={noteFormat} class={inputClass}>
<div class={labelClass}>{$t('widget.format') ?? 'Format'}</div>
<Select bind:value={noteFormat}>
<option value="markdown">Markdown</option>
<option value="text">Plain Text</option>
<option value="html">HTML</option>
</select>
</label>
</Select>
</div>
{:else if widgetType === 'embed'}
@@ -285,9 +287,8 @@
</label>
</div>
<div>
<label class={labelClass}>{$t('widget.height') ?? 'Height'} ({embedHeight}px)
<input type="range" min="100" max="800" bind:value={embedHeight} class="w-full accent-primary" />
</label>
<div class={labelClass}>{$t('widget.height') ?? 'Height'} ({embedHeight}px)</div>
<Slider min={100} max={800} bind:value={embedHeight} ariaLabel="Height" />
</div>
<div>
<label class={labelClass}>Sandbox
@@ -318,16 +319,15 @@
</label>
</div>
<div>
<label class={labelClass}>{$t('widget.style') ?? 'Style'}
<select bind:value={clockStyle} class={inputClass}>
<div class={labelClass}>{$t('widget.style') ?? 'Style'}</div>
<Select bind:value={clockStyle}>
<option value="digital">Digital</option>
<option value="analog">Analog</option>
<option value="24h">24h</option>
</select>
</label>
</Select>
</div>
<label class="flex items-center gap-2 text-sm text-foreground">
<input type="checkbox" bind:checked={clockShowWeather} class="h-3.5 w-3.5 rounded border-input accent-primary" />
<label class="flex cursor-pointer items-center gap-3 text-sm text-foreground">
<Switch bind:checked={clockShowWeather} size="sm" ariaLabel={$t('widget.show_weather') ?? 'Show Weather'} />
{$t('widget.show_weather') ?? 'Show Weather'}
</label>
{#if clockShowWeather}
@@ -352,18 +352,16 @@
</label>
</div>
<div>
<label class={labelClass}>Source Type
<select bind:value={sysStatsSourceType} class={inputClass}>
<div class={labelClass}>Source Type</div>
<Select bind:value={sysStatsSourceType}>
<option value="glances">Glances</option>
<option value="prometheus">Prometheus</option>
<option value="custom">Custom</option>
</select>
</label>
</Select>
</div>
<div>
<label class={labelClass}>Refresh ({sysStatsRefreshInterval}s)
<input type="range" min="5" max="300" bind:value={sysStatsRefreshInterval} class="w-full accent-primary" />
</label>
<div class={labelClass}>Refresh ({sysStatsRefreshInterval}s)</div>
<Slider min={5} max={300} bind:value={sysStatsRefreshInterval} ariaLabel="Refresh interval" />
</div>
{:else if widgetType === 'rss'}
@@ -373,12 +371,11 @@
</label>
</div>
<div>
<label class={labelClass}>Max Items ({rssMaxItems})
<input type="range" min="1" max="50" bind:value={rssMaxItems} class="w-full accent-primary" />
</label>
<div class={labelClass}>Max Items ({rssMaxItems})</div>
<Slider min={1} max={50} bind:value={rssMaxItems} ariaLabel="Max items" />
</div>
<label class="flex items-center gap-2 text-sm text-foreground">
<input type="checkbox" bind:checked={rssShowSummary} class="h-3.5 w-3.5 rounded border-input accent-primary" />
<label class="flex cursor-pointer items-center gap-3 text-sm text-foreground">
<Switch bind:checked={rssShowSummary} size="sm" ariaLabel="Show Summary" />
Show Summary
</label>
@@ -398,9 +395,8 @@
<button type="button" onclick={addCalendarUrl} class="text-xs text-primary hover:underline">+ Add URL</button>
</div>
<div>
<label class={labelClass}>Days Ahead ({calendarDaysAhead})
<input type="range" min="1" max="30" bind:value={calendarDaysAhead} class="w-full accent-primary" />
</label>
<div class={labelClass}>Days Ahead ({calendarDaysAhead})</div>
<Slider min={1} max={30} bind:value={calendarDaysAhead} ariaLabel="Days ahead" />
</div>
{:else if widgetType === 'markdown'}
@@ -417,13 +413,12 @@
</label>
</div>
<div>
<label class={labelClass}>Source
<select bind:value={metricSource} class={inputClass}>
<div class={labelClass}>Source</div>
<Select bind:value={metricSource}>
<option value="static">Static</option>
<option value="json">JSON Endpoint</option>
<option value="prometheus">Prometheus</option>
</select>
</label>
</Select>
</div>
{#if metricSource === 'static'}
<div>
@@ -461,9 +456,8 @@
</label>
</div>
<div>
<label class={labelClass}>Refresh ({metricRefreshInterval}s)
<input type="range" min="5" max="300" bind:value={metricRefreshInterval} class="w-full accent-primary" />
</label>
<div class={labelClass}>Refresh ({metricRefreshInterval}s)</div>
<Slider min={5} max={300} bind:value={metricRefreshInterval} ariaLabel="Refresh interval" />
</div>
</div>
@@ -482,8 +476,8 @@
{/each}
<button type="button" onclick={addLinkGroupLink} class="text-xs text-primary hover:underline">+ Add Link</button>
</div>
<label class="flex items-center gap-2 text-sm text-foreground">
<input type="checkbox" bind:checked={linkGroupCollapsible} class="h-3.5 w-3.5 rounded border-input accent-primary" />
<label class="flex cursor-pointer items-center gap-3 text-sm text-foreground">
<Switch bind:checked={linkGroupCollapsible} size="sm" ariaLabel="Collapsible" />
Collapsible
</label>
@@ -494,39 +488,35 @@
</label>
</div>
<div>
<label class={labelClass}>Type
<select bind:value={cameraType} class={inputClass}>
<div class={labelClass}>Type</div>
<Select bind:value={cameraType}>
<option value="image">Image</option>
<option value="mjpeg">MJPEG</option>
<option value="hls">HLS</option>
</select>
</label>
</Select>
</div>
<div>
<label class={labelClass}>Refresh ({cameraRefreshInterval}s)
<input type="range" min="1" max="60" bind:value={cameraRefreshInterval} class="w-full accent-primary" />
</label>
<div class={labelClass}>Refresh ({cameraRefreshInterval}s)</div>
<Slider min={1} max={60} bind:value={cameraRefreshInterval} ariaLabel="Refresh interval" />
</div>
<div>
<label class={labelClass}>Aspect Ratio
<select bind:value={cameraAspectRatio} class={inputClass}>
<div class={labelClass}>Aspect Ratio</div>
<Select bind:value={cameraAspectRatio}>
<option value="16/9">16:9</option>
<option value="4/3">4:3</option>
<option value="1/1">1:1</option>
</select>
</label>
</Select>
</div>
{:else if widgetType === 'integration'}
<div>
<label class={labelClass}>{$t('widget.app') ?? 'App'}
<select bind:value={integrationAppId} class={inputClass} bind:this={firstInput}>
<div class={labelClass}>{$t('widget.app') ?? 'App'}</div>
<Select bind:value={integrationAppId}>
<option value="">Select app...</option>
{#each apps as app (app.id)}
<option value={app.id}>{app.name}</option>
{/each}
</select>
</label>
</Select>
</div>
<div>
<label class={labelClass}>Endpoint ID
@@ -534,9 +524,8 @@
</label>
</div>
<div>
<label class={labelClass}>Refresh ({integrationRefreshInterval}s)
<input type="range" min="10" max="600" bind:value={integrationRefreshInterval} class="w-full accent-primary" />
</label>
<div class={labelClass}>Refresh ({integrationRefreshInterval}s)</div>
<Slider min={10} max={600} bind:value={integrationRefreshInterval} ariaLabel="Refresh interval" />
</div>
{/if}
</div>
@@ -548,7 +537,7 @@
{$t('common.cancel') ?? 'Cancel'}
</button>
<button type="button" onclick={handleSave}
class="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90">
class="rounded-xl bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90">
{mode === 'create' ? ($t('common.add') ?? 'Add') : ($t('common.save') ?? 'Save')}
</button>
</div>
@@ -9,6 +9,10 @@
import EntityPicker from '$lib/components/ui/EntityPicker.svelte';
import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte';
import type { EntityPickerItem } from '$lib/components/ui/EntityPicker.svelte';
import Switch from '$lib/components/ui/Switch.svelte';
import Slider from '$lib/components/ui/Slider.svelte';
import Select from '$lib/components/ui/Select.svelte';
import Checkbox from '$lib/components/ui/Checkbox.svelte';
interface Props {
sectionId: string;
@@ -60,7 +64,7 @@
// Calendar fields
let calendarUrls = $state<Array<{ url: string; color: string; label: string }>>([
{ url: '', color: '#6366f1', label: '' }
{ url: '', color: '#e8754f', label: '' }
]);
let calendarDaysAhead = $state(7);
@@ -198,7 +202,7 @@
rssFeedUrl = '';
rssMaxItems = 10;
rssShowSummary = true;
calendarUrls = [{ url: '', color: '#6366f1', label: '' }];
calendarUrls = [{ url: '', color: '#e8754f', label: '' }];
calendarDaysAhead = 7;
markdownContent = '';
metricLabel = '';
@@ -350,7 +354,7 @@
}
function addCalendarUrl() {
calendarUrls = [...calendarUrls, { url: '', color: '#6366f1', label: '' }];
calendarUrls = [...calendarUrls, { url: '', color: '#e8754f', label: '' }];
}
function removeCalendarUrl(index: number) {
@@ -367,7 +371,7 @@
// Input CSS class for reuse
const inputClass =
'w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30';
'w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30';
</script>
<div class="mb-3 rounded-lg border border-border bg-muted/50 p-3">
@@ -505,14 +509,13 @@
</div>
<div>
<span class="mb-1 block text-sm font-medium text-foreground">Select Apps</span>
<div class="max-h-40 space-y-1 overflow-y-auto rounded-lg border border-input bg-background p-2">
<div class="max-h-40 space-y-1 overflow-y-auto rounded-xl border border-input bg-background p-2">
{#each apps as app (app.id)}
<label class="flex items-center gap-2 rounded px-2 py-1 text-sm text-foreground hover:bg-accent">
<input
type="checkbox"
<label class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-sm text-foreground hover:bg-accent">
<Checkbox
checked={statusAppIds.includes(app.id)}
onchange={() => toggleStatusApp(app.id)}
class="h-4 w-4 rounded border-input accent-primary"
ariaLabel={app.name}
/>
{app.name}
</label>
@@ -549,12 +552,8 @@
<p class="mt-0.5 text-xs text-muted-foreground">Leave empty for local time</p>
</div>
<div>
<label class="flex items-center gap-2 text-sm font-medium text-foreground">
<input
type="checkbox"
bind:checked={clockShowWeather}
class="h-4 w-4 rounded border-input accent-primary"
/>
<label class="flex cursor-pointer items-center gap-3 text-sm font-medium text-foreground">
<Switch bind:checked={clockShowWeather} ariaLabel="Show Weather" />
Show Weather
</label>
</div>
@@ -599,26 +598,24 @@
</div>
<div>
<label for="sys-type-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Source Type</label>
<select
<Select
id="sys-type-{sectionId}"
bind:value={sysStatsSourceType}
class={inputClass}
>
<option value="glances">Glances</option>
<option value="prometheus">Prometheus</option>
<option value="custom">Custom JSON</option>
</select>
</Select>
</div>
<div>
<span class="mb-1 block text-sm font-medium text-foreground">Metrics</span>
<div class="flex flex-wrap gap-2">
{#each ['cpu', 'ram', 'disk', 'swap', 'network'] as metric (metric)}
<label class="flex items-center gap-1.5 rounded-md border border-input px-2 py-1 text-sm text-foreground hover:bg-accent">
<input
type="checkbox"
<label class="flex cursor-pointer items-center gap-2 rounded-xl border border-input px-2.5 py-1 text-sm text-foreground hover:bg-accent">
<Checkbox
checked={sysStatsMetrics.includes(metric)}
onchange={() => toggleSysStatsMetric(metric)}
class="h-3.5 w-3.5 rounded border-input accent-primary"
ariaLabel={metric}
/>
<span class="capitalize">{metric}</span>
</label>
@@ -629,14 +626,13 @@
<label for="sys-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Refresh Interval: {sysStatsRefreshInterval}s
</label>
<input
<Slider
id="sys-refresh-{sectionId}"
type="range"
bind:value={sysStatsRefreshInterval}
min="5"
max="300"
step="5"
class="w-full accent-primary"
min={5}
max={300}
step={5}
ariaLabel="Refresh interval"
/>
</div>
</div>
@@ -658,23 +654,18 @@
<label for="rss-max-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Max Items: {rssMaxItems}
</label>
<input
<Slider
id="rss-max-{sectionId}"
type="range"
bind:value={rssMaxItems}
min="3"
max="30"
step="1"
class="w-full accent-primary"
min={3}
max={30}
step={1}
ariaLabel="Max items"
/>
</div>
<div>
<label class="flex items-center gap-2 text-sm font-medium text-foreground">
<input
type="checkbox"
bind:checked={rssShowSummary}
class="h-4 w-4 rounded border-input accent-primary"
/>
<label class="flex cursor-pointer items-center gap-3 text-sm font-medium text-foreground">
<Switch bind:checked={rssShowSummary} ariaLabel="Show Summaries" />
Show Summaries
</label>
</div>
@@ -704,7 +695,7 @@
<input
type="color"
bind:value={calendarUrls[i].color}
class="h-9 w-9 cursor-pointer rounded-lg border border-input bg-background"
class="h-9 w-9 cursor-pointer rounded-xl border border-input bg-background"
title="Calendar color"
/>
</div>
@@ -733,14 +724,13 @@
<label for="cal-days-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Days Ahead: {calendarDaysAhead}
</label>
<input
<Slider
id="cal-days-{sectionId}"
type="range"
bind:value={calendarDaysAhead}
min="1"
max="30"
step="1"
class="w-full accent-primary"
min={1}
max={30}
step={1}
ariaLabel="Days ahead"
/>
</div>
</div>
@@ -855,14 +845,13 @@
<label for="metric-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Refresh: {metricRefreshInterval}s
</label>
<input
<Slider
id="metric-refresh-{sectionId}"
type="range"
bind:value={metricRefreshInterval}
min="10"
max="600"
step="10"
class="w-full accent-primary"
min={10}
max={600}
step={10}
ariaLabel="Refresh interval"
/>
</div>
</div>
@@ -916,12 +905,8 @@
</button>
</div>
<div>
<label class="flex items-center gap-2 text-sm font-medium text-foreground">
<input
type="checkbox"
bind:checked={linkGroupCollapsible}
class="h-4 w-4 rounded border-input accent-primary"
/>
<label class="flex cursor-pointer items-center gap-3 text-sm font-medium text-foreground">
<Switch bind:checked={linkGroupCollapsible} ariaLabel="Collapsible" />
Collapsible
</label>
</div>
@@ -942,43 +927,40 @@
</div>
<div>
<label for="cam-type-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Stream Type</label>
<select
<Select
id="cam-type-{sectionId}"
bind:value={cameraType}
class={inputClass}
>
<option value="image">Snapshot (Image)</option>
<option value="mjpeg">MJPEG Stream</option>
<option value="hls">HLS Stream</option>
</select>
</Select>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label for="cam-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Refresh: {cameraRefreshInterval}s
</label>
<input
<Slider
id="cam-refresh-{sectionId}"
type="range"
bind:value={cameraRefreshInterval}
min="1"
max="120"
step="1"
class="w-full accent-primary"
min={1}
max={120}
step={1}
ariaLabel="Refresh interval"
/>
</div>
<div>
<label for="cam-ratio-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Aspect Ratio</label>
<select
<Select
id="cam-ratio-{sectionId}"
bind:value={cameraAspectRatio}
class={inputClass}
>
<option value="16/9">16:9</option>
<option value="4/3">4:3</option>
<option value="1/1">1:1</option>
<option value="21/9">21:9</option>
</select>
</Select>
</div>
</div>
</div>
@@ -990,44 +972,41 @@
{#if integrationApps.length === 0}
<p class="text-sm text-muted-foreground">No apps with integrations configured. Add an integration to an app first.</p>
{:else}
<select
<Select
id="int-app-{sectionId}"
bind:value={integrationAppId}
class={inputClass}
>
<option value="">Select an app...</option>
{#each integrationApps as app (app.id)}
<option value={app.id}>{app.name} ({app.integrationType})</option>
{/each}
</select>
</Select>
{/if}
</div>
{#if integrationAppId && integrationEndpoints.length > 0}
<div>
<label for="int-endpoint-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Data Endpoint</label>
<select
<Select
id="int-endpoint-{sectionId}"
bind:value={integrationEndpointId}
class={inputClass}
>
<option value="">Select endpoint...</option>
{#each integrationEndpoints as ep (ep.id)}
<option value={ep.id}>{ep.name}</option>
{/each}
</select>
</Select>
</div>
<div>
<label for="int-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Refresh: {integrationRefreshInterval}s
</label>
<input
<Slider
id="int-refresh-{sectionId}"
type="range"
bind:value={integrationRefreshInterval}
min="10"
max="600"
step="10"
class="w-full accent-primary"
min={10}
max={600}
step={10}
ariaLabel="Refresh interval"
/>
</div>
{/if}
@@ -1038,7 +1017,7 @@
<button
type="button"
onclick={handleSubmitWidget}
class="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
class="rounded-xl bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
{$t('common.add')}
</button>
@@ -46,7 +46,7 @@
<div class="absolute inset-0 z-10 rounded-xl bg-black/5 transition-opacity">
<!-- Top-left: drag handle -->
<div class="absolute left-1.5 top-1.5">
<div class="cursor-grab rounded-md bg-card/90 p-1 text-muted-foreground shadow-sm backdrop-blur-sm" title="Drag to reorder">
<div class="cursor-grab rounded-md bg-card/90 p-1 text-muted-foreground shadow-[var(--shadow-soft)] backdrop-blur-sm" title="Drag to reorder">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="9" cy="5" r="1" /><circle cx="15" cy="5" r="1" />
<circle cx="9" cy="12" r="1" /><circle cx="15" cy="12" r="1" />
@@ -62,7 +62,7 @@
<button
type="button"
onclick={() => { showSizePicker = !showSizePicker; previewSpan = null; }}
class="rounded-md bg-card/90 p-1.5 text-muted-foreground shadow-sm backdrop-blur-sm transition-colors
class="rounded-md bg-card/90 p-1.5 text-muted-foreground shadow-[var(--shadow-soft)] backdrop-blur-sm transition-colors
{showSizePicker ? 'bg-primary text-primary-foreground' : 'hover:bg-accent hover:text-foreground'}"
title={$t('widget.resize') ?? 'Resize'}
>
@@ -78,7 +78,7 @@
<button
type="button"
onclick={() => onEdit(widgetId)}
class="rounded-md bg-card/90 p-1.5 text-muted-foreground shadow-sm backdrop-blur-sm transition-colors hover:bg-primary hover:text-primary-foreground"
class="rounded-md bg-card/90 p-1.5 text-muted-foreground shadow-[var(--shadow-soft)] backdrop-blur-sm transition-colors hover:bg-primary hover:text-primary-foreground"
title={$t('common.edit') ?? 'Edit'}
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -91,7 +91,7 @@
<button
type="button"
onclick={() => { showDeleteConfirm = true; }}
class="rounded-md bg-card/90 p-1.5 text-muted-foreground shadow-sm backdrop-blur-sm transition-colors hover:bg-destructive hover:text-destructive-foreground"
class="rounded-md bg-card/90 p-1.5 text-muted-foreground shadow-[var(--shadow-soft)] backdrop-blur-sm transition-colors hover:bg-destructive hover:text-destructive-foreground"
title={$t('common.delete') ?? 'Delete'}
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -103,7 +103,7 @@
<!-- Size picker popover -->
{#if showSizePicker && onResize}
<div class="absolute right-1.5 top-10 z-20 rounded-lg border border-border bg-card p-2 shadow-xl backdrop-blur-sm">
<div class="absolute right-1.5 top-10 z-20 rounded-xl border border-border bg-card p-2 shadow-[var(--shadow-lift)] backdrop-blur-sm">
<div class="mb-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{$t('widget.width') ?? 'Width'}
</div>
@@ -58,7 +58,7 @@
</script>
{#snippet skeleton()}
<div class="flex h-full items-center justify-center rounded-xl border border-border bg-card p-4">
<div class="flex h-full items-center justify-center rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
<span class="text-xs text-muted-foreground"></span>
</div>
{/snippet}
@@ -163,7 +163,7 @@
}} />
{/await}
{:else}
<div class="flex h-full items-center justify-center rounded-xl border border-border bg-card p-4">
<div class="flex h-full items-center justify-center rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
<span class="text-xs text-muted-foreground">{$t('widget.type', { values: { type: widget.type } })}</span>
</div>
{/if}
@@ -82,7 +82,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mx-4 w-full max-w-lg rounded-2xl border border-border bg-card shadow-2xl lg:max-w-2xl"
class="mx-4 w-full max-w-lg rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-lift)] lg:max-w-2xl"
onclick={(e) => e.stopPropagation()}
transition:scale={{ start: 0.95, duration: 150 }}
>
@@ -110,7 +110,7 @@
type="text"
bind:value={filterQuery}
placeholder={$t('widget.search_type') ?? 'Search widget types...'}
class="w-full rounded-lg border border-input bg-background py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
class="w-full rounded-xl border border-input bg-background py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
/>
</div>
</div>
@@ -127,7 +127,7 @@
onclick={() => onSelect(wt.value)}
class="flex items-start gap-3 rounded-xl px-3 py-3 text-left transition-colors hover:bg-accent"
>
<div class="mt-0.5 shrink-0 rounded-lg bg-primary/10 p-2 text-primary">
<div class="mt-0.5 shrink-0 rounded-xl bg-primary/10 p-2 text-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
{#each iconFor(wt.value).split('|') as segment, si (si)}
{#if segment.includes('m') || segment.includes('M') || segment.includes('a') || segment.includes('z') || segment.includes('A') || segment.includes('c') || segment.includes('l') || segment.includes('v') || segment.includes('h') || segment.includes('V') || segment.includes('H')}
@@ -9,9 +9,12 @@
const severityStyles = $derived.by(() => {
switch (data.severity) {
case 'critical': return 'border-red-500/50 bg-red-500/10 text-red-400';
case 'warning': return 'border-yellow-500/50 bg-yellow-500/10 text-yellow-400';
default: return 'border-blue-500/50 bg-blue-500/10 text-blue-400';
case 'critical':
return 'border-status-offline/40 bg-status-offline/10 text-status-offline-ink';
case 'warning':
return 'border-status-degraded/40 bg-status-degraded/10 text-status-degraded-ink';
default:
return 'border-room-sky/40 bg-room-sky/10 text-room-sky';
}
});
@@ -20,7 +23,7 @@
);
</script>
<div class="flex items-start gap-3 rounded-lg border p-3 {severityStyles}">
<div class="flex items-start gap-3 rounded-2xl border p-3.5 {severityStyles}">
<span class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-current text-xs font-bold">
{severityIcon}
</span>
@@ -15,7 +15,15 @@
data.labels.length > 0 ? Math.max(100 / data.labels.length - 2, 4) : 10
);
const defaultColors = ['#6366f1', '#22c55e', '#eab308', '#ef4444', '#06b6d4'];
// Cozy "room" palette for multi-series charts
const defaultColors = [
'var(--room-terra)',
'var(--room-sky)',
'var(--room-sage)',
'var(--room-butter)',
'var(--room-lav)',
'var(--room-peach)'
];
</script>
<div class="p-3">
@@ -12,9 +12,9 @@
const color = $derived.by(() => {
const warn = data.thresholds?.warning ?? 60;
const crit = data.thresholds?.critical ?? 85;
if (percentage >= crit) return '#ef4444'; // red
if (percentage >= warn) return '#eab308'; // yellow
return '#22c55e'; // green
if (percentage >= crit) return 'var(--status-offline)';
if (percentage >= warn) return 'var(--status-degraded)';
return 'var(--status-online)';
});
// SVG circle math
@@ -12,13 +12,13 @@
);
const trendColor = $derived(
data.trend === 'up' ? 'text-green-500' : data.trend === 'down' ? 'text-red-500' : 'text-muted-foreground'
data.trend === 'up' ? 'text-status-online-ink' : data.trend === 'down' ? 'text-status-offline-ink' : 'text-muted-foreground'
);
</script>
<div class="flex flex-col items-center justify-center gap-1 p-4 text-center">
<div class="flex items-baseline gap-1">
<span class="text-3xl font-bold text-foreground">{data.value}</span>
<span class="font-display text-3xl font-semibold text-foreground">{data.value}</span>
{#if data.unit}
<span class="text-sm text-muted-foreground">{data.unit}</span>
{/if}

Some files were not shown because too many files have changed in this diff Show More