/* static/css/app.css
   ---
   app styles — loaded after daisyUI, overriding theme vars and adding the glue CSS.

     1. theme customization    (brand your app here)
     2. HTMX loading states    (the bar, indicator visibility, button dim)
     3. WinBox × daisyUI       (rebinding winbox's hardcoded chrome to the active theme)
     4. desktop shell          (the dock, its placement modifiers, and the tinted backdrop)
     5. site tree              (the homepage you get with no dock: mobile + single-page mode)
     6. drop target            (the project file table, while something is being dragged over it)
     7. command bar            (⌘K — the site tree, by keyboard)

   components — cards, alerts, buttons, spinners and the rest — all come from daisyUI. keep this file small and let the theme do the work.
*/


/* 1. Theme customization
   ----------------------

   daisyUI 5 themes are plain CSS variables — override per theme, no build step.
   Full list: https://daisyui.com/docs/themes/
*/
[data-theme="nord"] {
  /* --color-primary: oklch(60% 0.2 270); */
  /* --radius-box: 0.5rem; */
}
[data-theme="sunset"] {
  /* --color-primary: oklch(70% 0.15 50); */
}


/* 2. HTMX loading states
   ---------------------- */

/*
 * Global loading bar — the thin stripe at the top of the viewport.
 * #htmx-bar in base.html has class="htmx-indicator".
 * HTMX adds .htmx-request to it while any request is in flight
 * (because <body> declares hx-indicator="#htmx-bar").
 */
#htmx-bar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 3px;
  background: var(--color-primary);
  z-index: 9999;
  transform-origin: left center;
  transform: scaleX(0);
}

/*
 * .htmx-indicator: invisible by default; visible when .htmx-request is on
 * the element itself or any ancestor. Pair it with daisyUI's spinner:
 *   <span class="htmx-indicator loading loading-spinner loading-xs"></span>
 */
.htmx-indicator {
  opacity: 0;
  transition: opacity 150ms ease-in;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
  opacity: 1;
}

/* Bar-specific animation while visible */
#htmx-bar.htmx-request {
  opacity: 1;
  animation: htmx-bar-slide 1.4s ease-in-out infinite;
}
@keyframes htmx-bar-slide {
  0%   { transform: scaleX(0)   translateX(0); }
  50%  { transform: scaleX(0.6) translateX(40%); }
  100% { transform: scaleX(0.1) translateX(900%); }
}

/* Dim the triggering button while its own request is in flight */
button.htmx-request { opacity: 0.65; cursor: wait; }

/*
 * .app-editor: chrome for the framework code editor (js/editor.js).
 * CodeMirror injects its own structural CSS; these rules bolt it onto
 * daisyUI's theme variables so the editor follows the active theme.
 */
