/* TaskModal Mark Complete celebration animation. @property opts the angle var
   into the typed CSS system so it can animate via @keyframes (plain custom
   properties can't be tweened). The .giki-modal-celebrate ::after layer paints
   a conic-gradient that's masked down to a 3px ring along the modal's rounded
   perimeter — a single green arc sweeps all the way around once, then the
   permanent .giki-modal-done border takes over as the standing visual cue.
   These classes are toggled by Giki.Web/Tasks/TaskModal.razor. */
@property --giki-celebrate-angle {
    syntax: '<angle>';
    initial-value: 0deg;
    inherits: false;
}
@keyframes giki-celebrate-spin {
    to { --giki-celebrate-angle: 360deg; }
}
.giki-modal-celebrate::after {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: inherit;
    padding: 3px;
    background: conic-gradient(
        from var(--giki-celebrate-angle),
        transparent 0deg,
        transparent 270deg,
        #22c55e 305deg,
        #22c55e 345deg,
        transparent 360deg
    );
    -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
    -webkit-mask-composite: xor;
    mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
    mask-composite: exclude;
    animation: giki-celebrate-spin 1500ms linear forwards;
    pointer-events: none;
    z-index: 50;
}
/* Permanent green border once a task is Done. Class chained with itself for
   higher specificity than the default border-[var(--color-border)]. */
.giki-modal-done.giki-modal-done {
    border-color: #22c55e;
}
@keyframes giki-completed-pop {
    0% { transform: scale(0.6); opacity: 0; }
    60% { transform: scale(1.15); opacity: 1; }
    100% { transform: scale(1); opacity: 1; }
}
.giki-completed-pop {
    animation: giki-completed-pop 500ms cubic-bezier(.34, 1.56, .64, 1) 1;
}

/* Visual cue when ChatPopover advances to the next/previous chat in an unread
   queue session — a quick slide+fade from the right (next) or left (prev). The
   wrapper uses @key on the atomId so Blazor remounts on chat change, which
   means this animation plays naturally on every queue navigation. Toggled by
   Giki.Web/Chat/ChatPopover.razor. */
@keyframes giki-chat-advance-in {
    from { opacity: 0; transform: translateX(20px); }
    to   { opacity: 1; transform: translateX(0); }
}
.giki-chat-advance-in {
    animation: giki-chat-advance-in 280ms ease-out;
}

.valid.modified:not([type=checkbox]) {
    outline: 1px solid #26b050;
}

.invalid {
    outline: 1px solid red;
}

.validation-message {
    color: red;
}

h1:focus {
    outline: none;
}

#blazor-error-ui {
    color-scheme: light only;
    background: lightyellow;
    bottom: 0;
    box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
    box-sizing: border-box;
    display: none;
    left: 0;
    padding: 0.6rem 1.25rem 0.7rem 1.25rem;
    position: fixed;
    width: 100%;
    z-index: 1000;
}

    #blazor-error-ui .dismiss {
        cursor: pointer;
        position: absolute;
        right: 0.75rem;
        top: 0.5rem;
    }

.blazor-error-boundary {
    background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
    padding: 1rem 1rem 1rem 3.7rem;
    color: white;
}

    .blazor-error-boundary::after {
        content: "An error has occurred."
    }

.loading-progress {
    position: absolute;
    display: block;
    width: 8rem;
    height: 8rem;
    inset: 20vh 0 auto 0;
    margin: 0 auto 0 auto;
}

    .loading-progress circle {
        fill: none;
        stroke: #e0e0e0;
        stroke-width: 0.6rem;
        transform-origin: 50% 50%;
        transform: rotate(-90deg);
    }

        .loading-progress circle:last-child {
            stroke: #1b6ec2;
            stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%;
            transition: stroke-dasharray 0.05s ease-in-out;
        }

.loading-progress-text {
    position: absolute;
    text-align: center;
    font-weight: bold;
    inset: calc(20vh + 3.25rem) 0 auto 0.2rem;
}

    .loading-progress-text:after {
        content: var(--blazor-load-percentage-text, "Loading");
    }

code {
    color: #c02d76;
}

.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
    color: var(--bs-secondary-color);
    text-align: end;
}

.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
    text-align: start;
}

/* ===== MainLayout Styles ===== */
/* Sidebar scrollbar styling */
.sidebar-scrollbar::-webkit-scrollbar {
    width: 6px;
}

.sidebar-scrollbar::-webkit-scrollbar-track {
    background: transparent;
}

.sidebar-scrollbar::-webkit-scrollbar-thumb {
    background: var(--color-border);
    border-radius: var(--radius-full);
}

.sidebar-scrollbar::-webkit-scrollbar-thumb:hover {
    background: var(--color-text-subtle);
}

/* ===== SplitPane ===== */
.split-pane {
    display: flex;
    flex-direction: column;
    flex: 1;
    min-height: 0;
    overflow: hidden;
}

.split-pane-top {
    height: var(--split-top-pct, 50%);
    min-height: 0;
    overflow-y: auto;
}

.split-pane-bottom {
    flex: 1;
    min-height: 0;
    overflow: auto;
}

.split-pane-handle {
    display: flex;
    align-items: center;
    justify-content: center;
    height: 8px;
    cursor: row-resize;
    user-select: none;
    touch-action: none;
    flex-shrink: 0;
}

.split-pane-handle-bar {
    width: 100%;
    height: 4px;
    border-radius: var(--radius-full);
    background: var(--color-border);
    transition: background 0.15s;
}

.split-pane-handle:hover .split-pane-handle-bar {
    background: var(--color-primary);
}

/* Hidden scrollbar – still scrollable */
.scrollbar-none {
    scrollbar-width: none;
}

.scrollbar-none::-webkit-scrollbar {
    display: none;
}

.scrollbar-thin-x:hover::-webkit-scrollbar-thumb:hover {
    background: rgba(128, 128, 128, 0.5);
}

/* ===== Attention dot pulse ===== */
@keyframes dot-pulse {
    0%, 100% { opacity: 1; transform: scale(1); }
    50% { opacity: .55; transform: scale(.85); }
}

.animate-dot-pulse {
    animation: dot-pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;
}

/* The same "somebody is waiting for you" beat, for a GLYPH rather than a dot (2026-08-01).
   dot-pulse scales 1 → .85, which is a gentle breathing light on a 5px dot and a shrinking,
   harder-to-read picture on a 22px icon — and it fought the slot's hover:scale-110, since an
   infinite transform animation and a hover transform cannot both win. Fading alone carries the
   same beat and leaves the glyph steady enough to recognise at a glance. */
@keyframes attention-pulse {
    0%, 100% { opacity: 1; }
    50% { opacity: .5; }
}

.animate-attention-pulse {
    animation: attention-pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;
}

/* Prevent body scroll when mobile menu is open */
body.mobile-menu-open {
    overflow: hidden;
}

/* Active nav item styling for MainLayout */
.nav-item-active {
    background-color: var(--color-primary-light) !important;
    color: var(--color-primary) !important;
}
/* ===== End MainLayout Styles ===== */

/* ===== Global thin translucent scrollbars ===== */
* {
    scrollbar-width: thin;
    scrollbar-color: rgba(128, 128, 128, 0.35) transparent;
}

*::-webkit-scrollbar {
    width: 6px;
    height: 6px;
}

*::-webkit-scrollbar-track {
    background: transparent;
}

*::-webkit-scrollbar-thumb {
    background: rgba(128, 128, 128, 0.35);
    border-radius: 9999px;
}

*::-webkit-scrollbar-thumb:hover {
    background: rgba(128, 128, 128, 0.55);
}

/* ===== Popover Animations ===== */
@keyframes popover-in {
    from {
        opacity: 0;
        transform: scale(0.95) translateY(-4px);
    }
    to {
        opacity: 1;
        transform: scale(1) translateY(0);
    }
}

.animate-popover-in {
    animation: popover-in 0.12s ease-out;
}

/* ===== Bottom sheet (Shared/BottomSheet.razor — design-system lock 2026-08-01) ===== */
@keyframes sheet-in {
    from { opacity: 0.6; transform: translateY(24px); }
    to   { opacity: 1;   transform: translateY(0); }
}

.animate-sheet-in {
    animation: sheet-in 0.2s cubic-bezier(0.22, 1, 0.36, 1);
}

/* The sheet panel slides up from the bottom on mobile and pops like a popover on
   desktop. A plain CSS class because Tailwind cannot variant the app.css animation
   classes (md:animate-popover-in would silently not exist). */
.bottom-sheet-panel {
    animation: sheet-in 0.2s cubic-bezier(0.22, 1, 0.36, 1);
}

@media (min-width: 768px) {
    .bottom-sheet-panel {
        animation: popover-in 0.12s ease-out;
    }
}

/* B7 (2026-08-02): the shell — backdrop + panel — is ALWAYS mounted and only hidden here.
   The reason is in BottomSheet.razor: JS portals the nodes to document.body below 768px, and if you
   portal a node that Blazor later REMOVES, Blazor cleans up in the wrong place. An always-mounted
   node can be moved freely; only the content is conditional.

   The doubled selector is specificity 0,2,0. Necessary, because output.css (Tailwind) loads AFTER
   app.css, and the panel carries `flex` while the backdrop carries `fixed`'s siblings — a
   single-class rule here would lose to `.flex { display: flex }`.

   Side effect, and it is the wanted one: display:none resets CSS animations, so the bottom sheet's
   sheet-in/popover-in plays again on EVERY open — not only on first mount. */
.giki-sheet-hidden.giki-sheet-hidden {
    display: none;
}

