Files
ImpTune/imptune/static/app.css
T
kawaandClaude Opus 5 b397d3dc3d feat: memory-only sessions on HTTP, streamed exports, UI refresh
Session

- COOKIE_SECURE=false no longer persists the owner key for ten years.
  services/session.cookie_kwargs() drops max_age in that mode, so the
  browser holds the key in memory and the session ends with the window.
  Everything still persists server-side; only the browser link is
  temporary. base.html shows a warning banner (FR/EN) and an extra
  paragraph in the onboarding modal, and the README explains the
  trade-off and the backup-key escape hatch.
- Both cookie writers (middleware, POST /session/restore) go through
  cookie_kwargs() so the policy cannot drift between them.
- The CSRF guard on /session/restore compared request.url.scheme against
  the Origin header. Behind a TLS-terminating proxy uvicorn sees http
  while the browser sends https, so every legitimate restore was
  rejected with 403. It now compares hosts only, including
  X-Forwarded-Host.
- /static/*, /favicon.ico and /robots.txt skip the middleware. Each
  cookieless hit was inserting an Owner row no browser could ever use.

Reliability

- Malformed printer-form FK fields no longer escape as HTTP 500:
  a non-numeric client_id/driver_id raised ValueError and an unknown
  driver_id hit a FOREIGN KEY constraint. Both are now 400/404 HTMX
  fragments, and the duplicated field checks moved into
  _validate_fields().
- Package exports stream. build_intunewin() encrypts the inner ZIP in
  1 MB chunks against temp files with a streaming HMAC and SHA256, and
  both endpoints serve the result with FileResponse plus a background
  cleanup task. A 100 MB driver used to be held in memory three or four
  times over per concurrent download. The byte layout is unchanged.
- FileResponse also escapes the download filename, which was previously
  interpolated raw into Content-Disposition.
- python-multipart >= 0.0.18 (CVE-2024-53981, reachable from
  /drivers/upload) and Pillow >= 10.3 (CVE-2024-28219, reachable from
  icon upload).
- icons.py reads cfg.ICONS_DIR instead of re-deriving the path from
  DATA_DIR, matching the .intunewin export.

UI

- Sidebar/topbar shell, inline SVG icon macros (partials/icons.html),
  card and data-table components, grouped printer list, and the
  dedicated /printers/new page replacing partials/printer_form.html.

Tests

- 194 pass with a bare `pytest tests/`: tests/conftest.py now forces
  cfg.COOKIE_SECURE = False like the e2e conftest already did, so the
  Secure cookie is no longer dropped over http://testserver.
- New coverage for the malformed-FK guards, the chunk-boundary cases in
  the encrypt loop (every residue mod _CHUNK plus a multi-megabyte
  payload), temp-dir cleanup after both exports, and the whole
  COOKIE_SECURE matrix.
- test_printer_edit.py located the Edit button by its translated label,
  so it only passed on English-locale machines. It now targets the
  showModal() hook, which also cuts the e2e run from 84s to 15s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:58:49 +02:00

1422 lines
37 KiB
CSS

/* ==========================================================================
ImpTune — application shell + component layer on top of Pico CSS.
Design direction: a workbench for a build pipeline (driver -> printer ->
package), not a dashboard. Prose is set in the system UI face; every value
the machine cares about (IPs, port names, INF driver names, PowerShell
commands) is set in monospace. That split is the visual identity.
Theme selectors mirror Pico's own so equal specificity + later source order
wins: light = :root:not([data-theme=dark]) ; dark = the two dark selectors.
`data-theme="auto"` is not `light`, so it follows the OS preference.
========================================================================== */
/* --- Tokens: light (paper) ----------------------------------------------- */
:root:not([data-theme="dark"]) {
--im-bg: #f2f2ef;
--im-surface: #ffffff;
--im-surface-2: #f8f8f5;
--im-surface-3: #eeeeea;
--im-line: #e2e3de;
--im-line-strong: #cdcfc8;
--im-text: #191f26;
--im-text-dim: #59626c;
--im-text-faint: #868f99;
--im-accent: #0d6f79;
--im-accent-hover: #0a575f;
--im-accent-soft: #dff0f1;
--im-accent-line: #a9d5d9;
--im-ok: #2b7048;
--im-ok-soft: #e2f0e7;
--im-warn: #8c5a0c;
--im-warn-soft: #fbeed7;
--im-danger: #a32f2f;
--im-danger-soft: #f8e4e2;
--im-shadow: 0 1px 2px rgba(20, 26, 32, .05), 0 8px 24px -12px rgba(20, 26, 32, .18);
--im-ring: rgba(13, 111, 121, .35);
/* Pico remap */
--pico-background-color: var(--im-bg);
--pico-color: var(--im-text);
--pico-muted-color: var(--im-text-dim);
--pico-muted-border-color: var(--im-line);
--pico-border-color: var(--im-line);
--pico-h1-color: var(--im-text);
--pico-h2-color: var(--im-text);
--pico-h3-color: var(--im-text);
--pico-h4-color: var(--im-text);
--pico-h5-color: var(--im-text);
--pico-h6-color: var(--im-text-dim);
--pico-primary: var(--im-accent);
--pico-primary-background: var(--im-accent);
--pico-primary-border: var(--im-accent);
--pico-primary-hover: var(--im-accent-hover);
--pico-primary-hover-background: var(--im-accent-hover);
--pico-primary-hover-border: var(--im-accent-hover);
--pico-primary-underline: var(--im-accent-line);
--pico-primary-focus: var(--im-ring);
--pico-primary-inverse: #ffffff;
--pico-secondary: var(--im-text-dim);
--pico-secondary-background: var(--im-surface-3);
--pico-secondary-border: var(--im-line-strong);
--pico-secondary-hover: var(--im-text);
--pico-secondary-hover-background: var(--im-line);
--pico-secondary-hover-border: var(--im-line-strong);
--pico-secondary-inverse: var(--im-text);
--pico-card-background-color: var(--im-surface);
--pico-card-border-color: var(--im-line);
--pico-card-sectioning-background-color: var(--im-surface-2);
--pico-card-box-shadow: none;
--pico-form-element-background-color: var(--im-surface);
--pico-form-element-border-color: var(--im-line-strong);
--pico-form-element-color: var(--im-text);
--pico-form-element-placeholder-color: var(--im-text-faint);
--pico-form-element-active-border-color: var(--im-accent);
--pico-form-element-focus-color: var(--im-accent);
--pico-code-background-color: var(--im-surface-3);
--pico-code-color: var(--im-text);
--pico-modal-overlay-background-color: rgba(24, 30, 36, .42);
}
/* --- Tokens: dark (ink) -------------------------------------------------- */
@media only screen and (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--im-bg: #0f151b;
--im-surface: #161d24;
--im-surface-2: #1b232b;
--im-surface-3: #222c35;
--im-line: #26313a;
--im-line-strong: #35434e;
--im-text: #e5eaee;
--im-text-dim: #98a5b0;
--im-text-faint: #6c7a86;
--im-accent: #33bec6;
--im-accent-hover: #5ad3d9;
--im-accent-soft: #10333a;
--im-accent-line: #1f5d66;
--im-ok: #58c185;
--im-ok-soft: #14301f;
--im-warn: #dfa757;
--im-warn-soft: #33260f;
--im-danger: #e8746c;
--im-danger-soft: #35191a;
--im-shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 12px 32px -14px rgba(0, 0, 0, .7);
--im-ring: rgba(51, 190, 198, .4);
--pico-background-color: var(--im-bg);
--pico-color: var(--im-text);
--pico-muted-color: var(--im-text-dim);
--pico-muted-border-color: var(--im-line);
--pico-border-color: var(--im-line);
--pico-h1-color: var(--im-text);
--pico-h2-color: var(--im-text);
--pico-h3-color: var(--im-text);
--pico-h4-color: var(--im-text);
--pico-h5-color: var(--im-text);
--pico-h6-color: var(--im-text-dim);
--pico-primary: var(--im-accent);
--pico-primary-background: var(--im-accent);
--pico-primary-border: var(--im-accent);
--pico-primary-hover: var(--im-accent-hover);
--pico-primary-hover-background: var(--im-accent-hover);
--pico-primary-hover-border: var(--im-accent-hover);
--pico-primary-underline: var(--im-accent-line);
--pico-primary-focus: var(--im-ring);
--pico-primary-inverse: #06232a;
--pico-secondary: var(--im-text-dim);
--pico-secondary-background: var(--im-surface-3);
--pico-secondary-border: var(--im-line-strong);
--pico-secondary-hover: var(--im-text);
--pico-secondary-hover-background: var(--im-line-strong);
--pico-secondary-hover-border: var(--im-line-strong);
--pico-secondary-inverse: var(--im-text);
--pico-card-background-color: var(--im-surface);
--pico-card-border-color: var(--im-line);
--pico-card-sectioning-background-color: var(--im-surface-2);
--pico-card-box-shadow: none;
--pico-form-element-background-color: var(--im-surface-2);
--pico-form-element-border-color: var(--im-line-strong);
--pico-form-element-color: var(--im-text);
--pico-form-element-placeholder-color: var(--im-text-faint);
--pico-form-element-active-border-color: var(--im-accent);
--pico-form-element-focus-color: var(--im-accent);
--pico-code-background-color: var(--im-surface-3);
--pico-code-color: var(--im-text);
--pico-modal-overlay-background-color: rgba(4, 8, 11, .62);
}
}
[data-theme="dark"] {
--im-bg: #0f151b;
--im-surface: #161d24;
--im-surface-2: #1b232b;
--im-surface-3: #222c35;
--im-line: #26313a;
--im-line-strong: #35434e;
--im-text: #e5eaee;
--im-text-dim: #98a5b0;
--im-text-faint: #6c7a86;
--im-accent: #33bec6;
--im-accent-hover: #5ad3d9;
--im-accent-soft: #10333a;
--im-accent-line: #1f5d66;
--im-ok: #58c185;
--im-ok-soft: #14301f;
--im-warn: #dfa757;
--im-warn-soft: #33260f;
--im-danger: #e8746c;
--im-danger-soft: #35191a;
--im-shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 12px 32px -14px rgba(0, 0, 0, .7);
--im-ring: rgba(51, 190, 198, .4);
--pico-background-color: var(--im-bg);
--pico-color: var(--im-text);
--pico-muted-color: var(--im-text-dim);
--pico-muted-border-color: var(--im-line);
--pico-border-color: var(--im-line);
--pico-h1-color: var(--im-text);
--pico-h2-color: var(--im-text);
--pico-h3-color: var(--im-text);
--pico-h4-color: var(--im-text);
--pico-h5-color: var(--im-text);
--pico-h6-color: var(--im-text-dim);
--pico-primary: var(--im-accent);
--pico-primary-background: var(--im-accent);
--pico-primary-border: var(--im-accent);
--pico-primary-hover: var(--im-accent-hover);
--pico-primary-hover-background: var(--im-accent-hover);
--pico-primary-hover-border: var(--im-accent-hover);
--pico-primary-underline: var(--im-accent-line);
--pico-primary-focus: var(--im-ring);
--pico-primary-inverse: #06232a;
--pico-secondary: var(--im-text-dim);
--pico-secondary-background: var(--im-surface-3);
--pico-secondary-border: var(--im-line-strong);
--pico-secondary-hover: var(--im-text);
--pico-secondary-hover-background: var(--im-line-strong);
--pico-secondary-hover-border: var(--im-line-strong);
--pico-secondary-inverse: var(--im-text);
--pico-card-background-color: var(--im-surface);
--pico-card-border-color: var(--im-line);
--pico-card-sectioning-background-color: var(--im-surface-2);
--pico-card-box-shadow: none;
--pico-form-element-background-color: var(--im-surface-2);
--pico-form-element-border-color: var(--im-line-strong);
--pico-form-element-color: var(--im-text);
--pico-form-element-placeholder-color: var(--im-text-faint);
--pico-form-element-active-border-color: var(--im-accent);
--pico-form-element-focus-color: var(--im-accent);
--pico-code-background-color: var(--im-surface-3);
--pico-code-color: var(--im-text);
--pico-modal-overlay-background-color: rgba(4, 8, 11, .62);
}
/* --- Typography + global rhythm ----------------------------------------- */
:root {
--im-mono: ui-monospace, "Cascadia Mono", "Cascadia Code", "SF Mono",
"JetBrains Mono", Consolas, "Liberation Mono", monospace;
--im-r: 8px;
--im-r-sm: 5px;
--im-sidebar-w: 236px;
--pico-font-family-sans-serif: system-ui, -apple-system, "Segoe UI Variable Text",
"Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--pico-font-family-monospace: var(--im-mono);
--pico-font-size: 93.75%; /* 15px base */
--pico-line-height: 1.55;
--pico-border-radius: var(--im-r-sm);
--pico-spacing: 1rem;
--pico-form-element-spacing-vertical: .55rem;
--pico-form-element-spacing-horizontal: .7rem;
--pico-typography-spacing-vertical: .9rem;
--pico-outline-width: 2px;
font-variant-numeric: tabular-nums;
}
body {
background: var(--im-bg);
color: var(--im-text);
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, h4 { letter-spacing: -.011em; font-weight: 600; }
/* Utility label — the recurring "field label" voice of a control panel. */
.eyebrow {
font-size: .688rem;
font-weight: 600;
letter-spacing: .085em;
text-transform: uppercase;
color: var(--im-text-faint);
margin: 0;
}
.mono { font-family: var(--im-mono); font-size: .84em; }
.dim { color: var(--im-text-dim); }
.faint { color: var(--im-text-faint); }
.nowrap { white-space: nowrap; }
.ico { flex: 0 0 auto; vertical-align: -.18em; }
:where(a, button, [role="button"], summary, input, select, textarea):focus-visible {
outline: 2px solid var(--im-accent);
outline-offset: 2px;
box-shadow: none;
}
/* ==========================================================================
App shell
========================================================================== */
.layout {
display: flex;
min-height: 100vh;
align-items: stretch;
}
.layout > * { box-sizing: border-box; }
nav.sidebar {
width: var(--im-sidebar-w);
flex: 0 0 var(--im-sidebar-w);
background: var(--im-surface);
border-right: 1px solid var(--im-line);
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
overscroll-behavior: contain;
}
nav.sidebar ul,
nav.sidebar li {
margin: 0;
padding: 0;
list-style: none;
display: block;
width: 100%;
}
/* Brand: wordmark + a printed-sheet mark built from the accent. */
.brand {
display: flex;
align-items: center;
gap: .6rem;
padding: 1.05rem 1rem .95rem;
text-decoration: none;
color: var(--im-text);
border-bottom: 1px solid var(--im-line);
}
.brand:hover { background: var(--im-surface-2); }
.brand-mark {
width: 26px;
height: 26px;
border-radius: 6px;
background: var(--im-accent);
color: var(--pico-primary-inverse);
display: grid;
place-items: center;
font-family: var(--im-mono);
font-size: .75rem;
font-weight: 700;
letter-spacing: -.03em;
flex: 0 0 auto;
}
.brand-name {
font-weight: 650;
font-size: 1.02rem;
letter-spacing: -.02em;
line-height: 1.1;
}
.brand-sub {
display: block;
font-size: .656rem;
font-weight: 500;
letter-spacing: .07em;
text-transform: uppercase;
color: var(--im-text-faint);
}
.nav-group { padding: .85rem 0 .2rem; }
.nav-group + .nav-group { border-top: 1px solid var(--im-line); }
.nav-group-label {
padding: 0 1rem .45rem;
font-size: .656rem;
font-weight: 600;
letter-spacing: .09em;
text-transform: uppercase;
color: var(--im-text-faint);
}
.sidebar-nav a {
display: flex;
align-items: center;
gap: .6rem;
box-sizing: border-box;
width: 100%;
padding: .5rem 1rem;
margin: 0;
text-decoration: none;
color: var(--im-text-dim);
font-size: .906rem;
font-weight: 500;
line-height: 1.4;
border-left: 2px solid transparent;
transition: background-color .12s ease, color .12s ease;
}
.sidebar-nav a .ico { color: var(--im-text-faint); transition: color .12s ease; }
.sidebar-nav a:hover {
background: var(--im-surface-2);
color: var(--im-text);
}
.sidebar-nav a:hover .ico { color: var(--im-text-dim); }
.sidebar-nav a.active {
color: var(--im-text);
font-weight: 600;
background: var(--im-accent-soft);
border-left-color: var(--im-accent);
}
.sidebar-nav a.active .ico { color: var(--im-accent); }
.sidebar-foot {
margin-top: auto;
border-top: 1px solid var(--im-line);
padding: .7rem .75rem .85rem;
}
.sidebar-foot details { margin: 0; }
.sidebar-foot summary {
display: flex;
align-items: center;
gap: .5rem;
padding: .4rem .25rem;
font-size: .844rem;
font-weight: 500;
color: var(--im-text-dim);
cursor: pointer;
list-style: none;
}
.sidebar-foot summary::after { display: none; }
.sidebar-foot summary:hover { color: var(--im-text); }
.sidebar-foot summary .ico { color: var(--im-text-faint); }
.sidebar-foot .foot-links {
padding: .1rem 0 .2rem;
display: grid;
gap: .1rem;
}
.sidebar-foot .foot-links a {
display: block;
padding: .3rem .3rem .3rem 1.85rem;
font-size: .812rem;
color: var(--im-text-dim);
text-decoration: none;
border-radius: var(--im-r-sm);
}
.sidebar-foot .foot-links a:hover { background: var(--im-surface-2); color: var(--im-accent); }
.sidebar-foot .foot-note {
padding: .35rem .3rem 0 1.85rem;
font-size: .75rem;
color: var(--im-text-faint);
line-height: 1.4;
}
/* --- Main column --------------------------------------------------------- */
.main-wrapper {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
}
.topbar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
gap: 1rem;
min-height: 58px;
padding: .6rem 1.75rem;
background: color-mix(in srgb, var(--im-bg) 88%, transparent);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--im-line);
}
.topbar-title {
display: flex;
flex-direction: column;
gap: .1rem;
min-width: 0;
}
.topbar-title h1 {
margin: 0;
font-size: 1.1rem;
font-weight: 620;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.topbar-title .crumb {
display: flex;
align-items: center;
gap: .3rem;
font-size: .75rem;
color: var(--im-text-faint);
}
.topbar-title .crumb a { color: var(--im-text-faint); text-decoration: none; }
.topbar-title .crumb a:hover { color: var(--im-accent); }
.topbar-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: .5rem;
}
.topbar-sep {
width: 1px;
height: 22px;
background: var(--im-line);
margin: 0 .15rem;
}
.icon-btn {
--pico-background-color: transparent;
display: inline-grid;
place-items: center;
width: 2rem;
height: 2rem;
padding: 0;
margin: 0;
border: 1px solid transparent;
border-radius: var(--im-r-sm);
background: transparent;
color: var(--im-text-dim);
font-size: .75rem;
font-weight: 600;
line-height: 1;
cursor: pointer;
}
.icon-btn:hover {
background: var(--im-surface-3);
border-color: var(--im-line);
color: var(--im-text);
}
.sidebar-toggle { display: none; }
.main-content {
flex: 1 1 auto;
min-width: 0;
width: 100%;
max-width: 1180px;
padding: 1.6rem 1.75rem 4rem;
}
/* ==========================================================================
Buttons
========================================================================== */
.btn,
a[role="button"].btn,
button.btn {
display: inline-flex;
align-items: center;
gap: .4rem;
padding: .48rem .8rem;
border-radius: var(--im-r-sm);
border: 1px solid var(--im-accent);
background: var(--im-accent);
color: var(--pico-primary-inverse);
font-size: .875rem;
font-weight: 570;
line-height: 1.3;
text-decoration: none;
cursor: pointer;
width: auto;
transition: background-color .12s ease, border-color .12s ease, color .12s ease;
}
.btn:hover { background: var(--im-accent-hover); border-color: var(--im-accent-hover); }
.btn.ghost {
background: var(--im-surface);
border-color: var(--im-line-strong);
color: var(--im-text);
}
.btn.ghost:hover { background: var(--im-surface-2); border-color: var(--im-text-faint); }
.btn.quiet {
background: transparent;
border-color: transparent;
color: var(--im-text-dim);
padding: .35rem .5rem;
}
.btn.quiet:hover { background: var(--im-surface-3); color: var(--im-text); }
.btn.danger { background: transparent; border-color: transparent; color: var(--im-danger); }
.btn.danger:hover { background: var(--im-danger-soft); border-color: var(--im-danger-soft); }
.btn.sm { padding: .3rem .55rem; font-size: .812rem; }
.btn[aria-disabled="true"],
.btn:disabled {
opacity: .45;
cursor: not-allowed;
pointer-events: none;
}
.btn-row {
display: flex;
flex-wrap: wrap;
gap: .5rem;
align-items: center;
}
/* ==========================================================================
Cards, sections, page furniture
========================================================================== */
.card {
background: var(--im-surface);
border: 1px solid var(--im-line);
border-radius: var(--im-r);
margin: 0 0 1.1rem;
overflow: clip;
}
.card-head {
display: flex;
align-items: center;
gap: .75rem;
padding: .8rem 1rem;
border-bottom: 1px solid var(--im-line);
background: var(--im-surface-2);
}
.card-head h2,
.card-head h3 {
margin: 0;
font-size: .938rem;
font-weight: 620;
line-height: 1.3;
}
.card-head .sub {
margin: .1rem 0 0;
font-size: .781rem;
color: var(--im-text-dim);
line-height: 1.35;
}
.card-head .head-actions { margin-left: auto; display: flex; gap: .4rem; align-items: center; }
.card-body { padding: 1rem; }
.card-body > :last-child { margin-bottom: 0; }
.card-body p:first-child { margin-top: 0; }
.card-flush > .card-body { padding: 0; }
.card-grid {
display: grid;
gap: 1.1rem;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
margin-bottom: 1.1rem;
}
.card-grid > .card { margin-bottom: 0; }
.split {
display: grid;
gap: 1.1rem;
grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr);
align-items: start;
}
@media (max-width: 900px) { .split { grid-template-columns: minmax(0, 1fr); } }
.page-intro {
margin: -.35rem 0 1.15rem;
max-width: 62ch;
color: var(--im-text-dim);
font-size: .906rem;
}
/* ==========================================================================
Signature: the pipeline rail (driver -> printer -> package)
Order carries real information — each stage unlocks the next.
========================================================================== */
.rail {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
border: 1px solid var(--im-line);
border-radius: var(--im-r);
background: var(--im-surface);
overflow: clip;
margin-bottom: 1.1rem;
}
.rail-step {
position: relative;
display: block;
padding: 1rem 1.1rem 1.05rem;
text-decoration: none;
color: inherit;
border-left: 1px solid var(--im-line);
transition: background-color .12s ease;
}
.rail-step:first-child { border-left: 0; }
.rail-step:hover { background: var(--im-surface-2); }
/* Progress hairline across the top of each stage. */
.rail-step::before {
content: "";
position: absolute;
inset: 0 0 auto 0;
height: 2px;
background: var(--im-line-strong);
}
.rail-step.done::before { background: var(--im-ok); }
.rail-step.next::before { background: var(--im-accent); }
.rail-top {
display: flex;
align-items: center;
gap: .5rem;
margin-bottom: .55rem;
}
.rail-num {
width: 1.35rem;
height: 1.35rem;
flex: 0 0 auto;
display: grid;
place-items: center;
border-radius: 50%;
border: 1px solid var(--im-line-strong);
color: var(--im-text-faint);
font-family: var(--im-mono);
font-size: .688rem;
font-weight: 600;
}
.rail-step.done .rail-num {
background: var(--im-ok-soft);
border-color: var(--im-ok);
color: var(--im-ok);
}
.rail-step.next .rail-num {
background: var(--im-accent);
border-color: var(--im-accent);
color: var(--pico-primary-inverse);
}
.rail-title { font-weight: 600; font-size: .938rem; }
.rail-count {
margin-left: auto;
font-family: var(--im-mono);
font-size: 1.05rem;
font-weight: 600;
color: var(--im-text);
}
.rail-desc {
margin: 0;
font-size: .812rem;
line-height: 1.45;
color: var(--im-text-dim);
}
.rail-cta {
display: inline-flex;
align-items: center;
gap: .3rem;
margin-top: .55rem;
font-size: .812rem;
font-weight: 570;
color: var(--im-accent);
}
.rail-step.done .rail-cta { color: var(--im-text-faint); }
@media (max-width: 720px) {
.rail { grid-template-columns: minmax(0, 1fr); }
.rail-step { border-left: 0; border-top: 1px solid var(--im-line); }
.rail-step:first-child { border-top: 0; }
}
/* ==========================================================================
Badges, pills, status
========================================================================== */
.badge {
display: inline-flex;
align-items: center;
gap: .28rem;
padding: .12rem .45rem;
border-radius: 999px;
border: 1px solid var(--im-line-strong);
background: var(--im-surface-3);
color: var(--im-text-dim);
font-size: .719rem;
font-weight: 600;
letter-spacing: .01em;
line-height: 1.5;
white-space: nowrap;
}
.badge .ico { width: 12px; height: 12px; }
.badge.ok { background: var(--im-ok-soft); border-color: transparent; color: var(--im-ok); }
.badge.warn { background: var(--im-warn-soft); border-color: transparent; color: var(--im-warn); }
.badge.info { background: var(--im-accent-soft); border-color: transparent; color: var(--im-accent); }
.pill {
display: inline-block;
padding: .1rem .4rem;
border-radius: var(--im-r-sm);
background: var(--im-surface-3);
color: var(--im-text-dim);
font-family: var(--im-mono);
font-size: .719rem;
line-height: 1.6;
white-space: nowrap;
}
.pill-row { display: flex; flex-wrap: wrap; gap: .25rem; align-items: center; }
/* A multi-model INF can name a dozen printers: show three, fold the rest. */
details.more-pills { display: inline-block; margin: 0; }
details.more-pills > summary {
cursor: pointer;
list-style: none;
color: var(--im-accent);
background: var(--im-accent-soft);
}
details.more-pills > summary::marker { content: ""; }
details.more-pills > summary::-webkit-details-marker { display: none; }
details.more-pills > summary::after { display: none; }
details.more-pills[open] > .pill-row { margin-top: .25rem; }
/* ==========================================================================
Tables
========================================================================== */
.table-scroll {
width: 100%;
overflow-x: auto;
}
.table-scroll table,
.data-table {
margin: 0;
width: 100%;
border-collapse: collapse;
font-size: .875rem;
}
.data-table thead th {
padding: .5rem .75rem;
background: var(--im-surface-2);
border-bottom: 1px solid var(--im-line);
font-size: .688rem;
font-weight: 600;
letter-spacing: .075em;
text-transform: uppercase;
color: var(--im-text-faint);
text-align: left;
white-space: nowrap;
}
.data-table tbody td {
padding: .6rem .75rem;
border-bottom: 1px solid var(--im-line);
vertical-align: middle;
background: transparent;
}
.data-table tbody tr:last-child td { border-bottom: 0; }
.data-table tbody tr:hover td { background: var(--im-surface-2); }
.data-table .cell-name {
font-weight: 570;
color: var(--im-text);
text-decoration: none;
}
.data-table .cell-name:hover { color: var(--im-accent); text-decoration: underline; }
.data-table .cell-sub {
display: block;
margin-top: .1rem;
font-family: var(--im-mono);
font-size: .719rem;
color: var(--im-text-faint);
}
.data-table td.num { font-family: var(--im-mono); font-size: .812rem; }
.data-table td.actions { width: 1%; white-space: nowrap; text-align: right; }
.data-table td.actions .btn-row {
justify-content: flex-end;
flex-wrap: nowrap; /* keep the action cluster on one line */
gap: .25rem;
}
/* Group heading above each client's table */
.group-head {
display: flex;
align-items: center;
gap: .5rem;
padding: .7rem 1rem;
border-bottom: 1px solid var(--im-line);
background: var(--im-surface-2);
}
.group-head h3 {
margin: 0;
font-size: .875rem;
font-weight: 620;
}
.group-head h3 a { color: inherit; text-decoration: none; }
.group-head h3 a:hover { color: var(--im-accent); text-decoration: underline; }
.group-head .count {
font-family: var(--im-mono);
font-size: .75rem;
color: var(--im-text-faint);
}
.group-head .head-actions { margin-left: auto; }
/* ==========================================================================
Toolbar (search + filters + primary action)
========================================================================== */
.toolbar {
display: flex;
align-items: center;
gap: .6rem;
flex-wrap: wrap;
margin-bottom: 1.1rem;
}
.search {
position: relative;
flex: 1 1 240px;
max-width: 340px;
}
.search .ico {
position: absolute;
left: .6rem;
top: 50%;
transform: translateY(-50%);
color: var(--im-text-faint);
pointer-events: none;
}
.search input[type="search"],
.search input[type="text"] {
margin: 0;
padding-left: 2rem;
height: 2.25rem;
font-size: .875rem;
background-image: none;
}
.filter-count {
font-size: .812rem;
color: var(--im-text-faint);
font-family: var(--im-mono);
}
.toolbar .spacer { margin-left: auto; }
/* ==========================================================================
Empty states — an invitation to act, not a mood
========================================================================== */
.empty,
p.empty-state {
color: var(--im-text-dim);
font-style: normal;
}
.empty {
display: grid;
justify-items: start;
gap: .3rem;
padding: 1.75rem 1rem 1.9rem;
text-align: left;
}
.empty .empty-ico {
display: grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
border-radius: var(--im-r-sm);
background: var(--im-surface-3);
color: var(--im-text-faint);
margin-bottom: .3rem;
}
.empty strong { font-size: .938rem; font-weight: 620; color: var(--im-text); }
.empty p { margin: 0; font-size: .875rem; max-width: 52ch; }
.empty .btn { margin-top: .6rem; }
/* ==========================================================================
Key/value description grid
========================================================================== */
.kv {
display: grid;
grid-template-columns: minmax(9rem, auto) minmax(0, 1fr);
margin: 0;
}
.kv dt,
.kv dd {
margin: 0;
padding: .5rem .1rem;
border-top: 1px solid var(--im-line);
font-size: .875rem;
}
.kv dt:first-of-type,
.kv dt:first-of-type + dd { border-top: 0; }
.kv dt {
color: var(--im-text-faint);
font-size: .781rem;
font-weight: 500;
padding-right: 1rem;
}
.kv dd { color: var(--im-text); }
.kv dd.mono-val { font-family: var(--im-mono); font-size: .812rem; }
/* ==========================================================================
Command rows + copy
========================================================================== */
.cmd {
display: flex;
align-items: stretch;
gap: 0;
border: 1px solid var(--im-line);
border-radius: var(--im-r-sm);
background: var(--im-surface-2);
overflow: clip;
}
.cmd code {
flex: 1 1 auto;
min-width: 0;
padding: .5rem .65rem;
background: transparent;
color: var(--im-text);
font-family: var(--im-mono);
font-size: .781rem;
line-height: 1.5;
overflow-x: auto;
white-space: pre;
}
.cmd button {
flex: 0 0 auto;
width: auto;
margin: 0;
padding: 0 .65rem;
border: 0;
border-left: 1px solid var(--im-line);
border-radius: 0;
background: var(--im-surface-3);
color: var(--im-text-dim);
font-size: .75rem;
font-weight: 600;
cursor: pointer;
}
.cmd button:hover { background: var(--im-accent-soft); color: var(--im-accent); }
.field-stack { display: grid; gap: .85rem; }
.field-stack > label { margin: 0; }
/* ==========================================================================
Forms — grouped fieldsets so a long form reads as a few short ones
========================================================================== */
.form-section {
border: 0;
border-top: 1px solid var(--im-line);
margin: 0;
padding: 1.35rem 0 .2rem;
display: grid;
gap: 1rem;
}
.form-section:first-of-type { border-top: 0; padding-top: 0; }
/* A <legend> renders inside the fieldset's top border, so the section rule
would strike through it. Lean into that: the label knocks a gap in the
hairline and the rule continues to its right. */
.form-section legend {
padding: 0 .55rem 0 0;
margin: 0 0 -.35rem;
background: var(--im-surface);
font-size: .688rem;
font-weight: 600;
letter-spacing: .085em;
text-transform: uppercase;
color: var(--im-text-faint);
}
.form-row {
display: grid;
gap: .85rem;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.form-row > label { margin: 0; }
.label-text {
display: block;
margin-bottom: .25rem;
font-size: .812rem;
font-weight: 550;
color: var(--im-text);
}
.hint {
display: block;
margin-top: .25rem;
font-size: .75rem;
color: var(--im-text-faint);
line-height: 1.4;
}
.check-row {
display: flex;
flex-wrap: wrap;
gap: 1.1rem;
align-items: center;
}
.check-row label {
display: flex;
align-items: center;
gap: .45rem;
margin: 0;
font-size: .875rem;
}
.check-row input[type="checkbox"] { margin: 0; }
.form-foot {
display: flex;
align-items: center;
gap: .6rem;
flex-wrap: wrap;
border-top: 1px solid var(--im-line);
margin-top: 1.1rem;
padding-top: 1rem;
}
.form-foot .btn { margin: 0; }
input[type="file"] { font-size: .844rem; }
/* File upload panel */
.uploader {
display: grid;
gap: .7rem;
padding: .9rem;
border: 1px dashed var(--im-line-strong);
border-radius: var(--im-r);
background: var(--im-surface-2);
}
.uploader p { margin: 0; font-size: .812rem; color: var(--im-text-dim); }
.uploader input[type="file"] { margin: 0; }
.uploader .btn-row { margin: 0; }
/* ==========================================================================
Alerts / inline feedback
========================================================================== */
.notice,
.error,
div.error > p {
font-size: .875rem;
}
.notice,
.error {
display: block;
padding: .65rem .8rem;
border-radius: var(--im-r-sm);
border: 1px solid transparent;
margin: 0 0 .9rem;
}
.notice {
background: var(--im-warn-soft);
border-color: color-mix(in srgb, var(--im-warn) 30%, transparent);
color: var(--im-warn);
}
.error {
background: var(--im-danger-soft);
border-color: color-mix(in srgb, var(--im-danger) 30%, transparent);
color: var(--im-danger);
}
.error p, .notice p { margin: 0; }
/* Memory-only-session banner (COOKIE_SECURE=false): icon, message, and the
escape hatch on one line, stacking on narrow screens. */
.notice.session-ephemeral {
display: flex;
align-items: center;
gap: .6rem;
}
.notice.session-ephemeral p { flex: 1; min-width: 12rem; }
.notice.session-ephemeral .ico { flex: none; }
.notice.session-ephemeral .btn {
flex: none;
border-color: color-mix(in srgb, var(--im-warn) 40%, transparent);
color: var(--im-warn);
}
.notice.session-ephemeral .btn:hover {
background: color-mix(in srgb, var(--im-warn) 14%, transparent);
border-color: var(--im-warn);
}
@media (max-width: 640px) {
.notice.session-ephemeral { flex-wrap: wrap; }
}
.notice details { margin: .4rem 0 0; }
.notice summary { font-size: .812rem; cursor: pointer; }
.notice ul { margin: .4rem 0 0; font-size: .812rem; font-family: var(--im-mono); }
.ok-note {
display: inline-flex;
align-items: center;
gap: .35rem;
font-size: .844rem;
color: var(--im-ok);
}
/* ==========================================================================
Icon preview
========================================================================== */
.icon-preview {
display: flex;
align-items: center;
gap: .75rem;
padding: .6rem;
border: 1px solid var(--im-line);
border-radius: var(--im-r-sm);
background: var(--im-surface-2);
margin-bottom: .8rem;
}
.icon-preview img {
width: 56px;
height: 56px;
border-radius: var(--im-r-sm);
background: var(--im-surface-3);
image-rendering: auto;
}
.icon-preview .meta { font-size: .812rem; color: var(--im-text-dim); }
/* ==========================================================================
Dialogs
========================================================================== */
/* The edit dialog is rendered inside its row's Actions cell, so it inherits
that cell's right-aligned, no-wrap text. Reset at the dialog boundary. */
dialog {
text-align: start;
white-space: normal;
font-size: 1rem;
}
dialog > article {
border: 1px solid var(--im-line);
border-radius: var(--im-r);
box-shadow: var(--im-shadow);
padding: 0;
max-width: 34rem;
overflow: hidden;
}
/* Pico bleeds article header/footer past the article padding with negative
horizontal margins. These panels are padded themselves, so drop that. */
dialog article > header,
dialog article > footer,
dialog .dialog-head,
dialog .dialog-foot { margin: 0; }
/* Class-based, not element-based: the edit dialog wraps head/body/foot in a
<form>, so `dialog > article > header` would never match. */
dialog .dialog-head,
dialog .dialog-foot {
display: flex;
align-items: center;
gap: .55rem;
padding: .85rem 1.1rem;
margin: 0;
background: var(--im-surface-2);
}
dialog .dialog-head { border-bottom: 1px solid var(--im-line); }
dialog .dialog-head h3 {
margin: 0;
font-size: 1rem;
font-weight: 620;
}
/* Pico draws its own X into any [aria-label=Close] button — we supply the glyph. */
dialog .dialog-head .close {
margin-left: auto;
background-image: none;
font-size: 1.15rem;
line-height: 1;
}
dialog .dialog-foot {
border-top: 1px solid var(--im-line);
justify-content: flex-end;
}
/* Head and foot stay put; only the fields scroll on a short viewport. */
dialog .dialog-body {
padding: 1.1rem;
max-height: min(68vh, 36rem);
overflow-y: auto;
}
dialog .dialog-body > :last-child { margin-bottom: 0; }
dialog .dialog-body .form-section:first-of-type { padding-top: 0; }
[x-cloak] { display: none !important; }
/* ==========================================================================
HTMX feedback
========================================================================== */
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator { display: inline-flex; align-items: center; gap: .4rem; font-size: .812rem; color: var(--im-text-dim); }
form.htmx-request button[type="submit"] { opacity: .6; pointer-events: none; }
/* ==========================================================================
Responsive: sidebar becomes an off-canvas drawer
========================================================================== */
@media (max-width: 860px) {
.sidebar-toggle { display: inline-grid; }
nav.sidebar {
position: fixed;
z-index: 40;
inset: 0 auto 0 0;
transform: translateX(-100%);
transition: transform .18s ease;
box-shadow: var(--im-shadow);
}
body.nav-open nav.sidebar { transform: translateX(0); }
body.nav-open .nav-scrim {
position: fixed;
inset: 0;
z-index: 30;
background: var(--pico-modal-overlay-background-color);
}
.topbar { padding: .6rem 1rem; }
.main-content { padding: 1.1rem 1rem 3rem; }
}
@media (max-width: 620px) {
/* Page actions shrink to their icons; the h1 keeps the context. */
.topbar-actions .btn > span { display: none; }
.topbar-actions .btn { padding: .45rem .55rem; }
.topbar-sep { display: none; }
/* Print defaults are the least urgent column — the detail page has them. */
.data-table .col-defaults { display: none; }
}
/* ==========================================================================
Small corrections to Pico defaults that the components rely on
========================================================================== */
.kv dd a,
.hint a,
.data-table a:not(.btn):not(.cell-name) { color: var(--im-accent); }
.data-table img { display: inline-block; max-width: 100%; }
/* Headings borrowed as section eyebrows keep the eyebrow scale, not h2's. */
h2.eyebrow, h3.eyebrow { font-size: .688rem; line-height: 1.4; }
/* Pico marks any <details><summary> as an accordion; the session menu and the
folded pill list are not accordions. */
.sidebar-foot summary::marker,
details.more-pills > summary::marker { content: ""; }
/* The upload confirmation reuses the full driver table; in the add-printer
sidebar there is only room for the file and its parsed names. */
.form-aside .data-table .col-arch,
.form-aside .data-table .col-used,
.form-aside .data-table .col-added { display: none; }
.uploader label { margin: 0; }
.uploader .hint { margin-top: 0; }
figure { margin: 0; }
@media (prefers-reduced-motion: reduce) {
* { transition-duration: .01ms !important; animation-duration: .01ms !important; }
}