.app-editor {
  border: 1px solid var(--color-base-300);
  border-radius: var(--radius-box, 0.5rem);
  /* light themes ride base-100; dark themes get a dedicated editor surface
     (oneDark's #282c34) so code doesn't blend into the page. light-dark()
     keys off the color-scheme each daisyUI theme declares — editor.js uses
     the same signal to swap in the oneDark syntax palette. */
  background: light-dark(var(--color-base-100), #282c34);
  min-height: 10rem;
  max-height: 28rem;
  overflow: hidden;
}
.app-editor .cm-editor { height: 100%; background: transparent; }
.app-editor .cm-scroller { max-height: 28rem; }
.app-editor .cm-gutters {
  background: light-dark(var(--color-base-200), #21252b);
  color: light-dark(var(--color-base-content), #9da5b4);
  opacity: 0.85;
  border-right: 1px solid light-dark(var(--color-base-300), #181a1f);
}
.app-editor .cm-activeLine { background: color-mix(in oklab, var(--color-base-content) 6%, transparent); }
.app-editor .cm-activeLineGutter { background: color-mix(in oklab, var(--color-base-content) 10%, transparent); }
.app-editor .cm-editor.cm-focused { outline: 2px solid var(--color-primary); outline-offset: -1px; }

/* the dedicated editor page (pages/editor.html) gets the full viewport
 * height instead of the inline 28rem cap.
 * dvh, not vh: on iOS safari 100vh is the large viewport (toolbars hidden),
 * so a vh-capped editor runs past the bottom of the screen and its last
 * lines sit behind the browser toolbar. vh line kept as the <15.4 fallback. */
.app-editor-page .app-editor,
.app-editor-page .app-editor .cm-scroller {
  max-height: calc(100vh - 16rem);
  max-height: calc(100dvh - 16rem);
}
.app-editor-page .app-editor { min-height: 24rem; }

/*
 * .app-md: the markdown preview's rendered HTML
 * (pages/partials/markdown_preview.html ← core/content/markdown.py).
 *
 * markdown-it emits plain semantic tags — <h1>, <ul>, <blockquote> — and
 * tailwind's preflight resets them all to identical unstyled text, so
 * without this the preview is a wall of undifferentiated prose. daisyUI's
 * typography plugin isn't loaded (the browser runtime ships core utilities
 * only), hence a hand-rolled, deliberately small ruleset. everything keys
 * off theme variables, so the preview follows the theme picker like the
 * rest of the app.
 */
.app-md { line-height: 1.65; overflow-wrap: break-word; }
.app-md > :first-child { margin-top: 0; }

.app-md h1, .app-md h2, .app-md h3,
.app-md h4, .app-md h5, .app-md h6 {
  font-weight: 700;
  line-height: 1.25;
  margin: 1.4em 0 0.5em;
}
.app-md h1 { font-size: 1.6rem; }
.app-md h2 { font-size: 1.35rem; }
.app-md h3 { font-size: 1.15rem; }
.app-md h4, .app-md h5, .app-md h6 { font-size: 1rem; }
/* headings earn a rule the way readmes do; base-300 keeps it quiet */
.app-md h1, .app-md h2 {
  padding-bottom: 0.2em;
  border-bottom: 1px solid var(--color-base-300);
}

.app-md p, .app-md ul, .app-md ol, .app-md blockquote,
.app-md pre, .app-md table { margin: 0.85em 0; }

.app-md ul, .app-md ol { padding-left: 1.5em; }
.app-md ul { list-style: disc; }
.app-md ol { list-style: decimal; }
.app-md li { margin: 0.25em 0; }
.app-md li > ul, .app-md li > ol { margin: 0.25em 0; }

.app-md a { color: var(--color-primary); text-decoration: underline; }
.app-md strong { font-weight: 700; }
.app-md em { font-style: italic; }
.app-md hr { border-top: 1px solid var(--color-base-300); margin: 1.6em 0; }

.app-md blockquote {
  border-left: 3px solid var(--color-primary);
  padding: 0.1em 0 0.1em 0.9em;
  color: color-mix(in oklab, var(--color-base-content) 75%, transparent);
}

/* code: inline is a chip, fenced is a scrollable slab. no highlighting —
 * the renderer emits <code class="language-x"> and nothing colors it,
 * which is an honest place to stop for a preview pane. */
.app-md code {
  font-family: ui-monospace, SFMono-Regular, monospace;
  font-size: 0.875em;
  background: var(--color-base-200);
  border-radius: 0.25rem;
  padding: 0.1em 0.35em;
}
.app-md pre {
  background: var(--color-base-200);
  border: 1px solid var(--color-base-300);
  border-radius: var(--radius-box, 0.5rem);
  padding: 0.85rem;
  overflow-x: auto;
}
.app-md pre code { background: none; padding: 0; font-size: 0.8125rem; }

.app-md table { border-collapse: collapse; display: block; overflow-x: auto; }
.app-md th, .app-md td {
  border: 1px solid var(--color-base-300);
  padding: 0.35em 0.6em;
  text-align: left;
}
.app-md th { background: var(--color-base-200); font-weight: 600; }

.app-md img { max-width: 100%; height: auto; border-radius: 0.25rem; }


/* 3. WinBox × daisyUI (.wb-daisy)
   -------------------------------

   windows.js opens every window with class "wb-daisy". winbox.min.css ships
   hardcoded chrome (#0050ff titlebar, white body, white icons); these rules
   rebind all of it to the active daisyUI theme, same trick as .app-editor.
   neutral/neutral-content is the titlebar pair because neutral stays dark
   (with light content) across effectively all 35 built-in themes.
*/
.winbox.wb-daisy {
  background: var(--color-neutral);
  border-radius: var(--radius-box, 0.5rem);
  overflow: hidden;                       /* clip the body to the radius */
}
.wb-daisy .wb-title {
  color: var(--color-neutral-content);
  font-family: inherit;                   /* winbox defaults to Arial */
  font-size: 0.875rem;
}
.wb-daisy .wb-body {
  background: var(--color-base-100);
  color: var(--color-base-content);
}
.wb-daisy:not(.focus) .wb-title { opacity: 0.6; }   /* dim inactive windows */

/*
 * winbox.min.css ships `.winbox iframe { position:absolute; width:100%;
 * height:100% }` — correct for its OWN iframe-mode windows, where the iframe
 * IS the window body. but our windows are HTMX mounts (see winbox_assets.html),
 * so that rule also swallows any iframe belonging to the mounted PAGE. the
 * build tab's preview was the casualty: absolutely positioned, it left its
 * flex slot measuring empty (min-h-16), so the collapse sized itself as if
 * the preview weren't there, while the frame — sized 100%/100% against
 * .collapse, which daisyUI gives position:relative — spilled out over the
 * panes below. .collapse sets no overflow, so nothing clipped the mess.
 *
 * put mounted-page iframes back in normal flow. winbox's own body iframe is a
 * direct child of .wb-body and never matches .wb-mount, so iframe-mode windows
 * (pop-out preview, toolbox tools) are untouched. two class selectors beats
 * winbox's one, which matters: winbox.min.css loads from {% block scripts %},
 * i.e. AFTER this file, so source order would otherwise go the wrong way.
 * width/height revert to auto — the element's own style/attributes decide.
 */
.winbox .wb-mount iframe {
  position: static;
  width: auto;
  height: auto;
}

/*
 * control icons: winbox bakes them in as white data:-URI SVGs, which vanish
 * on any theme whose neutral is light. repaint them as CSS masks filled with
 * neutral-content — the same SVGs, now theme-aware. (mask position/size
 * mirror winbox.min.css.)
 */
.wb-daisy .wb-min,
.wb-daisy .wb-max,
.wb-daisy .wb-full,
.wb-daisy .wb-close {
  background-image: none;
  background-color: var(--color-neutral-content);
}
.wb-daisy .wb-min {
  -webkit-mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAyIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNOCAwaDdhMSAxIDAgMCAxIDAgMkgxYTEgMSAwIDAgMSAwLTJoN3oiLz48L3N2Zz4=) no-repeat center calc(50% + 6px) / 14px auto;
          mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAyIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNOCAwaDdhMSAxIDAgMCAxIDAgMkgxYTEgMSAwIDAgMSAwLTJoN3oiLz48L3N2Zz4=) no-repeat center calc(50% + 6px) / 14px auto;
}
.wb-daisy .wb-max {
  -webkit-mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9IiNmZmYiIHZpZXdCb3g9IjAgMCA5NiA5NiI+PHBhdGggZD0iTTIwIDcxLjMxMUMxNS4zNCA2OS42NyAxMiA2NS4yMyAxMiA2MFYyMGMwLTYuNjMgNS4zNy0xMiAxMi0xMmg0MGM1LjIzIDAgOS42NyAzLjM0IDExLjMxMSA4SDI0Yy0yLjIxIDAtNCAxLjc5LTQgNHY1MS4zMTF6Ii8+PHBhdGggZD0iTTkyIDc2VjM2YzAtNi42My01LjM3LTEyLTEyLTEySDQwYy02LjYzIDAtMTIgNS4zNy0xMiAxMnY0MGMwIDYuNjMgNS4zNyAxMiAxMiAxMmg0MGM2LjYzIDAgMTItNS4zNyAxMi0xMnptLTUyIDRjLTIuMjEgMC00LTEuNzktNC00VjM2YzAtMi4yMSAxLjc5LTQgNC00aDQwYzIuMjEgMCA0IDEuNzkgNCA0djQwYzAgMi4yMS0xLjc5IDQtNCA0SDQweiIvPjwvc3ZnPg==) no-repeat center / 17px auto;
          mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9IiNmZmYiIHZpZXdCb3g9IjAgMCA5NiA5NiI+PHBhdGggZD0iTTIwIDcxLjMxMUMxNS4zNCA2OS42NyAxMiA2NS4yMyAxMiA2MFYyMGMwLTYuNjMgNS4zNy0xMiAxMi0xMmg0MGM1LjIzIDAgOS42NyAzLjM0IDExLjMxMSA4SDI0Yy0yLjIxIDAtNCAxLjc5LTQgNHY1MS4zMTF6Ii8+PHBhdGggZD0iTTkyIDc2VjM2YzAtNi42My01LjM3LTEyLTEyLTEySDQwYy02LjYzIDAtMTIgNS4zNy0xMiAxMnY0MGMwIDYuNjMgNS4zNyAxMiAxMiAxMmg0MGM2LjYzIDAgMTItNS4zNyAxMi0xMnptLTUyIDRjLTIuMjEgMC00LTEuNzktNC00VjM2YzAtMi4yMSAxLjc5LTQgNC00aDQwYzIuMjEgMCA0IDEuNzkgNCA0djQwYzAgMi4yMS0xLjc5IDQtNCA0SDQweiIvPjwvc3ZnPg==) no-repeat center / 17px auto;
}
.wb-daisy .wb-full {
  -webkit-mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2Utd2lkdGg9IjIuNSIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNOCAzSDVhMiAyIDAgMCAwLTIgMnYzbTE4IDBWNWEyIDIgMCAwIDAtMi0yaC0zbTAgMThoM2EyIDIgMCAwIDAgMi0ydi0zTTMgMTZ2M2EyIDIgMCAwIDAgMiAyaDMiLz48L3N2Zz4=) no-repeat center / 16px auto;
          mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2Utd2lkdGg9IjIuNSIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNOCAzSDVhMiAyIDAgMCAwLTIgMnYzbTE4IDBWNWEyIDIgMCAwIDAtMi0yaC0zbTAgMThoM2EyIDIgMCAwIDAgMi0ydi0zTTMgMTZ2M2EyIDIgMCAwIDAgMiAyaDMiLz48L3N2Zz4=) no-repeat center / 16px auto;
}
.wb-daisy .wb-close {
  -webkit-mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii0xIC0xIDE4IDE4Ij48cGF0aCBmaWxsPSIjZmZmIiBkPSJtMS42MTMuMjEuMDk0LjA4M0w4IDYuNTg1IDE0LjI5My4yOTNsLjA5NC0uMDgzYTEgMSAwIDAgMSAxLjQwMyAxLjQwM2wtLjA4My4wOTRMOS40MTUgOGw2LjI5MiA2LjI5M2ExIDEgMCAwIDEtMS4zMiAxLjQ5N2wtLjA5NC0uMDgzTDggOS40MTVsLTYuMjkzIDYuMjkyLS4wOTQuMDgzQTEgMSAwIDAgMSAuMjEgMTQuMzg3bC4wODMtLjA5NEw2LjU4NSA4IC4yOTMgMS43MDdBMSAxIDAgMCAxIDEuNjEzLjIxeiIvPjwvc3ZnPg==) no-repeat 5px center / 15px auto;
          mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii0xIC0xIDE4IDE4Ij48cGF0aCBmaWxsPSIjZmZmIiBkPSJtMS42MTMuMjEuMDk0LjA4M0w4IDYuNTg1IDE0LjI5My4yOTNsLjA5NC0uMDgzYTEgMSAwIDAgMSAxLjQwMyAxLjQwM2wtLjA4My4wOTRMOS40MTUgOGw2LjI5MiA2LjI5M2ExIDEgMCAwIDEtMS4zMiAxLjQ5N2wtLjA5NC0uMDgzTDggOS40MTVsLTYuMjkzIDYuMjkyLS4wOTQuMDgzQTEgMSAwIDAgMSAuMjEgMTQuMzg3bC4wODMtLjA5NEw2LjU4NSA4IC4yOTMgMS43MDdBMSAxIDAgMCAxIDEuNjEzLjIxeiIvPjwvc3ZnPg==) no-repeat 5px center / 15px auto;
}