/* ===== Done checkmark pop-bounce ===== */
@keyframes done-pop {
    0%   { transform: scale(0.5); opacity: 0; }
    50%  { transform: scale(1.35); }
    70%  { transform: scale(0.9); }
    100% { transform: scale(1); opacity: 1; }
}

.animate-done-pop {
    animation: done-pop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}

/* ===== Row hover lift ===== */
.row-hover-lift {
    transition: transform 0.15s ease, background-color 0.15s ease;
}

.row-hover-lift:hover {
    transform: translateX(3px);
    /* The transform above makes the hovered row its own STACKING CONTEXT, and that quietly
       traps anything absolutely positioned inside it — an open PersonChipPopover resolves its
       z-index against the row instead of against the table, so the rows BELOW paint over the
       card and their names show through it (S/N 404: 10 of 12 sampled points on the card were
       covered; with these two lines, 12 of 12). position:relative is required because z-index
       does nothing on a static element. Do NOT "fix" a covered popover by raising ITS z-index
       — no value can win from inside a lower stacking context. */
    position: relative;
    z-index: 1;
}

/* ===== Collapsed sidebar nav flyout tooltip ===== */
.nav-flyout {
    opacity: 0;
    transition: opacity 0.15s ease;
}

.group:hover > .nav-flyout {
    opacity: 1;
}

/* ===== Popover source-row spotlight =====
   2026-08-02 (Christian, C1): `--brand` and `--brand-bg` are deleted as a concept. They were
   defined in ZERO places across the whole repo — there was no brand concept distinct from primary,
   only two names nobody had ever given a value.

   The consequence was not cosmetic: `background` survived on its fallback and looked right, but
   `box-shadow: inset 3px 0 0 0 var(--brand)` had NO fallback. A var() without a fallback whose
   token does not exist makes the WHOLE declaration invalid at computed-value time — so the 3px
   purple left edge, which is the entire point of highlighting the source row, had never been drawn.
   No warning anywhere; the rule sat in the file looking complete.

   The background was also a third purple (#9107FA) that neither light (#831CEF) nor dark (#a55aff)
   knows about. Both now follow the theme. */
.popover-source-row {
    background: color-mix(in srgb, var(--color-primary) 6%, transparent);
    box-shadow: inset 3px 0 0 0 var(--color-primary);
}

/* ===== Done row slide animations ===== */
@keyframes row-done-out {
    0%   { opacity: 1; transform: translateY(0); max-height: 80px; }
    60%  { opacity: 0.3; transform: translateY(12px); }
    100% { opacity: 0; transform: translateY(20px); max-height: 0; padding-top: 0; padding-bottom: 0; }
}

@keyframes row-undone-out {
    0%   { opacity: 1; transform: translateY(0); max-height: 80px; }
    60%  { opacity: 0.3; transform: translateY(-12px); }
    100% { opacity: 0; transform: translateY(-20px); max-height: 0; padding-top: 0; padding-bottom: 0; }
}

@keyframes row-arrive {
    0%   { opacity: 0; transform: translateX(-8px); background-color: rgba(34,197,94,0.15); }
    50%  { opacity: 1; background-color: rgba(34,197,94,0.10); }
    100% { opacity: 1; transform: translateX(0); background-color: transparent; }
}

/* ===== Page title fly-up animation ===== */
@keyframes page-title-fly-up {
    0%   { opacity: 0; transform: translateY(22px); }
    100% { opacity: 1; transform: translateY(0); }
}

.animate-page-title {
    animation: page-title-fly-up 0.38s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}

.animate-row-done-out {
    animation: row-done-out 0.3s ease-in forwards;
    overflow: hidden;
}

.animate-row-undone-out {
    animation: row-undone-out 0.3s ease-in forwards;
    overflow: hidden;
}

.animate-row-arrive {
    animation: row-arrive 0.4s ease-out;
}

/* ===== Calendar card hover lift ===== */
.card-hover-lift {
    transition: transform 0.12s ease, background-color 0.15s ease, box-shadow 0.12s ease;
}

.card-hover-lift:hover {
    transform: translateX(2px);
    box-shadow: -2px 0 0 0 var(--color-primary);
}

/* ===== Projects page: force single-row FlowList on all screen sizes =====
   Projects typically has many more flows than MeToday/TeamToday because we
   include flows that have projects (even without live MeToday items). The
   shared FlowList uses md:flex-wrap which makes multi-row on desktop —
   override to horizontal scroll instead so the strip stays one row. */
.projects-flowlist-noscroll-wrap .scrollbar-none {
    flex-wrap: nowrap !important;
    overflow-x: auto !important;
}
/* FB-36: gentle pulse on the dept-nav peek strip so the eye catches it
   without it being noisy. ~2.6s breath, low-amplitude opacity sweep. */
@keyframes deptPeekPulse {
    0%, 100% { opacity: 0.45; }
    50%      { opacity: 1; }
}

/* Flow-pick pulse (Christian, 2026-07-17): when a dept is chosen but NO flow is, the board is
   deliberately empty ("Pick a flow to see its board") — but nothing pointed the eye at the flow
   pills. A gentle purple breath on the pill text + a soft tint fades in and out until a flow is
   picked. Same idiom as deptPeekPulse / the attention dots (TasksVisualTest E13): pulse = "look
   here", never louder than a breath. ~2.4s so it reads calm, not alarmed. */
@keyframes flowPickPulse {
    0%, 100% { color: var(--color-text); background-color: transparent; }
    50%      { color: var(--color-primary); background-color: rgba(145, 7, 250, 0.10); }
}

/* Curtain-peek RETIRED 2026-07-17 (Christian, superseding his own list item #1 from 2026-07-03):
   the sliding pointer-events-none overlay looked like an OPEN sidebar that ignored clicks. Its
   trigger now auto-opens the real sidebar (MainLayout.OnLayoutStateChanged) and the dept names
   carry the purple pick-pulse (flowPickPulse above) — one shared "choose here" idiom with the
   flow pills. deptPeekPulse stays: the collapsed handle-rail still breathes. */

/* §47.H rev2 (2026-05-24, Christian): in-line markings in the rich-text editor.
   One marking can carry SEVERAL references at once (an audit + a training + a lubrication task on
   the same text). The refs live in the data-refs JSON attribute; badges render as inline
   <sup class="giki-badge giki-badge-{kind}"> elements after the text.

   Single ref → the span gets class "giki-ref giki-ref-{kind}" (colour-coded background).
   Multi ref  → the span gets class "giki-ref giki-ref-multi" (neutral striped background).
   Badges (<sup>) always stack after the text, one per distinct ref kind.
*/
.giki-ref {
    padding: 0 2px;
    border-radius: 3px;
    cursor: pointer;
    transition: background-color 120ms, box-shadow 120ms;
    position: relative;
}
.giki-ref:hover {
    box-shadow: 0 0 0 1px currentColor inset;
}

/* Single-ref styles */
.giki-ref-compliance {
    background-color: rgba(239, 68, 68, 0.14);
    color: rgb(185, 28, 28);
    border-bottom: 1.5px solid rgb(239, 68, 68);
}
.dark .giki-ref-compliance { color: rgb(248, 113, 113); }

.giki-ref-task-recurring {
    background-color: rgba(59, 130, 246, 0.14);
    color: rgb(29, 78, 216);
    border-bottom: 1.5px solid rgb(59, 130, 246);
}
.dark .giki-ref-task-recurring { color: rgb(96, 165, 250); }

.giki-ref-lms {
    background-color: rgba(168, 85, 247, 0.14);
    color: rgb(126, 34, 206);
    border-bottom: 1.5px solid rgb(168, 85, 247);
}
.dark .giki-ref-lms { color: rgb(192, 132, 252); }

/* SKILL AND DOCUMENT WERE MISSING ENTIRELY until 2026-08-14 — Christian: "no markings can be seen in
   the text" after saving on a Skill.

   MEASURED in the browser: the span HAD its class (`giki-ref giki-ref-skill`) and its badge, but
   background-color read rgba(0,0,0,0) and border-bottom read "none 0px". No rule existed. So the
   marking was created, saved and clickable — and completely invisible.

   This is the SECOND time the same omission happened: H16 added Skill and Document to the marking
   menu without giving them either a badge icon (fixed in H21) or a colour (fixed here). When a type
   is added in one place it has to be added in ALL of them — the menu, the panel, the badge and this
   block.

   The colours are taken from the menu's own: Skill emerald, Document amber, the same as the buttons
   in AtomFormModalRichTextBlock and in the markings panel. */
.giki-ref-skill {
    background-color: rgba(16, 185, 129, 0.14);
    color: rgb(4, 120, 87);
    border-bottom: 1.5px solid rgb(16, 185, 129);
}
.dark .giki-ref-skill { color: rgb(52, 211, 153); }

.giki-ref-document {
    background-color: rgba(245, 158, 11, 0.14);
    color: rgb(180, 83, 9);
    border-bottom: 1.5px solid rgb(245, 158, 11);
}
.dark .giki-ref-document { color: rgb(251, 191, 36); }

.giki-ref-link {
    background-color: rgba(100, 116, 139, 0.14);
    color: rgb(51, 65, 85);
    border-bottom: 1.5px solid rgb(100, 116, 139);
}
.dark .giki-ref-link { color: rgb(148, 163, 184); }

.giki-ref-request {
    background-color: rgba(245, 158, 11, 0.14);
    color: rgb(180, 83, 9);
    border-bottom: 1.5px dashed rgb(245, 158, 11);
}
.dark .giki-ref-request { color: rgb(251, 191, 36); }