/* 4. Desktop shell (pages/desktop.html)
   -------------------------------------

   the dock is a fixed vertical rail on the LEFT edge: launcher buttons on
   top, a divider, then the taskbar — one button per open window (windows.js
   renders them). z-70 keeps it above windows (index: 60) so it's always
   reachable. hidden on mobile via the template's `hidden sm:flex`.
*/
.wb-dock {
  /* the placement transform is composed from two custom properties instead of
   * being written out per state. position, extend and auto-hide each want to
   * set ONE axis, and a plain `transform:` would have them clobbering each
   * other — every combination would need its own selector. */
  --dock-x: 0;
  --dock-y: -50%;

  position: fixed;
  left: 0.75rem;
  top: 50%;
  transform: translate(var(--dock-x), var(--dock-y));
  z-index: 70;
  flex-direction: column;
  align-items: center;
  gap: 0.25rem;
  padding: 0.625rem 0.375rem;
  border-radius: var(--radius-box, 0.5rem);
  max-height: calc(100dvh - 1.5rem);
  backdrop-filter: blur(6px);
}
.wb-dock-divider {
  height: 1px;
  align-self: stretch;
  margin: 0.25rem 0.25rem;
  background: color-mix(in oklab, var(--color-base-content) 20%, transparent);
}
/* hide the divider (and the empty strip) while nothing is running */
.wb-taskbar:empty { display: none; }
.wb-dock:has(.wb-taskbar:empty) .wb-dock-divider { display: none; }

/* separator before the standalone-only controls (refresh / quit). same look
 * as .wb-dock-divider but NOT tied to the taskbar — these buttons are always
 * present in standalone, so their divider must never be hidden. */
.wb-dock-sep {
  height: 1px;
  align-self: stretch;
  margin: 0.25rem 0.25rem;
  background: color-mix(in oklab, var(--color-base-content) 20%, transparent);
}
.wb-dock-bottom .wb-dock-sep {
  height: auto;
  width: 1px;
  align-self: stretch;
  margin: 0.25rem;
}

/* dock placement (per-user, /priv/dock)
   -------------------------------------
   base .wb-dock is the LEFT rail. these modifiers move it to the right edge
   or lay it flat along the bottom. --dock-y: -50% centres the vertical rails;
   the bottom rail re-centres on X instead (--dock-x) and flips to a horizontal
   flow (divider + taskbar turn with it).
*/
.wb-dock-right {
  left: auto;
  right: 0.75rem;
}
.wb-dock-bottom {
  left: 50%;
  right: auto;
  top: auto;
  bottom: 0.75rem;
  --dock-x: -50%;
  --dock-y: 0;
  flex-direction: row;
  max-height: none;
  max-width: calc(100vw - 1.5rem);
}
.wb-dock-bottom .wb-dock-divider {
  height: auto;
  width: 1px;
  align-self: stretch;
  margin: 0.25rem;
}
.wb-dock-bottom .wb-taskbar {
  flex-direction: row;
  overflow-x: auto;
  overflow-y: hidden;
}

/* extend (per-user, /priv/dock)
   -----------------------------
   stretch the rail along its whole edge — full viewport height for the side
   rails, full width for the bottom one — and sit flush in the corner: at that
   size the floating-pill treatment reads as a mistake, so the inset and the
   rounding go and it becomes a proper edge bar. the launchers centre on the
   long axis; only the CROSS axis offset is dropped, so `left`/`right`/`bottom`
   placement still decides which edge it clings to.
*/
.wb-dock-extend { border-radius: 0; }

/* the shell's tray (arrange / theme / activity / sign out, plus refresh and
 * quit in standalone) moves into an extended bar, pinned to its far end —
 * bottom of a side rail, right of the bottom bar. the auto margin eats the free
 * space, which is what leaves the launchers packed at the START of the bar: the
 * taskbar arrangement, and the reason .wb-dock-extend doesn't centre its
 * contents. */
.wb-dock-tray {
  display: flex;
  flex-direction: inherit;    /* follows the rail: column, or row along the bottom */
  align-items: center;
  gap: 0.25rem;
}

/* the arrange menu has nothing to arrange until something is running, and a
 * button that does nothing is worse than no button. same test the taskbar
 * divider uses, for the same reason — one empty taskbar, two things that hide
 * with it. */
.wb-dock:has(.wb-taskbar:empty) .wb-dock-layouts { display: none; }
/* the auto margin goes on the SEPARATOR, not the tray: one auto margin eats
 * all the free space, two would split it and strand the rule mid-bar. */
.wb-dock-sep:has(+ .wb-dock-tray)             { margin-top: auto; }
.wb-dock-bottom .wb-dock-sep:has(+ .wb-dock-tray) {
  margin-top: 0.25rem;
  margin-left: auto;
}
.wb-dock-left.wb-dock-extend,
.wb-dock-right.wb-dock-extend {
  top: 0;
  bottom: 0;
  --dock-y: 0;
  max-height: none;
}
.wb-dock-left.wb-dock-extend   { left: 0; }
.wb-dock-right.wb-dock-extend  { right: 0; }
.wb-dock-bottom.wb-dock-extend {
  left: 0;
  right: 0;
  bottom: 0;
  --dock-x: 0;
  max-width: none;
}

/* inset backdrop
   --------------
   pad the desktop away from an extended bar, so the centred hint line reads
   as centred on the DESKTOP rather than pushed off-centre by the dock.
   --wb-dock-thickness is measured and published by windows.js: the bar's
   thickness falls out of the icon size, the padding and the theme radius,
   so a hardcoded number here would be a duplicate of the truth that quietly
   disagrees with it. the 0px fallback means "no dock measured yet" — the
   first paint is simply un-inset rather than wrong.
*/
.wb-inset-left   { padding-left:   var(--wb-dock-thickness, 0px); }
.wb-inset-right  { padding-right:  var(--wb-dock-thickness, 0px); }
.wb-inset-bottom { padding-bottom: var(--wb-dock-thickness, 0px); }