/* §47.H rev2: multi-ref — a striped background, so it visually signals "several pearls at once".
   The border-bottom is a gradient of all the active colours (shown via the badges below). */
.giki-ref-multi {
    background-image:
        repeating-linear-gradient(135deg,
            rgba(0,0,0,0.04) 0,
            rgba(0,0,0,0.04) 4px,
            rgba(0,0,0,0.10) 4px,
            rgba(0,0,0,0.10) 8px);
    color: var(--color-text);
    border-bottom: 1.5px solid rgb(100, 116, 139);
}
.dark .giki-ref-multi {
    background-image:
        repeating-linear-gradient(135deg,
            rgba(255,255,255,0.05) 0,
            rgba(255,255,255,0.05) 4px,
            rgba(255,255,255,0.10) 4px,
            rgba(255,255,255,0.10) 8px);
}

/* §47.H rev2: a badge element (sup) appended inline after the marking text.
   Indholdet er Bootstrap Icons via ::before-content. */
sup.giki-badge {
    display: inline-block;
    font-size: 0.7em;
    margin-left: 3px;
    opacity: 0.8;
    line-height: 1;
    vertical-align: super;
}
/* H21 (2026-08-12): badges are drawn with EMOJI, not with an icon font.

   MEASURED: the rules here pointed at "bootstrap-icons" with unicode code points, and that font is
   not loaded — document.fonts holds only Inter. The result was a tofu glyph: two empty squares in
   the text, SEEN on screen. So the badges had never shown an icon since §47.H rev2 was built in May
   — and that badge is exactly the "indication" that was supposed to say something hangs on this
   passage.

   document.fonts.check('12px bootstrap-icons') answers true anyway — it says the font CAN be used
   (with fallback), not that it is loaded. That kind of answer is worse than no answer.

   Emoji needs no loading and uses the same characters the markings panel shows in its list, so a
   type looks the same in both places. Skill and Document were missing entirely — H16 added the types
   to the menu, but not here. */
/* H28 (2026-08-14): THE EMOJI IS OUT, the app's own icon set is in.

   Christian: "we need a different marking display that is not just colour — maybe the icons used on
   the popup."

   Emoji was right in H21 compared with the tofu squares it replaced — but it is still a different
   visual language from the rest of the app, and it renders differently on Windows, Mac and Android.
   Now helpers.js `_renderSpan` inserts an inline SVG from the SAME set the marking menu uses
   (Shared/GiIcon.razor). The icon you clicked to create the thing is the one standing in the text
   afterwards.

   `request` keeps its emoji: it does not appear in the menu and therefore has no icon to inherit. */
sup.giki-badge::before {
    font-style: normal;
    font-weight: normal;
}
sup.giki-badge-request::before        { content: "⏳"; }

/* The icon is drawn as a MASK and not as an image, so it inherits the badge's colour via
   currentColor. An <img> would lock the colour and turn the six types into six identical grey marks
   in dark mode.

   The paths are verbatim from Shared/GiIcon.razor — the same six the marking menu draws. One
   language: the icon you clicked to create the thing is the one standing in the text afterwards. If
   an icon changes in GiIcon it has to change here too; that is the price of the marking surviving
   the sanitizer, which does not allow <svg> — see helpers.js _renderSpan. */
sup.giki-badge:not(.giki-badge-request)::before {
    content: "";
    display: inline-block;
    width: 1em;
    height: 1em;
    vertical-align: -0.14em;
    background-color: currentColor;
    -webkit-mask-repeat: no-repeat;
    mask-repeat: no-repeat;
    -webkit-mask-position: center;
    mask-position: center;
    -webkit-mask-size: contain;
    mask-size: contain;
}

sup.giki-badge-compliance::before {
    -webkit-mask-image: var(--giki-icon-compliance);
    mask-image: var(--giki-icon-compliance);
}
sup.giki-badge-task-recurring::before {
    -webkit-mask-image: var(--giki-icon-recurring);
    mask-image: var(--giki-icon-recurring);
}
sup.giki-badge-lms::before {
    -webkit-mask-image: var(--giki-icon-lms);
    mask-image: var(--giki-icon-lms);
}
sup.giki-badge-skill::before {
    -webkit-mask-image: var(--giki-icon-skill);
    mask-image: var(--giki-icon-skill);
}
sup.giki-badge-document::before {
    -webkit-mask-image: var(--giki-icon-document);
    mask-image: var(--giki-icon-document);
}
sup.giki-badge-link::before {
    -webkit-mask-image: var(--giki-icon-link);
    mask-image: var(--giki-icon-link);
}

/* The drawings themselves. As variables, because a data URI containing quotes and # has to be
   encoded exactly, and one place to fix is better than twelve (each mask appears twice, for the
   webkit prefix). */
:root {
    --giki-icon-compliance: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z'/%3E%3Cpath d='m9 12 2 2 4-4'/%3E%3C/svg%3E");
    --giki-icon-recurring: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8'/%3E%3Cpath d='M21 3v5h-5'/%3E%3Cpath d='M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16'/%3E%3Cpath d='M8 16H3v5'/%3E%3C/svg%3E");
    --giki-icon-lms: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21.42 10.922a1 1 0 0 0-.019-1.838L12.83 5.18a2 2 0 0 0-1.66 0L2.6 9.08a1 1 0 0 0 0 1.832l8.57 3.908a2 2 0 0 0 1.66 0z'/%3E%3Cpath d='M22 10v6'/%3E%3Cpath d='M6 12.5V16a6 3 0 0 0 12 0v-3.5'/%3E%3C/svg%3E");
    --giki-icon-skill: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='8' r='6'/%3E%3Cpath d='M15.477 12.89 17 22l-5-3-5 3 1.523-9.11'/%3E%3C/svg%3E");
    --giki-icon-document: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z'/%3E%3Cpath d='M14 2v4a2 2 0 0 0 2 2h4'/%3E%3Cpath d='M10 9H8'/%3E%3Cpath d='M16 13H8'/%3E%3Cpath d='M16 17H8'/%3E%3C/svg%3E");
    --giki-icon-link: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 17H7A5 5 0 0 1 7 7h2'/%3E%3Cpath d='M15 7h2a5 5 0 1 1 0 10h-2'/%3E%3Cline x1='8' x2='16' y1='12' y2='12'/%3E%3C/svg%3E");
}

/* The number stands as text after the icon, when the same type hangs on the sentence more than
   once. */
sup.giki-badge { letter-spacing: 0.02em; }

/* H29 (2026-08-14): the handle between task and chat, WHILE it is being dragged.

   Without this the divider loses its hover colour the instant the cursor leaves it — and it does so
   immediately, because the handle slides along with the split. So you sat dragging something that
   looked inactive. The class is set by gikiSplit on pointerdown and removed on release, so the state
   follows the drag rather than the cursor's incidental position. */
.giki-split-active { background-color: color-mix(in srgb, var(--color-primary) 15%, transparent); }
.giki-split-active > div { background-color: var(--color-primary) !important; }

/* H21: the number, when several of the same type sit on one marking — written as text into the <sup>
   by _renderSpan, and it has to be readable next to the icon in front of it. */
sup.giki-badge { font-variant-numeric: tabular-nums; }

/* H27 (2026-08-13): the badge is a BUTTON, and that has to be visible.
   Clicking it opens the list of what hangs on the passage — the route by which you add a second
   category to the same sentence. Without an affordance it is dead decoration nobody discovers: it
   has been clickable from the second this rule was written, and not one minute before. */
sup.giki-badge {
    cursor: pointer;
    border-radius: 3px;
    padding: 0 1px;
    transition: opacity 120ms ease, background-color 120ms ease;
}
sup.giki-badge:hover {
    opacity: 1;
    background-color: color-mix(in srgb, var(--color-primary) 18%, transparent);
}

/* ===== The gesture contract (agent 6, 2026-08-01) =====
   See x-Docs/fundament-2026-07-30/gesture-kontrakt-2026-08-01.md + js/giki-interactions.js. */

/* A solid popover surface, independent of the Tailwind build. Finding 35 was a transparent context
   menu (card text bled through) — that class of bug must not be able to come back because an
   arbitrary bg utility is missing from output.css. app.css always ships. */
.giki-solid-pop {
    background-color: var(--color-bg-card);
    box-shadow: var(--shadow-xl);
}

/* Body scroll lock while a bottom sheet is open (counter-based in JS, so stacked sheets do not
   unlock too early). Set on <html> by gikiInteractions. */
.giki-scroll-lock,
.giki-scroll-lock body {
    overflow: hidden !important;
    overscroll-behavior: none;
}

/* Long-press zones: iOS's text callout and selection must not hijack the gesture.
   Set automatically by longPressBind (on the children when the wrapper is display:contents, since
   user-select does not inherit through contents). */
.giki-longpress {
    -webkit-user-select: none;
    user-select: none;
    -webkit-touch-callout: none;
}

/* ===== Action popovers: a bottom sheet on the phone, an anchored popover from 768px =====
   (the gold-standard critique's THEME 4, applied to the AtomActions family, 2026-08-02)

   Background, MEASURED 2026-08-02 at 375px: the eight popovers opened at a FIXED position
   (Open(item, 400, 200)) in a 375px-wide screen and sat entirely off-screen. Clamping against 100vw
   fixed the visibility — but a free-floating 280px box is still not the right answer on a phone.
   THEME 4 says bottom sheet, and that is also the MAUI pattern.

   The doubled class selector gives specificity 0,2,0. That is not decoration: output.css (Tailwind)
   loads AFTER app.css, so a single-class rule here would lose to `rounded-xl` and `fixed`'s siblings
   in the markup. Two classes win without !important.

   The coordinates arrive as --px/--py/--pw from the markup's style attribute, so the inline part
   carries only NUMBERS — no positioning that would leak through on mobile. */