/* auto-hide
   ---------
   collapse the rail to a thin peek at its edge; reveal on hover or when a
   launcher inside takes focus (keyboard-reachable).

   two things keep it from flapping open/shut, which the naive version did:

     1. the hidden rail is anchored FLUSH to the viewport edge (left/right/
        bottom: 0), not at the usual 0.75rem inset. inset, the collapsed peek
        covered the edge strip but the revealed rail did not — so parking the
        pointer in that strip (exactly where you throw it to summon a hidden
        dock) hovered → revealed → un-hovered → hid, forever. flush, the
        revealed rail's hit area is a strict superset of the peek's, so a
        reveal can never pull the surface out from under the pointer.
     2. timing buffers, so brushing past doesn't pop it and a moment's wobble
        off the edge doesn't slam it shut. keyboard focus is exempt — a
        tabbed-to launcher must show itself now, not in 120ms.

   ::before adds an invisible cushion around the rail, widening the 0.5rem
   peek into something you can actually hit. z-index keeps it behind the
   dock's own surface so it never eats a launcher click.
*/
.wb-dock-autohide {
  transition: transform 0.18s ease;
  transition-delay: 0.4s;                                    /* grace before hiding */
}
.wb-dock-autohide::before {
  content: "";
  position: absolute;
  inset: -0.5rem;
  z-index: -1;
}
.wb-dock-left.wb-dock-autohide   { left: 0;   --dock-x: calc(-100% + 0.5rem); }
.wb-dock-right.wb-dock-autohide  { right: 0;  --dock-x: calc(100% - 0.5rem); }
.wb-dock-bottom.wb-dock-autohide { bottom: 0; --dock-y: calc(100% - 0.5rem); }

.wb-dock-left.wb-dock-autohide::before   { right: -1rem; }
.wb-dock-right.wb-dock-autohide::before  { left: -1rem; }
.wb-dock-bottom.wb-dock-autohide::before { top: -1rem; }

.wb-dock-left.wb-dock-autohide:hover,
.wb-dock-left.wb-dock-autohide:focus-within,
.wb-dock-right.wb-dock-autohide:hover,
.wb-dock-right.wb-dock-autohide:focus-within   { --dock-x: 0; }
.wb-dock-bottom.wb-dock-autohide:hover,
.wb-dock-bottom.wb-dock-autohide:focus-within  { --dock-y: 0; }

.wb-dock-autohide:hover        { transition-delay: 0.12s; }  /* arm before revealing */
.wb-dock-autohide:focus-within { transition-delay: 0s; }     /* keyboard: immediate */

/* motion-sensitive users get the snap, not the slide — but keep the buffers,
 * they're what stops the flicker. */
@media (prefers-reduced-motion: reduce) {
  .wb-dock-autohide { transition-duration: 0s; }
}

.wb-taskbar {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 0.25rem;
  min-height: 0;      /* let it shrink (and thus scroll) inside the flex rail */
  overflow-y: auto;
  scrollbar-width: none;
}
.wb-task {
  max-width: 7rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-weight: 400;
  justify-content: flex-start;
}

/* on a VERTICAL rail the taskbar sets the dock's THICKNESS, and a button
 * that shrink-wraps its title makes that thickness track the longest window
 * title: open "chat" and the rail is narrow, open "bridge.py — editor" and it
 * lurches wider, taking the reserved space, the inset backdrop and every
 * window boundary with it. pinning the buttons to the cap gives the rail
 * exactly two widths — icons-only when idle, this one when anything is
 * running — so there is a single transition to react to instead of one per
 * title. (the bottom rail is unaffected: there, titles grow along the bar's
 * LENGTH, which costs nothing.) */
.wb-dock-left .wb-task,
.wb-dock-right .wb-task {
  width: 7rem;
}
.wb-task-active {
  background: color-mix(in oklab, var(--color-primary) 18%, transparent);
  box-shadow: inset 2px 0 0 var(--color-primary);
}
.wb-task-min { opacity: 0.5; font-style: italic; }

/* the desktop backdrop fills the viewport behind the windows.
 * dvh matches .wb-dock / .wb-mobile below — vh would overshoot on iOS. */
.wb-desktop {
  min-height: calc(100vh - 10rem);
  min-height: calc(100dvh - 10rem);
  position: relative;   /* lift the hint above the tinted backdrop */
  z-index: 0;
}

/* tinted backdrop (opt-in, /priv/background)
   ------------------------------------------
   a full-viewport tint behind the desktop content, driven ENTIRELY by the
   three data-bg-* attributes pages/desktop.html stamps on the mount point.
   no javascript, on purpose:
     - it re-tints on a theme change by itself (the daisyUI vars change and
       the gradient follows), so it can never fall out of sync;
     - it needs no colour parsing, so it renders identically in a browser and
       inside pywebview's WKWebView;
     - it is correct on the very first paint instead of popping in.

   z-index 0 + pointer-events:none keeps it above the base background but out
   of the way — .wb-desktop (z-index 0, later in the DOM) draws its hint over
   it, and windows (z 60) / the dock (z 70) float well clear. (z-index 0, not
   -1: a -1 fixed layer can be painted OVER by the body's own background in
   some engines, which hid it entirely in the desktop app.) pointer-events is
   load-bearing — the backdrop must never intercept a click meant for a window.

   visibility is handled HERE, not with Tailwind's `hidden sm:block`: this app
   runs the Tailwind *browser* runtime, which generates utility CSS async, so
   the element would flash. this stylesheet is render-blocking.

   see core/background.py — COLORS / GRADIENTS / DIRECTIONS map 1:1 onto the
   selectors below. keep the two in sync. */
.wb-bg {
  display: none;                 /* mobile: no desktop paradigm, no backdrop */
  position: fixed;
  top: 0;
  left: 0;
  /* explicit viewport size, NOT inset:0 — inset resolves against the containing
   block, which a transformed ancestor could shrink to nothing; vw/vh always
   track the viewport. */
  width: 100vw;
  height: 100vh;
  height: 100dvh;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;
  transition: background 0.35s ease;
}
@media (min-width: 640px) {      /* sm: the desktop breakpoint */
  .wb-bg { display: block; }
}

/* the settings-page swatch: the same paint, boxed. position/overflow are set
   HERE as well as by Tailwind utilities in the template — same reason as
   .wb-bg above, the browser runtime emits those async. */
.wb-bg-preview {
  position: relative;
  overflow: hidden;
  height: 9rem;
  transition: background 0.35s ease;
}

/* colour: which palette ROLE carries the tint. "base" is base-content — the
   theme's foreground, quiet and never shouty; it is the graphite option. */
[data-bg-color="base"]      { --wb-c: var(--color-base-content); }
[data-bg-color="primary"]   { --wb-c: var(--color-primary); }
[data-bg-color="secondary"] { --wb-c: var(--color-secondary); }
[data-bg-color="accent"]    { --wb-c: var(--color-accent); }
[data-bg-color="neutral"]   { --wb-c: var(--color-neutral); }
[data-bg-color="info"]      { --wb-c: var(--color-info); }
[data-bg-color="success"]   { --wb-c: var(--color-success); }
[data-bg-color="warning"]   { --wb-c: var(--color-warning); }
[data-bg-color="error"]     { --wb-c: var(--color-error); }

/* strength: how much of that colour survives the mix over base-100. the
   ceiling is deliberately low — this is furniture, not a feature. */
.wb-bg, .wb-bg-preview        { --wb-w: 10%; }
[data-bg-gradient="subtle"]   { --wb-w: 7%;  }
[data-bg-gradient="medium"]   { --wb-w: 14%; }
[data-bg-gradient="rich"]     { --wb-w: 24%; }

/* @supports guard: colour-mix is the whole mechanism, and an engine without it
   would drop the entire `background` declaration as invalid. gating it means
   such a browser simply gets the flat backdrop — the pre-tint behaviour rather
   than a broken one. gradient="none" opts out the same way. */
@supports (background: color-mix(in oklab, red 50%, transparent)) {
  .wb-bg[data-bg-gradient]:not([data-bg-gradient="none"]),
  .wb-bg-preview[data-bg-gradient]:not([data-bg-gradient="none"]) {
    --wb-tint: color-mix(in oklab, var(--wb-c, var(--color-primary)) var(--wb-w), transparent);
  }

  /* direction: where the colour pools. radial is a soft centred bloom; the
   other two fade from the named edge. */
  [data-bg-direction="radial"] {
    background: radial-gradient(72% 62% at 50% 42%, var(--wb-tint) 0%, transparent 78%);
  }
  [data-bg-direction="top"] {
    background: linear-gradient(to bottom, var(--wb-tint) 0%, transparent 72%);
  }
  [data-bg-direction="bottom"] {
    background: linear-gradient(to top, var(--wb-tint) 0%, transparent 72%);
  }
  /* "none" wins over the direction rules above regardless of source order. */
  [data-bg-gradient="none"] { background: none; }
}

/*
   mobile: phones get the plain multi-page app — windows.js navigates
   instead of opening windows, so the only winbox that can exist below the
   sm breakpoint is an iframe tool (pyxel — inherently windowed). those
   open maximized (.wb-mobile); pin the geometry and hide the resize
   chrome, which winbox can't make touch-friendly anyway.
*/
@media (max-width: 640px) {
  .winbox.wb-mobile {
    top: 0 !important;
    left: 0 !important;
    width: 100vw !important;
    height: 100dvh !important;
    border-radius: 0;
  }
  .winbox.wb-mobile .wb-n,  .winbox.wb-mobile .wb-s,
  .winbox.wb-mobile .wb-e,  .winbox.wb-mobile .wb-w,
  .winbox.wb-mobile .wb-nw, .winbox.wb-mobile .wb-ne,
  .winbox.wb-mobile .wb-sw, .winbox.wb-mobile .wb-se,
  .winbox.wb-mobile .wb-max, .winbox.wb-mobile .wb-full {
    display: none;
  }
}

/*
 * the desktop bell's unread dot (pages/desktop.html + js/activity.js):
 * a slow breathe rather than a blink — noticeable in the corner of your
 * eye, ignorable when you're mid-thought.
 */
#wb-notify-dot.wb-dot-live {
  animation: wb-dot-breathe 2.6s ease-in-out infinite;
}
@keyframes wb-dot-breathe {
  0%, 100% { opacity: 0.25; }
  50%      { opacity: 1; }
}


/* 5. Site tree (components/sitetree.html)
   ---------------------------------------

   the homepage for anyone without a dock — every mobile viewport, and
   single-page mode at any width. the markup is daisyUI `menu` plus a handful
   of hooks (.tree-*); this file is what turns a correct list into something
   worth landing on.

   the rules below are all hover/open affordances and one rail. nothing here
   is load-bearing: strip the whole section and the tree still renders, still
   filters, still navigates — it just stops being a pleasure to poke at.
*/

/*
 * quick row — the `quick` leaves, as tiles. the lift is small on purpose:
 * these sit above the search box, and a tile that jumps is a tile you
 * mis-tap on a phone.
 */
.tree-quick {
  transition: transform 0.12s ease, border-color 0.12s ease, background-color 0.12s ease;
}
.tree-quick:hover {
  transform: translateY(-1px);
  background: var(--color-base-300);
  border-color: color-mix(in oklab, var(--color-primary) 45%, transparent);
}
.tree-quick:active { transform: translateY(0); }

/*
 * the "/" hint. it is a reminder, not a label: once you are typing you have
 * plainly received the message, so it gets out of the way.
 * :placeholder-shown is the only honest test for "the box is empty" — value
 * is a property, not a selector.
 */
.tree-kbd { transition: opacity 0.12s ease; }
.tree-search:focus-within .tree-kbd,
.tree-search input:not(:placeholder-shown) ~ .tree-kbd { opacity: 0; }

.tree-search:focus-within {
  border-color: color-mix(in oklab, var(--color-primary) 55%, transparent);
}

/*
 * icon tiles. resting state is the quiet grey daisyUI gives them; the row
 * under the cursor — and the branch you have open — borrows the accent.
 * that second one matters more than it sounds: with six branches collapsed
 * and one open, the open one should be findable without reading.
 */