.giki-action-pop.giki-action-pop {
    left: 0;
    right: 0;
    bottom: 0;
    top: auto;
    width: auto;
    max-width: 100%;
    max-height: 85dvh;
    overflow-y: auto;
    overscroll-behavior: contain;
    border-radius: 1rem 1rem 0 0;
    padding-bottom: env(safe-area-inset-bottom);
    animation: sheet-in 0.2s cubic-bezier(0.22, 1, 0.36, 1);
}

@media (min-width: 768px) {
    .giki-action-pop.giki-action-pop {
        left: var(--px);
        top: var(--py);
        right: auto;
        bottom: auto;
        width: var(--pw);
        max-width: none;
        max-height: calc(100vh - var(--py) - 12px);
        /* --radius-xl = 0.75rem = the old `rounded-xl` exactly. No visual change on desktop; only
           the mobile presentation is new. */
        border-radius: var(--radius-xl);
        padding-bottom: 0;
        animation: popover-in 0.12s ease-out;
    }
}

/* ===== §1: no action may exist ONLY behind hover =====
   (gesture contract §1 / gold standard THEME 2, 2026-08-02)

   A control that is `opacity-0` until the mouse touches its parent does not exist on a phone and
   does not exist in MAUI. The condition is not "small screen" but "no hover capability": a touch
   laptop at 1400px has exactly the same problem.

   Put .giki-hover-reveal ON the control that already carries opacity-0 group-hover:… — this rule
   leaves the hover behaviour untouched on a mouse and makes the control permanently visible where
   there is no mouse. Doubled selector = specificity 0,2,0, so it beats Tailwind's `opacity-0`
   (output.css loads after app.css) without !important. */
@media (hover: none) {
    /* `html:not(.giki-has-mouse)` added 2026-08-03 — see the block below. Without a genuine mouse
       this is still the governing rule; if we see one, hover behaviour takes over. */
    html:not(.giki-has-mouse) .giki-hover-reveal.giki-hover-reveal {
        /* 0.6 and not 1: the contract says "dimmed if you like, opacity-60, NEVER opacity-0".
           The first version set 1, and MEASURED on a 375px screenshot the card picker shouted just
           as loudly as the done checkmark itself — exactly the confusion the code's own comment
           ("away from the done-checkmark, so it's never confused with the check") was written to
           avoid. Reachable, but not insistent. */
        opacity: 0.6;
        pointer-events: auto;
    }
}

/* ===== When there demonstrably IS a mouse (Christian 2026-08-03) =====

   THE PROBLEM, MEASURED IN CHRISTIAN'S OWN CHROME: his machine is a Windows laptop with a
   touchscreen, so Chrome treats touch as the PRIMARY input. Result: `matchMedia('(hover: hover)')`
   is FALSE — `any-hover` too — even though the mouse works perfectly (MEASURED: 13 elements under
   the cursor, `:hover` firing).

   The consequence is bigger than it sounds: Tailwind wraps the ENTIRE `group-hover:` family in
   `@media (hover: hover)` (output.css:4214-4274, with `.group-hover\:opacity-100` at :4256). So on
   his screen every hover-revealed control in the app is permanently visible — 163 card pickers and
   12 column pickers on one board, MEASURED.

   WHY NOT SIMPLY DROP THE MEDIA GATE: a real phone also reports `hover: none`, and CSS cannot tell
   the two apart. Removing the gate would hide the controls on the phone — a §1.5 violation.

   THE FIX REACTS TO WHAT THE USER DOES, not to what the device claims: `helpers.js` sets
   `.giki-has-mouse` on <html> the first time it sees a pointermove with `pointerType === "mouse"`.
   A finger also generates mousemove for backward compatibility, but NEVER pointerType "mouse", so a
   pure touch device never reaches here and keeps the block above. §1.5 is tightened, not bypassed.

   THE SELECTOR IS `.opacity-0` ON PURPOSE. All seven consumers are MEASURED consistent: `opacity-0`
   means "hidden until hover", and the two that can be locked visible (KanbanCard/KanbanColumn when
   something is SELECTED) set `opacity-100` instead. By matching only `.opacity-0` we never touch a
   control the markup itself keeps visible — a selected box stays put.

   Specificity: 0,3,1 against Tailwind's 0,1,0 for `.opacity-0`, so no !important. */
html.giki-has-mouse .giki-hover-reveal.opacity-0 {
    opacity: 0;
    transition: opacity 150ms ease;
}
html.giki-has-mouse .group:hover .giki-hover-reveal.opacity-0 {
    opacity: 1;
}

/* ===== A clickable button has to look clickable (MEASURED 2026-08-03) =====

   Tailwind v4 removed the preflight rule that gave <button> `cursor: pointer` — in v4 it inherits
   `default` like any other element. The code compensates by writing `cursor-pointer` into the class
   string, and that works in most places, but not all.

   MEASURED on Tasks, counting only VISIBLE and ENABLED buttons: 803 in total, 717 with
   `cursor: pointer`, **86 without (11%)**. Affected were, among others, the entire top bar (New
   atom, Search, How it works, Activity Log, the notification counters, the theme switch), the task
   form's six footer actions (Close, Mark Complete, I can't do it, History, QR, New meeting) and the
   kanban sections' counters.

   That is "rules as code" drift in its purest form: the rule is enforced by copying a class string
   rather than by a primitive, so it drifts every time somebody forgets it. One rule fixes all 86 AND
   stops it happening again.

   `:disabled` and `aria-disabled` are excluded, so a locked button does not promise something it
   will not do — see also the disabled rule below. */
button:not(:disabled):not([aria-disabled="true"]) {
    cursor: pointer;
}

/* ===== A locked control has to look locked (MEASURED 2026-08-03) =====

   The task form says "Preview — only assignees can answer", and the question fields ARE genuinely
   locked: MEASURED `disabled: true` + `pointer-events: none`, so the gate WORKS. But also MEASURED
   was `opacity: 1` with a normal background and border — the field looks exactly like an active one.
   I clicked into it and typed "ZZ test": nothing happened, no reaction, no message.

   The banner explains it perfectly well, but it does not get read when the field looks usable. A
   silent refusal is the worst kind: the user assumes the fault is theirs.

   The dimming is kept light (0.55) so the content can still be READ — a locked task has to be
   reviewable, just not answerable. `cursor: not-allowed` gives the same message on contact. */
input:disabled,
textarea:disabled,
select:disabled {
    opacity: 0.55;
    cursor: not-allowed;
}

/* ===== The chip entrance animation (T13) — MOVED HERE 2026-08-03 =====
   The rules lived in an inline <style> inside AtomFormModalAssignmentPanel. A <style> tag does apply
   to the WHOLE document — but only while the component is rendered. That panel lives in the task
   form's modal, FlowStepModal is a different modal, and the two are never open at the same time. So
   FlowStepModal's ChipAnimClass returned `chip-enter-*` classes that did NOT exist when it asked for
   them. MEASURED: 0 occurrences of chip-enter / chipSlideIn / chipGlow in app.css, output.css and
   theme.css.

   These classes are not written by Tailwind (they are not utilities), so app.css is the right place:
   it is linked directly from index.html:15 and needs no rebuild.

   ⚠ `@@keyframes` in a .razor becomes `@keyframes` here — the double-@ is Razor's escape, not CSS.

   The glow keyframes name the COLOUR in their name (chipGlowPurple/Blue/Green), but the value is the
   role's token (theme.css:82-86). That is deliberate, from C1/A2 2026-08-02: the names stay
   recognisable, and if a role colour changes the glow follows on its own. color-mix rather than
   rgba, because rgba() cannot take a var() as its colour and the 35% has to be preserved. */