#sitetree .tree-ico,
.tree-quick .tree-ico {
  transition: background-color 0.12s ease, color 0.12s ease;
}
#sitetree li > a:hover .tree-ico,
#sitetree li > details > summary:hover .tree-ico,
.tree-quick:hover .tree-ico {
  background: color-mix(in oklab, var(--color-primary) 18%, transparent);
  color: var(--color-primary);
}
#sitetree details[open] > summary .tree-ico {
  background: color-mix(in oklab, var(--color-primary) 12%, transparent);
  color: var(--color-primary);
}

/* the chevron leans into the row you're pointing at. */
#sitetree .tree-chev { transition: transform 0.12s ease, color 0.12s ease; }
#sitetree li > a:hover .tree-chev {
  transform: translateX(2px);
  color: color-mix(in oklab, var(--color-base-content) 60%, transparent);
}

/*
 * how many pages live down there. tabular figures so a column of them lines
 * up, and quiet enough that it reads as texture until you look for it.
 */
#sitetree .tree-count {
  font-size: 0.6875rem;
  font-variant-numeric: tabular-nums;
  color: color-mix(in oklab, var(--color-base-content) 35%, transparent);
}

/*
 * nesting rail — one hairline down the left of every expanded branch, so a
 * file four levels into the working folder still reads as four levels in.
 * the project tree can nest; without this it's an indent you have to measure.
 *
 * daisyUI's menu draws its own rail as a ::before on the nested <ul>; that one
 * is switched off and restated as a border on the same element, so the line is
 * ours to tint and cannot end up drawn twice a pixel apart. indentation stays
 * daisyUI's (logical properties, so RTL still works) — only the line is here.
 */
#sitetree :where(li ul)::before { display: none; }   /* daisyUI's own rail, restated below */
#sitetree ul {
  border-inline-start: 1px solid color-mix(in oklab, var(--color-base-content) 10%, transparent);
}

/* top-level sections get a hairline between them — the tree's only divider. */
#sitetree > li + li {
  border-top: 1px solid color-mix(in oklab, var(--color-base-content) 7%, transparent);
}

/* a search that hid the row above shouldn't leave its divider behind. */
#sitetree > li.hidden + li { border-top: 0; }

@media (prefers-reduced-motion: reduce) {
  .tree-quick,
  #sitetree .tree-ico,
  #sitetree .tree-chev,
  .tree-kbd { transition: none; }
  .tree-quick:hover { transform: none; }
  #sitetree li > a:hover .tree-chev { transform: none; }
}


/* 6. Drop target (partials/files_region.html + js/upload.js)
   ---------------------------------------------------------

   the file table while a drag is over it. a real class rather than utilities
   toggled from javascript: the Tailwind browser runtime generates CSS from
   what it finds in the DOM, and a class that only ever exists mid-drag is a
   class it has no reason to have generated yet.

   an outline rather than a border, because a border would resize the table
   under the cursor you are aiming with.
*/
.drop-hot {
  outline: 2px dashed color-mix(in oklab, var(--color-primary) 70%, transparent);
  outline-offset: 6px;
  border-radius: var(--radius-box, 0.5rem);
}


/* 7. Command bar (components/commandbar.html + js/command.js)
   ----------------------------------------------------------

   the bar is daisyUI's modal and menu doing nearly all of it. what's left is
   the one state a menu has no concept of: the row the KEYBOARD is on, which
   is not the row the mouse is over and not the page you're currently looking
   at either.

   the same treatment hover gets, so arrowing down the list and running the
   mouse down it feel like the same gesture — which they are.
*/
#commandbar-list .cmd-active > a,
#commandbar-list .cmd-row > a:hover {
  background: color-mix(in oklab, var(--color-primary) 14%, transparent);
}
#commandbar-list .cmd-active > a .tree-ico,
#commandbar-list .cmd-row > a:hover .tree-ico {
  background: color-mix(in oklab, var(--color-primary) 22%, transparent);
  color: var(--color-primary);
}

/* the dialog opens a fifth of the way down rather than centred: the list grows
   downward, and a centred box that changes height as you type moves the thing
   you are reading. */
#commandbar .modal-box { transition: none; }


/* 8. Toolbox (pages/toolbox.html + js/toolbox.js)
   ---------------------------------------------

   a palette strip, a rail, and one pane. everything below answers to the
   width of .tb-shell rather than the viewport — the page is as often inside
   a desktop window as it is full-screen, and a window 700px wide on a 2560px
   monitor wants the narrow layout. narrow: the rail is a wrapping row of
   tabs above the pane (wrapping, not scrolling: a tab you'd have to scroll
   to find is a tab you don't know exists). wide: a sticky column with the
   group names beside the pane.
*/
.tb-shell { container-type: inline-size; }
.tb-layout { display: grid; gap: 1rem; }

.tb-rail { display: flex; flex-wrap: wrap; gap: .25rem; }
.tb-group { display: contents; }
.tb-group-label { display: none; }

.tb-item {
  display: flex; align-items: center; gap: .5rem;
  padding: .25rem .625rem .25rem .25rem;
  border-radius: var(--radius-field, .5rem);
  white-space: nowrap; font-size: .875rem;
  color: color-mix(in oklab, var(--color-base-content) 70%, transparent);
}
.tb-item:hover { background: color-mix(in oklab, var(--color-base-content) 6%, transparent); }
.tb-item[aria-current="true"] {
  background: var(--color-base-200);
  color: var(--color-base-content);
  font-weight: 600;
}
.tb-item[aria-current="true"] .tree-ico {
  background: color-mix(in oklab, var(--color-primary) 22%, transparent);
  color: var(--color-primary);
}
/* the live dot: a tool's window is open and its bridge is talking. painted by arcade.js. */
.tb-live {
  flex: none; width: .5rem; height: .5rem; border-radius: 999px;
  margin-inline-start: auto;
  background: var(--color-success);
  visibility: hidden;
}
.tb-item.is-live .tb-live { visibility: visible; }