@keyframes chipSlideIn {
    0%   { opacity: 0; transform: translateY(8px) scale(0.92); }
    60%  { transform: translateY(-1px) scale(1.02); }
    100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes chipGlowPurple {
    0%, 100% { box-shadow: none; }
    40% { box-shadow: 0 0 8px 2px color-mix(in srgb, var(--color-role-owner) 35%, transparent); }
}
@keyframes chipGlowBlue {
    0%, 100% { box-shadow: none; }
    40% { box-shadow: 0 0 8px 2px color-mix(in srgb, var(--color-role-viewer) 35%, transparent); }
}
@keyframes chipGlowGreen {
    0%, 100% { box-shadow: none; }
    40% { box-shadow: 0 0 8px 2px color-mix(in srgb, var(--color-role-assignee) 35%, transparent); }
}
@keyframes chipGlowAmber {
    0%, 100% { box-shadow: none; }
    40% { box-shadow: 0 0 8px 2px color-mix(in srgb, var(--color-role-template-owner) 35%, transparent); }
}
@keyframes chipGlowTeal {
    0%, 100% { box-shadow: none; }
    40% { box-shadow: 0 0 8px 2px color-mix(in srgb, var(--color-role-template-viewer) 35%, transparent); }
}
.chip-enter-owner           { animation: chipSlideIn 0.4s ease-out, chipGlowPurple 0.65s ease-out; }
.chip-enter-viewer          { animation: chipSlideIn 0.4s ease-out, chipGlowBlue   0.65s ease-out; }
.chip-enter-assignee        { animation: chipSlideIn 0.4s ease-out, chipGlowGreen  0.65s ease-out; }
.chip-enter-template-owner  { animation: chipSlideIn 0.4s ease-out, chipGlowAmber  0.65s ease-out; }
.chip-enter-template-viewer { animation: chipSlideIn 0.4s ease-out, chipGlowTeal   0.65s ease-out; }

/* ===== The chat's DETAIL PANE — reworked 2026-08-03 after Christian's question =====
   The rule is now the same at any width: the LEFT is navigation and stays put, the RIGHT is the
   detail pane and changes content.
     below 768px    one panel: list OR conversation OR info
     768–1279px     list + detail pane (the conversation, or info when it is open)
     1280px and up  list + conversation + info as three columns

   The first version let the LIST give way to the info drawer. Christian asked whether that was
   really right, and it was not: the list is navigation, the info panel is a temporary
   detail/settings surface (participants, media, mute, block). You do not sacrifice navigation for
   something you open, use and close again. The conversation is what should give way — it is the
   thing the user asked to see something else instead of.

   WHY HERE AND NOT IN TAILWIND: `xl:w-80`, `xl:flex-none` and `xl:border-l` — NONE of them exist in
   the shipped output.css (MEASURED with grep -F). output.css is not built automatically; app.css is
   linked directly from index.html:15. */
.giki-chat-detail {
    flex: 1 1 0%;
    min-width: 0;
}
@media (width >= 80rem) {
    /* From 1280 there is room for all three, and info becomes a fixed 320px column again. */
    .giki-chat-detail {
        flex: 0 0 auto;
        width: 20rem;
    }
}

/* ===== K5: the message bubble's 600px ceiling — MOVED OUT OF `xl:` 2026-08-02 =====
   Decision 2026-08-01, "message bubbles get a 600px ceiling", applies to: ALL chat surfaces.
   It was built as `xl:max-w-[600px]` (1280). MEASURED at 1021px — Christian's own width — both lg
   (1024) and xl (1280) were inactive, so the effective ceiling was `sm:max-w-[80%]`: the message row
   is 941px, 80% = 753px. 153px OVER the decided ceiling. So the decision was built behind a
   breakpoint that is dead for the primary user.

   WHY HERE AND NOT IN TAILWIND: `md:max-w-[600px]`, `lg:max-w-[600px]` and `sm:max-w-[600px]` —
   none of them exist in the shipped output.css (MEASURED with grep -F; only the `xl:` variant is
   built). output.css is not built automatically, and app.css is linked directly from index.html:15.

   WHY min() AND NOT A FIXED NUMBER: the percentage and the pixel ceiling are BOTH ceilings, and the
   tighter one has to win. At 768px the row is narrow enough that 80% is below 600, and a fixed
   `max-width: 600px` would there make the bubble WIDER than the percentage rule allowed. min() gives
   exactly "never over 80%, and never over 600px" in one rule.

   Doubled selector = specificity 0,2,0, so it beats Tailwind's `max-w-[90%]` (0,1,0) even though
   output.css loads after app.css. The same device as .giki-hover-reveal above. */
@media (width >= 40rem) {
    .giki-msg-cap.giki-msg-cap {
        max-width: min(80%, 600px);
    }
}

/* ===== The pearls' touch target (B5c / punch 82) — DECIDED AND BUILT 2026-08-02 =====
   Christian 2026-08-02: "yes to wrapping onto two lines on touch — but ONLY in the task form."
   The list rows (Assets/GatekeeperPerspective) keep one line, so the table rhythm is not broken.

   FIRST ATTEMPT, ROLLED BACK the same day — recorded here so nobody tries it again:
   .giki-pearl got `padding: 10px; margin: -10px; box-sizing: content-box`. MEASURED at 375px: the
   pearl width went 24 -> 46px, but the centres were still 34px apart => 12px of OVERLAP in all six
   gaps. The negative margin was the mistake: it gave the hit zone space the layout did not have, so
   a tap in the overlap hit the neighbouring pearl.

   The arithmetic that forces the wrap: seven controls at 44px need 7*44 + 6*8 = 356px. The strip has
   ~230px (MEASURED 2026-08-02; the classes alone give 7*24 + 6*8 = 216px intrinsically, the rest is
   badge overhang — expect 216, not 230, if you re-measure on the element itself). There simply is
   not room on one line. With wrapping there is:
       line 1: 4 pearls = 4*44 + 3*8 = 200px
       line 2: 3 pearls = 3*44 + 2*8 = 148px
   A max-width of 208px locks the split to 4+3: a fifth pearl would need 252px. Without the ceiling
   the browser wraps according to whatever space happens to be there (6+1 at 375px) — two lines, but
   lopsided. The 8px of slack is against rounding, not decoration.

   The pearl becomes 44px VISUALLY too, not only as a hit zone. A 24px dot with 20px of invisible
   padding around it is a control you aim at wrongly — you aim at what you can see, and that is
   exactly what made the attempt above a bug. The icon grows with it (12 -> 20px), otherwise it is a
   dot in a lake; the ratio goes from 50% to 45%, i.e. almost unchanged.

   The condition is `hover: none` and not a width — exactly like .giki-hover-reveal above: a touch
   laptop at 1400px has the same problem. The M11 bug (a media query on the WINDOW's width while the
   panel is 350px) cannot arise here: WHICH surface this is gets decided in C# by WrapPearlsOnTouch,
   not by geometry.

   Why here and not as utilities: h-11/w-11 (44px) is NOT present in the shipped output.css
   (checked), and min-w-44 is a trap — in Tailwind v4 that is 11rem = 176px. @media (hover: none)
   cannot be written as a utility at all. app.css always ships.

   Doubled selector: output.css loads AFTER app.css, so .giki-pearl alone would lose to Tailwind's
   .h-6/.w-6 on cascade order at equal specificity.

   The aria-label half of punch 82 was already closed; it lives in the markup. */
@media (hover: none) {
    /* ⚠ ONE ROW. NOT TWO, NOT THREE.
       Christian 2026-08-16: "it's supposed to be one row and not two. I don't know why you think
       two would be okay." He is right, and accepting two rows as "good enough" after cutting three
       down to two was stopping halfway.

       ── What was actually wrong, MEASURED and not guessed ───────────────────────────────────
       The shell has 387px. The pearls were capped at 208 by a `max-width` HERE — the space was
       there all along; I was the one squeezing them. On top of that the wrapping was set in BOTH
       places (here on the outer row and again on .giki-pearl-pearls), so the outer broke first and
       the inner after it: three rows out of two rules that were each right on their own.

       ── Why not simply remove max-width ────────────────────────────────────────────────────
       Worked out: seven pearls at 44px + six gaps at 8 = 356, plus the overview button (24), the
       separator (9) and two gaps = ~405. On a real 390px phone there is ~340. So a fixed 44px
       cannot fit on one row, whatever you do to max-width.

       ── So they SHARE the width instead of having one ──────────────────────────────────────
       `flex: 1 1 0` on each pearl with `nowrap` on the row: the seven each get a seventh of
       whatever is there, and there is no width to get wrong. At 440px they come out ~44; at 390
       they come out ~40. One row at any width, without a magic number.

       `max-height: 44px` is the ceiling, so they do not grow into slabs on a wide touch laptop —
       downwards they are bounded by the screen, not by a rule.

       ⚠ The OUTER row must be `flex` with `width: 100%`, not `inline-flex`: an inline-flex shrinks
       to its content, and then there is nothing to share. That was the one measuring 240px while
       the shell had 387. */
    .giki-pearl-row.giki-pearl-row {
        display: flex;
        width: 100%;
        flex-wrap: nowrap;
    }

    .giki-pearl-row.giki-pearl-row .giki-pearl-pearls {
        flex: 1 1 auto;
        min-width: 0;
        flex-wrap: nowrap;
        max-width: none;
        gap: 4px;
    }

    /* The pearl's wrapper is the flex child; the button sits inside it, so both need the rule. */
    .giki-pearl-row.giki-pearl-row .giki-pearl-pearls > div {
        flex: 1 1 0;
        min-width: 0;
    }

    .giki-pearl-row.giki-pearl-row .giki-pearl {
        width: 100%;
        height: auto;
        aspect-ratio: 1;
        max-height: 44px;
    }

    /* GiIcon renders an INLINE SVG for the names it has in its library and falls back to
       <i class="bi ..."> for the rest. Five of the seven pearl icons are bi-names, so both paths
       have to be covered — otherwise two icons grow and five do not. */
    .giki-pearl-row.giki-pearl-row .giki-pearl > svg {
        width: 20px;
        height: 20px;
    }

    .giki-pearl-row.giki-pearl-row .giki-pearl > i {
        font-size: 20px;
        line-height: 1;
    }
}

/* ===== Wrap on the phone, truncate on the desktop =====
   The design manual's §2.4 forbids truncation as the only expression. MEASURED at 375px: "Floor
   wash in the area f…" and "Sweep dry goods store" stood side by side, where the first was
   unreadable — and the card had plenty of spare vertical space. From 768px the columns stand side
   by side at a fixed width, and there truncation IS the right answer.
   The rule lives here and not as an md: utility, because output.css is not built automatically:
   `md:truncate` is not present in the shipped stylesheet (checked).

   Used on the card's task name AND on PersonChip's name: MEASURED at 375px it read "Daniel
   Kammersgaard Ga…", and half a person's name breaks the standing avatar requirement (faces AND
   whole personal names). Above 768px the chip sits in fixed columns, where truncation is right. */
@media (max-width: 767.98px) {
    .giki-wrap-mobile.giki-wrap-mobile {
        overflow: visible;
        text-overflow: clip;
        white-space: normal;
        overflow-wrap: anywhere;
    }
}

/* ===== The form's primary action row on the phone =====
   MEASURED 2026-08-02 at 375px: the row Close / I can't do it / Mark Complete sat on ONE line with
   flex-shrink-0, so it refused to give way. It ended 1px from the screen edge, and the green
   primary button — the most important control on screen — was visually cut off. The mockup review
   on 2026-08-01 found the same and wrote "the mobile footer needs the primary action at full width
   and the rest in a menu".

   This is the first half: the row may wrap below 768px, and the primary button (Save while editing,
   Mark Complete in read view) is forced onto its own full-width line. From 768px nothing changes.
   Doubled selector, so flex-shrink-0 in the markup does not win. */
/* The wrapping is UNCONDITIONAL and does not belong in a media query. The first version made it
   max-width: 767.98px — the screen's width — and that was wrong: MEASURED 2026-08-02 at 768px BOTH
   panels stand side by side, the task panel is 350px wide with its right edge at x=385, and the
   button row ran to x=390. "Mark Complete" was clipped by the panel's edge on a screen twice as
   wide as the phone. It is the PANEL's width that decides, not the window's. With unconditional
   wrap the button drops down by itself, exactly when it cannot fit — at any width. */
.giki-form-actions.giki-form-actions {
    flex-wrap: wrap;
    justify-content: flex-end;
    flex-shrink: 1;
    min-width: 0;
}

/* Below 768 px the wrapped primary button additionally gets the whole line. On a phone that is
   the difference between a thumb button and a small button in the right-hand corner. */
@media (max-width: 767.98px) {
    .giki-form-actions.giki-form-actions > .giki-form-action-primary {
        flex: 1 0 100%;
    }
}

/* ===== The file picker's desktop presentation (Christian's decision, 2026-08-02) =====
   BottomSheet is a bottom sheet below 768px and a popover above it. This sheet does not belong to a
   trigger inside a `relative` container — it is a centred choice — so the desktop placement is
   written here rather than as md: utilities.

   The reason is concrete and measured: `md:left-1/2`, `md:top-1/2`, `md:-translate-x-1/2` and
   `md:-translate-y-1/2` are NOT present in the shipped output.css (checked with grep). output.css is
   not built automatically, so a class no other file already uses simply does not exist — and the
   sheet would have stayed pinned to the bottom on desktop. app.css always ships. */
@media (min-width: 768px) {
    .giki-file-sheet.giki-file-sheet {
        position: fixed;
        left: 50%;
        top: 50%;
        right: auto;
        bottom: auto;
        width: 20rem;
        transform: translate(-50%, -50%);
    }
}

/* ===== The task modal's geometry (A2 + B1, 2026-08-02) =====
   These rules live here rather than in the markup's inline style because they have to be able to
   answer a media query. The width arrives as --modal-w from AtomFormModal.razor (the same pattern as
   .giki-action-pop), so the phone rule below can override it WITHOUT !important. The doubled
   selector is 0,2,0, so Tailwind's single classes in the markup do not win. */
.giki-modal-box.giki-modal-box {
    width: var(--modal-w);
    max-height: 92vh;
    border-radius: 0.75rem;
}

/* When the communications panel is open the box MUST take all the height it is allowed — otherwise
   it shrinks to its content and the message thread (flex-1) has nothing to grow into.
   MEASURED 2026-08-02 at 375x812: without a height the box was 317px and the thread 24px; with it,
   the thread became 454px. Without a chat panel the box still hugs its content, so a small task does
   not open as an empty tower (punch 86's complaint runs the other way). */
.giki-modal-box.giki-modal-fill {
    height: 92vh;
}

/* Below 640px there is only room for one panel, and a floating 345x317 box in the middle of a phone
   is not the answer. The modal goes full-screen: no 4vh of air, no rounded corners, the full width —
   and the full height, whether or not the chat panel is showing. It is also the surface MAUI mirrors.
   100dvh and not 100vh: the address bar in mobile browsers has to be part of the arithmetic. */
@media (max-width: 639.98px) {
    .giki-modal-host.giki-modal-host {
        padding-top: 0;
        padding-bottom: 0;
    }

    .giki-modal-box.giki-modal-box {
        width: 100%;
        height: 100dvh;
        max-height: 100dvh;
        border-radius: 0;
    }
}

/* ===== The chat composer inside a narrow panel (A2, 2026-08-02) =====
   MEASURED at 375x812 in the task modal, BEFORE: the row was 339px − px-4 (32) − three gaps (24)
   = 283, of which Attach (40), View once (40) and Send/Voice (40) took 120. The field got 163, and
   after its own px-5 (40) and the emoji button (32), the textarea stood at **91px**.

   The condition is the PANEL's width, not the window's — hence a container query and not a media
   query. That is measured, not a matter of principle: the first version used
   @media (max-width: 767.98px), and at exactly 768px the two panels sit side by side, so the chat
   panel is ~350px wide and the rule did not fire. The textarea came out at 102px on a screen twice
   as wide as the phone. Exactly the same mistake the footer wrapping made — see .giki-form-actions
   below.

   Below a 26rem (416px) panel width: the field takes the whole line and the icon buttons drop under
   it. Above: one line, exactly as today. */
.giki-chat-bar {
    container-type: inline-size;
}

@container (max-width: 26rem) {
    .giki-chat-composer.giki-chat-composer {
        flex-wrap: wrap;
        padding-left: 0.5rem;
        padding-right: 0.5rem;
        /* The home indicator on a phone: the same guard BottomSheet's footer uses. */
        padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
    }

    /* The field gets order:-1, so you see what you are typing ABOVE the buttons, not below them. */
    .giki-chat-composer.giki-chat-composer > .giki-chat-field {
        flex: 1 0 100%;
        order: -1;
        padding-left: 0.875rem;
        padding-right: 0.875rem;
    }

    /* Send/Voice is the row's most important button and belongs at the right edge, where the thumb
       goes looking for it. Without this, all three buttons sat squeezed together on the left with
       ~220px of empty space to the right (MEASURED at 375: x=13 / 61 / 109 in a 369px row).
       Send is the last element in the markup — the emoji popover comes before View once. */
    .giki-chat-composer.giki-chat-composer > button:last-child {
        margin-left: auto;
    }
}

/* The follow-up composer's reminder sheet first had a rule here following the same pattern. It was
   removed again the same day, because the MEASUREMENT at 1021px showed the problem was not placement
   alone: an `md:absolute` panel cannot escape the chat panel's `overflow: hidden`, and 296 of 646px
   were visible even WHEN the placement was right. The sheet now uses BottomSheet.DesktopAnchor, which
   portals to body and lets giki-interactions' flip + clamp compute against the viewport. No CSS
   placement here. */

/* ===== The buttons' SWITCHED-OFF state (punch 39, 2026-08-02) =====
   The recipe lives here and not in UiKit.cs's class strings, for two reasons.

   FIRST, the old recipe was wrong in a way no different number can fix. It was
   `disabled:opacity-40` on the whole button, and `opacity` composites the ENTIRE element — surface
   and text together — against whatever is behind it. So the text loses contrast at exactly the same
   rate as the surface it sits on. Opacity cannot create a difference; it can only wash one out.

   MEASURED on Btn.Primary (and the numbers are computed, not estimated):
       light  enabled   white on #831CEF                    6.22:1
       light  disabled  #ffffff@40% on #cda4f9              2.04:1   <- and the surface itself 2.04:1
       dark   enabled   white on #a55aff                    3.81:1
       dark   disabled  #787f89 on #543d89                  2.16:1
   Punch 39's complaint was the exact opposite of what you would expect: the ACTIVE button looked
   like a disabled one. When both states are "purple, just weaker", there is nothing to tell them
   apart by.

   New recipe — a real state instead of a faded one:
       light  disabled  #4b5563 on #f3f4f6                  6.87:1
       dark   disabled  #94a3b8 on #334155                  4.04:1
   More important than the numbers: enabled and disabled are now separated by HUE (purple vs. grey),
   not by strength. WCAG formally exempts inactive controls, so 4.04 in dark is not a violation — but
   the legibility is there, and the confusion is gone.

   SECOND, it frees up a visual dimension. `opacity` already means "being dragged right now" and
   "stopped" on the kanban card. Having it also carry "disabled" broke the rule that one dimension
   may only have one meaning.

   `disabled:opacity-40` and `disabled:cursor-not-allowed` are REMOVED from all five Btn strings, so
   there are not two recipes fighting. Doubled selector, because output.css loads after app.css.

   Why not Tailwind classes: `disabled:bg-[var(--color-bg-muted)]`,
   `disabled:text-[var(--color-text-muted)]` and `disabled:border-[var(--color-border)]` are NOT
   present in the built output.css (checked). They would have failed silently. */
.giki-btn.giki-btn:disabled {
    cursor: not-allowed;
    box-shadow: none;
    color: var(--color-text-muted);
}

/* Only the FILLED variants (Primary, Secondary) get a neutral surface. Ghost, Danger and GhostIcon
   have no surface when enabled, and a grey box around a disabled text button would add chrome where
   there was none — they make do with the muted ink. */
.giki-btn-filled.giki-btn-filled:disabled {
    background-color: var(--color-bg-muted);
    border-color: var(--color-border);
}

/* ===== Step-kolonnernes SMALLE taethed (Christian 2026-08-01, bygget 2026-08-02) =====
   Two densities: Normal 352px and Narrow 208px. MEASURED at 1359px with both sidebars collapsed:
   Normal gives 3 columns per row, Narrow gives 6 — i.e. 12 steps visible at once when Wrapped,
   against 6. That is the entire purpose, and it is what replaced the request for a list view in
   Tasks.

   The rule lives here and not as a Tailwind class, because the width must only apply from 768px: on
   a phone you see one column at a time, and 208px there would only cramp the cards. `w-52` (13rem)
   exists in output.css, but `md:w-52` and `md:w-[13rem]` do NOT (checked) — and a missing width
   class fails silently: the column would keep its normal width, and the control would look broken
   without a single warning.

   The markup deliberately does NOT emit md:w-[22rem] in Narrow, so there is no specificity fight
   against output.css (which loads after app.css). Doubled selector anyway, like the rest of this
   file, so the rule cannot lose to something added later.

   ⚠ Density removes FIELDS, not legibility (§2.5). When this width bites, the card drops the
   person's name (only the face remains) and "New task in {step}" becomes "New task" — but the task
   name wraps and stays whole. If anyone changes the width here, the name must still be able to fit:
   Schedule once shipped 78px tiles against 135–206px names and produced 326 clipped text elements
   on one screen. */
@media (min-width: 768px) {
    .giki-col-narrow.giki-col-narrow {
        width: 13rem;
    }
}

/* ===== The card's structure at NARROW density =====
   Christian asked for "a narrow one with maybe less information AND A DIFFERENT STRUCTURE". That
   second half is not decoration — without it the narrow density is unusable:

   MEASURED in a 208px column: the card is 174px, 150 inside after padding. The status chip is
   `flex-shrink-0` and took 80px, the picker 16, the gaps 16 — leaving the title 36px. "Sweep dry
   goods store" broke across FOUR lines, one word fragment at a time. None of them was "clipped", so
   a pure truncation check passed it: 0 clipped titles. The screen said otherwise. This is the
   Schedule bug (78px tiles against 135–206px names) in a new disguise.

   The chip may not be removed — §3.1: "fixed reserved space, fixed order, fixed colours, whatever
   the surface and density". So it is the RHYTHM that changes: the title takes the full width on its
   own line, and chip + face + picker drop beneath it.

   In app.css and not as utilities: `basis-full` and `order-first` are not present in output.css
   (checked), and a missing class would have left the card exactly as before — without a warning.
   Only from 768px, the same boundary as the narrow width itself. */
@media (min-width: 768px) {
    .giki-card-narrow.giki-card-narrow {
        flex-wrap: wrap;
    }

    /* The text block first and at full width. A marker class rather than :nth-child, so the rule
       does not break the day somebody inserts an element into the row. */
    .giki-card-narrow.giki-card-narrow > .giki-card-main {
        flex: 0 0 100%;
        order: -1;
    }
}

/* The select checkbox moves up into the card's top right corner at NARROW density (Christian
   2026-08-02). The arithmetic behind it: on the bottom line the chip is 114px, the face ~22 and the
   checkbox 16 — with gaps, 162px of the card's 148. Those three could not share the line, so the
   face ended up alone on a 27px-high row of its own. Move the checkbox up and chip + face fit with
   room to spare, and the card gets a whole line shorter.

   Narrow only: in Normal the checkbox belongs in its place in the row, and an `absolute` class in
   the markup would have applied to both densities. The card already has `relative` (group
   relative), so the checkbox anchors to the card and not to the row.

   `top-2`/`right-2` as utilities are not used, because they would have to apply conditionally — and
   an md: variant of them does not exist in output.css. Here they are numbers instead. */
@media (min-width: 768px) {
    .giki-card-narrow.giki-card-narrow > .giki-card-select {
        position: absolute;
        top: 8px;
        right: 8px;
        margin-top: 0;
    }

    /* The title has to give way to the checkbox we just lifted into the corner (MEASURED
       2026-08-03). The checkbox became `absolute` above and therefore left the row — but the title
       was never told something now sits on top of it. MEASURED on "sweep goods receiving": the
       title ran 991 → 1139 (148px = the WHOLE card width), the absolute checkbox 1127 → 1143, i.e.
       **12px of overlap**, with `padding-right: 0px` and `margin-right: 0px` on the title. SEEN:
       the last letter sat underneath the checkbox as soon as the card was selected.

       Exactly the same root mistake as the name/face collision in the column header earlier the
       same day: an absolute element laid over text without reserving the space.

       16 and not 24: the measurement was 12px plus a little air. In a 148px column every pixel of
       title width is expensive — §2.5 says density removes FIELDS, not legibility, and too large an
       indent would break the name a line earlier for no reason at all. */
    .giki-card-narrow .giki-wrap-mobile {
        padding-right: 16px;
    }
}

/* ─────────────────────────────────────────────────────────────────────────────────────────────────────
   OFF-CANVAS PANELS — S/N 471. These do NOT use Tailwind's translate-x-* utilities, deliberately.

   Tailwind v4 compiles `translate-x-full` to the STANDALONE `translate` property:

       .translate-x-full { --tw-translate-x: 100%; translate: var(--tw-translate-x) var(--tw-translate-y); }

   `translate` shipped in CHROME 104. Android System WebView updates through the Play Store rather than
   with the OS, so a phone can easily be older — the Pixel 5 API 33 emulator carries 103. On such a
   WebView the declaration is dropped SILENTLY: the class is in the stylesheet, the custom properties
   resolve, and the computed value is simply the initial one. Nothing errors and nothing logs.

   The consequence was total rather than cosmetic. All four right-hand panels (notifications, how-it-works,
   activity log, announcements) plus both left navs stayed where they were laid out — full width, fixed,
   z-index 500, stacked over `main` — so the header was unreachable and the app had no navigation at all.
   Measured on the device: `aside x=0 y=56 w=393 h=795` four times over, and a probe element carrying
   `translate-x-full` sitting at x=0.

   `transform: translateX()` has been supported since forever and is what these use now. It is a plain
   stylesheet rule rather than a Tailwind utility because the point is to NOT depend on what the generator
   emits: if Tailwind changes its output again, the app's ability to reach its own navigation must not.

   NAVIGATION IS THE THING THAT MAY NEVER FAIL. Everywhere else in the app, using Tailwind's translate
   utilities is fine — a mis-positioned popover on an ancient WebView is a blemish. A shell with no way
   to open the menu is not usable at all, so these six elements buy their independence.
   ───────────────────────────────────────────────────────────────────────────────────────────────────── */

/* Parked off-screen. The panel still occupies its place in the layout; only its painting moves. */
.giki-offcanvas-right { transform: translateX(100%); }
.giki-offcanvas-left  { transform: translateX(-100%); }

/* Slid into view. An explicit none rather than an absent class, so the transition has both ends. */
.giki-offcanvas-in    { transform: translateX(0); }

/* At lg the panels are docked, not overlaid, so the offset must come off — this replaces the
   `lg:translate-x-0` half of the old class pair. Same 1024px breakpoint Tailwind uses for lg. */
@media (min-width: 1024px) {
    .giki-offcanvas-right,
    .giki-offcanvas-left { transform: none; }
}

/* ===== The pearl row collapses to a single button when the space is not there =====
   Christian 2026-08-11: "when there is room for the whole row they should be shown, but when there
   is not, show this one instead, and there has to be an indication of whether everything is done or
   whether something is still unanswered."

   THE THRESHOLD IS MEASURED IN THE BROWSER, NOT CALCULATED. scrollWidth of the expanded row
   (overview button + separator + seven pearls) = 264px. 280 leaves 16px of slack for the two badges
   that stick out 2px on each side, and for the collapse cross where it is present. Arithmetic gave
   278 and was close, but what stands here is the MEASUREMENT — the two are not the same thing, and
   only one of them can be checked.

   WHY NOT MEASURE IN JS: a ResizeObserver on the strip itself closes a loop — hide the pearls and
   the cell gets narrower, so there "is room" again and it expands. Back and forth forever. The
   container query measures the SHELL, which is block/width:100% and therefore takes its width from
   the column. The price is that the column must have a width that CSS decides: an auto table that
   measures its own content closes the same loop. So the surface gives the pearl column a width.

   WHY HERE AND NOT AS A UTILITY: @container is not present in the shipped output.css, and output.css
   is not built automatically. The same trap as ToastHost. app.css always ships. */
.giki-pearl-shell {
    container-type: inline-size;
    display: block;
    width: 100%;
    min-width: 0;
}

@container (max-width: 280px) {
    .giki-pearl-shell .giki-pearl-pearls {
        display: none;
    }
}

/* ⚠ THE LINE-WRAPPING RULE STOOD HERE FOR THE THIRD TIME, AND THIS ONE WON.
   `flex-wrap: wrap; max-width: 208px` was written here when the pearls moved a level deeper — but
   the old rule on the outer row was never removed, and later a THIRD one was written further up
   that lets the pearls share the width.

   MEASURED: the container was still at 208px with `wrap`, and the pearls came out at 24px. Not
   because the new rule was wrong, but because THIS one sits LATER in the file and wins on source
   order at equal specificity (.giki-pearl-row.giki-pearl-row .giki-pearl-pearls in both).

   Two identical selectors in one file are not a duplicate you can leave alone: the lower one is the
   one that applies, and the upper one LOOKS like it works when you read it. Removed — the rule now
   lives in one place, next to the reason the pearls share the width. */


/* ===== §86 — "requested but not granted" must not look like "granted" =====
   Christian's question was exactly that: "when you request leave, you also have to be able to see
   whether you got it." Two bars in the same colour cannot answer it.

   STRIPES RATHER THAN A DIFFERENT COLOUR: colour is already taken — it says which TYPE of absence
   this is (holiday, sickness, working from home). A pale variant of each of the five would give ten
   colours to tell apart, and the two blues would look alike on a projector. A pattern is a different
   axis from colour, so the two questions do not collide — and it also works for someone who cannot
   distinguish blue from green.

   HERE AND NOT AS A UTILITY: repeating-linear-gradient cannot be written as a Tailwind class, and
   output.css is not built automatically — see the note further up this file. */
.giki-absence-requested {
    background-image: repeating-linear-gradient(
        45deg,
        rgba(255, 255, 255, 0.45) 0,
        rgba(255, 255, 255, 0.45) 3px,
        transparent 3px,
        transparent 7px
    );
}

/* ===== The task/chat split applies ONLY where there is room for two columns =====
   Christian 2026-08-16, with an iPhone view open: "something is wrong here."

   MEASURED AT 390px: the task panel sat at `flex: 0 0 53.6172%` → 206px, while the chat panel was
   hidden by `max-sm:hidden`. So 179px of the phone was empty beside a panel squeezed to half width.
   And the 53.6% is not even a design choice — it is the width the user last dragged to on a DESKTOP,
   remembered in --giki-split.

   WHY THE BUG COULD HAPPEN. Two mechanisms decided the same question without knowing about each
   other: `max-sm:hidden` is CSS and knows the screen width; `BothColumns` is a C# expression and does
   not. When the CSS hid one column, the C# expression went on saying "there are two", and the
   remaining panel kept its half width. Neither of them was wrong on its own.

   SO THE WIDTH MOVES IN HERE. An inline style cannot carry a media query, so the split could not be
   made breakpoint-aware where it used to live. Below 640px the rule does not apply and the panel
   falls back to its own `flex-1` and fills the screen — which is the entire point of showing one
   panel at a time.

   640 and not 768: that is `sm`, i.e. EXACTLY the boundary `max-sm:hidden` uses. Two numbers would
   mean a band where one rule had taken effect and the other had not — and that band would look like
   a random bug at particular screen widths.

   Here and not as a utility: an arbitrary Tailwind value using var() does not exist in the built
   output.css, and output.css is not built automatically — the same trap as ToastHost. app.css
   always ships. */
@media (min-width: 640px) {
    .giki-split-pane {
        flex: 0 0 var(--giki-split, 50%);
    }
}

/* ===== The browser must not steal the horizontal swipe =====
   Christian 2026-08-16, after the swipe finally worked with a real finger: "if the movement gets too
   long, the whole browser changes page."

   That is the browser's overscroll navigation: a horizontal movement running past the content's edge
   is chained onward to back/forward. `contain` stops exactly that chaining — the movement stays
   inside the page instead of turning into a history action.

   ⚠ AND IT DOES NOT SOLVE IT COMPLETELY, which is why the buttons are built alongside it. An EDGE
   swipe is a system gesture in several browsers, one a page cannot block; it lives outside the
   document. So the gesture must never be the only way to reach what it does.

   Only -x: vertical overscroll is "pull to refresh" and has to keep working.

   MEASURED BEFOREHAND: overscroll-behavior was set in only two places in the whole file — on the
   scroll lock and on the bottom sheet. The shell itself had nothing, so any horizontal movement on
   any page could end as a navigation. */
html,
body {
    overscroll-behavior-x: contain;
}

/* Thumb target. 44px is the width an adult thumb covers; below that the neighbouring button gets hit
   just as often as the one you aimed at.

   ⚠ IN HERE AND NOT AS `min-h-[44px]` IN MARKUP. MEASURED 2026-08-16: the arbitrary Tailwind value is
   NOT present in the built output.css, and the targets came out at 37px. The class looked right in
   the source and did nothing — and on a phone you only discover that when you miss. */
.giki-tap {
    min-height: 44px;
}

/* ===== The atom form can only be moved where there is room to move it =====
   Christian 2026-08-16, with a screenshot of a modal dragged half off the edge on a phone: "must not
   be movable on mobile."

   ⚠ HIDING THE DRAG HANDLE WAS NOT ENOUGH. The offset lived in an inline `transform`, and an inline
   style cannot carry a media query — so a modal dragged on a desktop STAYED skewed when the window
   got narrow. The handle was gone, and there was no visible way back.

   The numbers now arrive as --modal-dx / --modal-dy and the translate happens HERE, where it can be
   switched off. Below 640px the box sits centred, whatever anyone dragged it to on a bigger screen.

   Exactly the same bug and the same fix as .giki-split-pane got earlier the same day: two mechanisms
   decided the same question, and only one of them could see the screen width. */
@media (min-width: 640px) {
    .giki-modal-box {
        transform: translate(var(--modal-dx, 0px), var(--modal-dy, 0px));
    }
}

/* ===== The "Collapse all" button belongs at the bottom, even when the list is short =====
   Christian 2026-08-16, with an arrow drawn from the button down towards the bottom of the screen.

   ⚠ MEASURED: the button was `sticky`, and sticky follows its own SCROLL CONTAINER. If the list is
   short — two follow-ups on Me Today — there is nothing to scroll, so the button lands right below
   the content, i.e. at the TOP of the screen. It was meant for the bottom and only hit it when the
   list happened to be long enough. Exactly the same trap as the list handle an hour earlier: sticky
   looks like fixed right up until the content gets short.

   Below sm it is therefore fixed. Above sm, sticky is where it belongs: on a desktop the button
   follows its list, and there is enough room that it does not get in the way.

   4.5rem and not 1rem: the bottom bar (Me Today / Chats) and the Footer sit down there in the
   layout's flow, and the button has to sit ABOVE them, not behind them. The number is measured, not
   chosen: the bar is 57px and the footer ~26px. Safe-area is added on top, or the button ends up
   under the iPhone's home indicator. */
@media (max-width: 639px) {
    .giki-fab {
        position: fixed;
        right: 0.5rem;
        left: auto;
        bottom: calc(4.5rem + env(safe-area-inset-bottom)) !important;
    }
}

/* ===== Floating elements must not land on top of the bottom bar =====
   The bottom bar (Me Today / Chats) sits in the layout's flow just above the Footer and together
   they take ~83px. Two floating things sat lower than that:

     · the "Collapse all" button (.giki-fab) at 4.5rem = 72px
     · the "Back to search" chip at bottom-14 = 56px

   ⚠ MEASURED, AND I DID NOT SEE IT THE FIRST TIME: the FAB's lower edge was at 884 while the bar ran
   from 859 to 916. The number was in my own measurement, and I read it as "fixed, so it is at the
   bottom" without holding it up against the bar. A number is not an answer until you have asked it
   a question.

   7.5rem = 120px: the bar (57) + Footer (26) + slack. Set in ONE place, so a change in the bar's
   height does not have to be chased through two files.

   Only when the bar is actually there — the class is set by MainLayout on the two routes it shows
   on. On every other page these keep their lower position, where there is nothing to clear. */
@media (max-width: 639px) {
    .giki-has-bottom-bar .giki-fab {
        bottom: calc(7.5rem + env(safe-area-inset-bottom)) !important;
    }

    .giki-has-bottom-bar .giki-back-to-search {
        bottom: calc(7.5rem + env(safe-area-inset-bottom));
    }
}

/* ===== Checkboxes and radio buttons are too small for a finger =====
   READ IN THE CODE 2026-08-16, not counted on screen: 34 checkboxes across the .razor files, and only
   6 of them have a size at all (h-3, h-3.5, h-4, h-5). The other 28 sit on the browser default, which
   is ~13px in Chrome. The largest in the house is 20px.

   24px is not a matter of taste — it is the floor in WCAG 2.5.8 (Target Size, Minimum, level AA).
   Anything above is allowed; anything below is a defect, however tidy it looks.

   ⚠ WHY AN ATTRIBUTE SELECTOR AND NOT A CLASS: a class would have to be set in 34 places and
   forgotten in the 35th. `input[type="checkbox"]` weighs 0-1-1 and beats Tailwind's `.h-4` at 0-1-0,
   so the rule also wins where a size HAS been set — without !important and without touching a single
   .razor file. Same reasoning as the Ctrl constants themselves: one rule, one place.

   ⚠ ONLY BELOW 640px. With a mouse 13px is plenty, and 24px would blow apart the dense panels (the
   OrgChart form, the LMS enrolment) on a screen where the problem does not exist.

   28px and not the 28px control row: Ctrl.Base's 28px is a MEASURED and documented decision in
   UiKit.cs and clears the 24. It is not touched here. This is about fields that never got a size at
   all.

   ⚠ ALL THREE DENSE ROWS WERE CHECKED, and it was not redundant — the concern was real: a control
   growing from 13 to 24px can push its row taller. MEASURED 2026-08-16 with test data created for
   the purpose in TEST 2 Food:

     · The chat list, 440px, 14 rows: control 24x24, row unchanged at 72px. The height comes from
       the avatar, not from the control, so there was room to grow into.
     · The OrgChart role, 330px, 3 controls: 24x24, the whole label is the target at 256x24.
     · The LMS enrolment, 440px, 18 rows — the densest in the house, 11px text and a 20px avatar.

   The cost is MEASURED and not calculated: the rule was switched off and on again in the open
   enrolment. Without it, a 13px control in a 33px row; with it, a 24px control in a 37px row. THE
   ROW GREW 4 PX — once, in the densest panel there is. That is the entire cost.

   No overflow anywhere, and the longest name still truncates cleanly. */
@media (max-width: 639px) {
    input[type="checkbox"],
    input[type="radio"] {
        width: 1.5rem;
        height: 1.5rem;
        flex-shrink: 0;
    }
}