@container (min-width: 44rem) {
  .tb-layout { grid-template-columns: 13rem minmax(0, 1fr); align-items: start; }
  .tb-rail {
    flex-direction: column; flex-wrap: nowrap; gap: .75rem;
    position: sticky; top: 5rem;
  }
  .tb-group { display: flex; flex-direction: column; gap: .125rem; }
  .tb-group-label {
    display: block; padding: 0 .625rem .25rem;
    font-size: .6875rem; letter-spacing: .06em; text-transform: uppercase;
    color: color-mix(in oklab, var(--color-base-content) 45%, transparent);
  }
}

.tb-pane[hidden] { display: none !important; }

/* the host half's shared pieces: generated code, and the tool's own keys. */
.tb-code {
  max-height: 28rem; overflow: auto;
  border-radius: var(--radius-box, .5rem);
  background: var(--color-base-300);
  padding: .75rem;
}
.tb-keys {
  font-size: .75rem; line-height: 2;
  color: color-mix(in oklab, var(--color-base-content) 55%, transparent);
}

/* the palette strip: 8 swatches a row when narrow, all 16 in one row when there's room. palette.js renders them. */
.tb-swatches { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: .25rem; }
@container (min-width: 36rem) {
  .tb-swatches { grid-template-columns: repeat(16, minmax(0, 1fr)); }
}
.tb-swatch { display: flex; flex-direction: column; align-items: stretch; gap: .125rem; }
.tb-swatch input[type="color"] {
  width: 100%; height: 1.75rem; padding: 0; border: 0;
  background: none; cursor: pointer;
  border-radius: .375rem;   /* not the theme's selector radius: some themes make that a pill */
}
.tb-swatch input[type="color"]::-webkit-color-swatch-wrapper { padding: 0; }
.tb-swatch input[type="color"]::-webkit-color-swatch {
  border: 1px solid color-mix(in oklab, var(--color-base-content) 18%, transparent);
  border-radius: .375rem;   /* not the theme's selector radius: some themes make that a pill */
}
.tb-swatch input[type="color"]::-moz-color-swatch {
  border: 1px solid color-mix(in oklab, var(--color-base-content) 18%, transparent);
  border-radius: .375rem;   /* not the theme's selector radius: some themes make that a pill */
}
.tb-swatch span {
  text-align: center; font-family: var(--font-mono, monospace); font-size: .625rem;
  color: color-mix(in oklab, var(--color-base-content) 45%, transparent);
}


/* 9. Library (pages/library*.html)
   -------------------------------

   borrows the toolbox's shell, rail and code slab (§8) and adds three
   things: a card grid that answers to its container, the section headings,
   and docstring text — which is written as plain text with meaningful
   line breaks and indentation, so it is shown as exactly that.
*/
.lib-grid { display: grid; gap: 1rem; grid-template-columns: minmax(0, 1fr); }
@container (min-width: 40rem) { .lib-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@container (min-width: 64rem) { .lib-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }

.lib-h { font-size: 1.125rem; font-weight: 600; margin-bottom: .75rem; }
.lib-h span {
  font-size: .8125rem; font-weight: 400; margin-inline-start: .5rem;
  color: color-mix(in oklab, var(--color-base-content) 55%, transparent);
}

/* monospace, because the docstrings are written in it: aligned columns, indented examples, ascii tables all mean something */
.lib-doc {
  white-space: pre; overflow-x: auto;      /* the source's own line breaks; wrapping a hanging indent makes nonsense of it */
  font-family: var(--font-mono, monospace); font-size: .75rem; line-height: 1.65;
  color: color-mix(in oklab, var(--color-base-content) 80%, transparent);
}
.lib-sig { font-family: var(--font-mono, monospace); font-size: .875rem; overflow-wrap: anywhere; }
.lib-source { max-height: none; }

/* the layers strip: px → bop → extras/. a row when there's room, a stack when there isn't; the opt-in layer is dashed, because it's the
   one that isn't there until you put it there */
.lib-layers { display: grid; gap: .5rem; grid-template-columns: minmax(0, 1fr); }
@container (min-width: 52rem) { .lib-layers { grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1.3fr); align-items: stretch; } }
.lib-layer {
  display: flex; flex-direction: column; gap: .375rem; padding: .75rem .875rem; border-radius: var(--radius-box, .5rem);
  background: var(--color-base-200); border: 1px solid color-mix(in oklab, var(--color-base-content) 10%, transparent);
}
.lib-layer-opt { border-style: dashed; border-color: color-mix(in oklab, var(--color-primary) 55%, transparent); }
.lib-layer-join {
  display: none; align-self: center; font-family: var(--font-mono, monospace);
  color: color-mix(in oklab, var(--color-base-content) 35%, transparent);
}
@container (min-width: 52rem) { .lib-layer-join { display: block; } }

/* an extra's own page: its words on the left, the at-a-glance column on the right */
.lib-extra { display: grid; gap: 1.25rem; grid-template-columns: minmax(0, 1fr); }
@container (min-width: 56rem) { .lib-extra { grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr); } }

/* a diff: the project's copy against the library's, coloured by line kind */
.lib-diff { white-space: pre; overflow-x: auto; font-family: var(--font-mono, monospace); font-size: .75rem; line-height: 1.55; }
.lib-diff > div { padding-inline: .5rem; }
.lib-diff .d-add  { background: color-mix(in oklab, var(--color-success) 16%, transparent); }
.lib-diff .d-del  { background: color-mix(in oklab, var(--color-error) 16%, transparent); }
.lib-diff .d-hunk { color: color-mix(in oklab, var(--color-info) 85%, var(--color-base-content)); margin-top: .5rem; }
.lib-diff .d-head { color: color-mix(in oklab, var(--color-base-content) 55%, transparent); }

/* the reference's "on this page" list is long; in the narrow layout the rail is a row of tabs, and a row of twenty section tabs helps nobody */
.lib-toc { display: none; }
@container (min-width: 44rem) { .lib-toc { display: flex; } }
