/* 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;
    /* S/N 1266 - the strip is light yellow in BOTH themes, so its text must not follow the theme: without this line it
       inherited the page's text colour, white in dark mode - a yellow bar nobody could read (Christian's iPhone,
       2026-10-22). The same dark grey as light mode's text, named here instead of inherited. */
    color: var(--palette-gray-900);
    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); }
}

/* flowHandlePeek: the toolbar collapse handle breathes a soft purple ring when a flow is picked and
   the toolbar is still expanded — "you can tuck these rows away now for more board". Same calm
   "look here" idiom as flowPickPulse / deptPeekPulse (a breath, never an alarm); a RING rather than a
   colour swap, because the handle's capsule is already solid purple, so a colour swap would be
   invisible on it. Christian 2026-09-06: pulse it "like it does on the sidebar for departments".
   Stops the moment the handle is used (Tasks passes Pulse=false once collapsed). */
@keyframes flowHandlePeek {
    0%, 100% { box-shadow: 0 0 0 0 rgba(145, 7, 250, 0); }
    50%      { box-shadow: 0 0 0 4px rgba(145, 7, 250, 0.30); }
}

/* 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); }

/* THE PEARL ROW AT THE SENTENCE (Christian 2026-10-25): any of the seven pearls can hang on a sentence now, so the
   four kinds the menu never offered get a colour too - a one-off task (the same blue as the repeating one), a contact,
   a piece of equipment and a template. Models/MarkingTrace.cs MarkingKinds.Colour is the same table in C#. */
.giki-ref-task {
    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 { color: rgb(96, 165, 250); }

.giki-ref-contact {
    background-color: rgba(20, 184, 166, 0.14);
    color: rgb(15, 118, 110);
    border-bottom: 1.5px solid rgb(20, 184, 166);
}
.dark .giki-ref-contact { color: rgb(45, 212, 191); }

.giki-ref-asset {
    background-color: rgba(249, 115, 22, 0.14);
    color: rgb(194, 65, 12);
    border-bottom: 1.5px solid rgb(249, 115, 22);
}
.dark .giki-ref-asset { color: rgb(251, 146, 60); }

.giki-ref-template {
    background-color: rgba(99, 102, 241, 0.14);
    color: rgb(67, 56, 202);
    border-bottom: 1.5px solid rgb(99, 102, 241);
}
.dark .giki-ref-template { color: rgb(129, 140, 248); }

/* THE WAY BACK TO THE SENTENCE (Christian 2026-10-25): arriving from the item, the marking it came from pulses twice so
   the eye lands on it. Class set by helpers.js _focusRefIn for 2.6 s. */
.giki-ref-focus {
    animation: giki-ref-pulse 1.3s ease-out 2;
}
@keyframes giki-ref-pulse {
    0%   { box-shadow: 0 0 0 0 var(--color-primary); }
    60%  { box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-primary) 35%, transparent); }
    100% { box-shadow: 0 0 0 0 transparent; }
}

/* The pearl in the row at a sentence that already hangs on THAT sentence: a ring in the primary colour. A rule of its
   own rather than Tailwind's ring-* utilities, which are not in the shipped output.css. */
.giki-pearl-here {
    box-shadow: 0 0 0 2px var(--color-bg-card), 0 0 0 4px var(--color-primary);
}

/* THE RED RING (Christian 2026-10-28): the pearl's fill is its decision; a red ring says something hanging on it is not
   in order (the server's S/N 1332 roll-up). At a sentence both rings show: red inside, the sentence's colour outside. */
.giki-pearl-trouble {
    box-shadow: 0 0 0 2px var(--color-bg-card), 0 0 0 4px var(--color-error);
}
.giki-pearl-here.giki-pearl-trouble {
    box-shadow: 0 0 0 2px var(--color-bg-card), 0 0 0 4px var(--color-error), 0 0 0 6px var(--color-primary);
}

/* §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. */
/* THE MARK IN FRONT (Christian 2026-10-24): a bar in the kind's colour before the sentence, and a count when more than one
   thing hangs there. The icon drawn by ::before below is kept for the request badge only. */
sup.giki-badge {
    display: inline-block;
    width: 4px;
    height: 0.95em;
    margin: 0 3px 0 0;
    border-radius: 2px;
    vertical-align: -0.1em;
    top: 0;
    font-size: inherit;
    background-color: currentColor;
    cursor: pointer;
}
sup.giki-badge-compliance     { background-color: rgb(239, 68, 68); }
sup.giki-badge-task-recurring { background-color: rgb(59, 130, 246); }
sup.giki-badge-lms            { background-color: rgb(168, 85, 247); }
sup.giki-badge-skill          { background-color: rgb(16, 185, 129); }
sup.giki-badge-document       { background-color: rgb(245, 158, 11); }
sup.giki-badge-link           { background-color: rgb(100, 116, 139); }
sup.giki-badge-task           { background-color: rgb(59, 130, 246); }
sup.giki-badge-contact        { background-color: rgb(20, 184, 166); }
sup.giki-badge-asset          { background-color: rgb(249, 115, 22); }
sup.giki-badge-template       { background-color: rgb(99, 102, 241); }
sup.giki-badge-request        { width: auto; height: auto; background: none; }
sup.giki-count {
    width: auto;
    height: auto;
    background: none;
    font-size: 0.7em;
    font-weight: 700;
    color: var(--color-text-muted);
    vertical-align: super;
    margin-right: 3px;
}
sup.giki-badge:not(.giki-badge-request)::before { content: none !important; mask-image: none !important; -webkit-mask-image: none !important; }
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.

   H30 (2026-09-06, Christian: "make it clearer — but keep it ON THE ICON; marking the same text string
   twice with a mouse is near impossible"). The badge is the ONLY sane way to lay a second type on a
   passage, so it has to READ as a button at rest and SAY "add" on hover — not sit as a faint
   superscript nobody clicks (which is exactly what "and yet it doesn't work now" turned out to be:
   the feature was there, the door was invisible). At rest it is a small type-coloured chip (fill +
   hairline ring, full opacity, pointer). Hovering the marking brightens every badge and reveals ONE
   "+" chip after the last badge — the universal "add here". currentColor keeps every part in the
   type's own colour, so a red compliance mark and a blue task mark stay told apart. The tooltip
   (helpers.js _renderSpan) spells the action out in words. */
sup.giki-badge {
    cursor: pointer;
    opacity: 1;
    border-radius: 4px;
    padding: 0 2px;
    background-color: color-mix(in srgb, currentColor 10%, transparent);
    box-shadow: 0 0 0 1px color-mix(in srgb, currentColor 22%, transparent) inset;
    transition: background-color 120ms ease, box-shadow 120ms ease;
}
span.giki-ref:hover sup.giki-badge,
sup.giki-badge:hover {
    background-color: color-mix(in srgb, currentColor 22%, transparent);
    box-shadow: 0 0 0 1px currentColor inset;
}
/* The "+" that says you can add another type. One per marking (the last badge only), shown while the
   marking is hovered. Its own fill and ring, so it never reads as part of a count number on the badge
   in front of it. */
span.giki-ref:hover sup.giki-badge:last-of-type::after,
sup.giki-badge:hover:last-of-type::after {
    content: "+";
    margin-left: 3px;
    padding: 0 3px;
    border-radius: 3px;
    font-weight: 700;
    background-color: color-mix(in srgb, currentColor 20%, transparent);
    box-shadow: 0 0 0 1px color-mix(in srgb, currentColor 36%, transparent) inset;
}

/* ===== The gesture contract (agent 6, 2026-08-01) =====
   See x-Docs/foundation-2026-07-30/gesture-contract-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. */
    /* ⚠ THE CAP IS WHAT KEEPS THEM ROUND — added 2026-09-17, MEASURED in Christian's Chrome.
       His laptop has a touch screen, so `hover: none` matches at ANY window width, and at 1500px
       `flex: 1 1 0` handed each pearl a seventh of a wide panel: the button took the whole share,
       `max-height: 44px` held the height, and `aspect-ratio: 1` could not win — 62x44 OVALS, seven
       of them ("et eller andet er galt med vores perlerække ... kæmpe store knapper. De var små og
       runde før"). Edge and a laptop without touch report `hover: hover`, so nobody else saw it.
       The share still does the work where it was meant to (a 390px phone gives ~40px each); the cap
       only stops the growth once there is room for the full 44. */
    .giki-pearl-row.giki-pearl-row .giki-pearl-pearls > div {
        flex: 0 1 44px;
        max-width: 44px;
        min-width: 0;
    }

    .giki-pearl-row.giki-pearl-row .giki-pearl {
        width: 100%;
        max-width: 44px;
        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;
    }
}

/* ===== The checklist row's touch target (user test finding 27, 2026-10-15) =====
   MEASURED on a 375px phone: the tick box was 18x18 and the step row 33px high, and only the box
   took the tap. The tap now lands on the whole row (AtomForm/AtomFormModalStepsBlock.razor); here
   the row gets thumb height where there is no mouse - the same gate as the pearls above, for the
   same reason: a touch laptop has the same finger. min-h-[44px] is not in the shipped output.css
   and @media (hover: none) cannot be a utility; app.css always ships. On a mouse the row keeps
   its 33px. No Tailwind utility on the row sets min-height, so a single class is enough. */
@media (hover: none) {
    .giki-check-row {
        min-height: 44px;
    }
}

/* ===== 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;
    }

    /* S/N 1283 - A CLAMPED NAME KEEPS ITS CLIP. line-clamp draws its ellipsis at the last line it allows and hides the
       lines after it only while overflow is hidden; the rule above (written for one-line names) set overflow: visible, so
       on the Tasks card's two-line title the hidden lines were painted over the card's tick, chat, bell, poke, date and
       person (Rahul 2026-10-23, an iPhone; the same on Android and in Chrome at phone width). A clamped name now shows its
       two lines and the ellipsis on a phone, as on the computer; the whole name is in the form. */
    .giki-wrap-mobile.giki-wrap-mobile[class*="line-clamp-"] {
        overflow: hidden;
    }
}

/* ===== 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;
}

/* A side panel (Assignment, Schedule ...) is a layer over the task column, so it can only be as tall as the form under it.
   A short form - a new chat is a name and nothing else - left the Assignment panel ~300 px, and the people to pick lay
   cut off under its search box (Christian 2026-10-31: "you cannot add members to the group"). While a panel shows, the
   box takes a working height of its own. A phone is unaffected: there the box is the screen. */
.giki-modal-box.giki-modal-panel {
    min-height: min(44rem, 92vh);
}

/* chr §112.1 — the window fills the screen, for reading and marking a long document.
   HEIGHT ONLY: the width arrives through --modal-w (98vw) like every other width on this box, so the
   two halves of "maximised" stay in the one place each belongs. 96vh and not 100vh leaves the modal
   visibly a window - a dialog with no edge at all reads as a page, and Escape stops being obvious.
   The phone rule below still wins: it comes later in the file and sets height on the same selector. */
.giki-modal-box.giki-modal-max {
    height: 96vh;
    max-height: 96vh;
}

/* 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.
   A SMALL screen since 2026-09-29 - narrower than 640 px, or at most 500 px tall (.giki-small-only below): a phone held
   sideways got a 614 x 345 box with the task and the chat side by side, and the footer took 174 px of it, so of the
   task only its title showed (measured at 667 x 375). Christian, from a before/after picture: "yes, build it that way". */
@media (max-width: 639.98px), (max-height: 500px) {
    .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.

   2026-09-28, Christian in the Windows app (402 pt, the TestFlight code): the chat opened FROM A TASK
   stood on one line and "Type a message" broke over three lines, while the same chat opened in Chat had
   the field on its own line - "this model must be everywhere." The panel in the task was just over the
   26rem the rule below used to wait for, and the field has since gained Aa and LANG inside it, so one
   line no longer leaves room to write at any width that matters. So the field ALWAYS takes the whole
   line and the icon buttons sit under it; only the tighter padding still waits for a narrow panel. */
.giki-chat-bar {
    container-type: inline-size;
}

/* 2026-09-29 - THE TWO-LINE RULES ARE GONE AGAIN. Christian compared the chat with WhatsApp on his own screen and asked
   for WhatsApp's composer: ONE line, [+] [emoji | text] [mic/send], the tools behind the + (Chat/ChatInputBar.razor).
   The line no longer has four icons to crowd the field, so it needs no wrapping; only the narrow panel's padding
   below still applies. */

@container (max-width: 26rem) {
    .giki-chat-composer.giki-chat-composer {
        padding-left: 0.5rem;
        padding-right: 0.5rem;
        /* S/N 1237 - NO home-indicator inset here on a small screen: the conversation bar under the composer
           (Shared/MobileConversationBar.razor, .giki-small-only) carries it since 2026-10-15, and this second copy left an
           empty band under the chat box on every phone. */
        padding-bottom: 0.75rem;
    }
}

/* On a wide screen there is no bar under the composer, so a narrow composer keeps the guard BottomSheet's footer uses.
   The same breakpoint as .giki-small-only. */
@media (width > 639.98px) and (height > 500px) {
    @container (max-width: 26rem) {
        .giki-chat-composer.giki-chat-composer {
            padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
        }
    }
}

/* 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 card's structure at NORMAL density - variant A (finding 36, Christian 2026-09-19) =====
   The text block first and at the card's full width; the clip, the date, the person and the select box share the
   line under it. The same device as .giki-card-narrow above, at every width (a phone's card is Normal and gains
   the most), and the doubled selector for the same reason: output.css loads after this file, and Tailwind's
   `flex-1` on the text block would otherwise win. */
.giki-card-stack.giki-card-stack > .giki-card-main {
    flex: 0 0 100%;
    order: -1;
}

/* Finding 48 (Christian 2026-09-20): the person takes a LINE OF ITS OWN, always, at the left edge.
   Before this it sat at the right end of the clip/date line with `ml-auto`, so the card's shape depended on
   the length of somebody's name: a short one shared the line, a long one wrapped underneath - "sometimes on
   the same line and other times pushed underneath ... it would look a lot calmer if it were always on the
   second row and always left-aligned". Full width forces the wrap every time; the margin and alignment resets
   stand so the rule keeps holding if a utility is ever put back on that element. The doubled selector is for
   the same reason as its neighbour above: output.css loads after this file. */
.giki-card-stack.giki-card-stack > .giki-card-person {
    flex: 0 0 100%;
    margin-left: 0;
    justify-content: flex-start;
}

/* 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;
    }
}

/* ...and the same corner for a NORMAL card on a phone (Christian 2026-09-29, from a before/after picture at iPhone SE
   size: "yes, build it that way"). Without a mouse the select box is always shown (.giki-hover-reveal, gesture contract
   §1), and on the stacked card - the text at full width, then the person on a line of its own (finding 48) - it was
   the next element and so landed ALONE on a third line: an unlabelled square under the name, on every card. Nobody
   decided that line; it fell out of the two decisions. In the corner the card is 36 px shorter (MEASURED 181 -> 145 px
   with a lead chip, 152 -> 116 without), and the box still selects. The title gets the Narrow rule's 16 px for the
   same reason as there. At EVERY width since the same evening (Christian: "do the same on the computer"): with a mouse
   the box only shows on hover, but in its row it still took a line of its own at the bottom of every card. */
.giki-card-stack.giki-card-stack > .giki-card-select {
    position: absolute;
    top: 8px;
    right: 8px;
    margin-top: 0;
}

.giki-card-stack .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.
   Since 2026-09-29 the boundary is the SMALL screen (narrow, or at most 500 px tall), the same as the one-panel rule
   (giki-hide-small on the panels) - otherwise a phone held sideways would show its one panel at half width. */
@media (width > 639.98px) and (height > 500px) {
    .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. Since 2026-09-29 the small screen switches
   it off (narrow, or at most 500 px tall), as the small screen is what makes the box full-screen.

   ⚠ LEFT/TOP, NOT A TRANSFORM (2026-10-29, Christian as the compliance manager: "the overlay ... lay outside the screen").
   Any transform - translate(0,0) included, the box always had one - makes the box the containing block of EVERY
   position:fixed element inside it, and its overflow:hidden then cuts them at its edges. So the dialogs opened from inside
   an item ("Whom to ask?", the pearl dialog, the list picker, the person file) were centred on the FORM and cut at its
   frame: measured in Christian's Chrome, "Whom to ask?" ran 41 px past the form's bottom edge at 1920 x 855 and 42 px at
   1500 x 775, its buttons out of reach. A relative offset moves the box exactly as far and creates no containing block,
   so a fixed overlay inside it is measured from the window again. (The box is `relative` in its markup.) */
@media (width > 639.98px) and (height > 500px) {
    .giki-modal-box {
        left: var(--modal-dx, 0px);
        top: var(--modal-dy, 0px);
    }
}

/* ===== A box you can make bigger and move (Christian 2026-10-29, on the flow/board access box: "on a big screen one
   should be able to drag it so more people are seen at once, and move it like every other box") =====
   .giki-resizable-box carries the browser's own corner grip (resize: both) - no script of ours sizes it; helpers.js
   boxResize only tells Blazor the size so a re-render keeps it. It is moved by the task form's grip bar (helpers.js
   modalDrag), the offset as left/top for the reason written at .giki-modal-box above. Both are off on a small screen,
   where the box is the screen - the same cut as the form's handle (giki-hide-small). */
@media (width > 639.98px) and (height > 500px) {
    .giki-resizable-box {
        resize: both;
        overflow: hidden;
        min-width: 20rem;
        min-height: 16rem;
        max-width: 96vw;
    }
}

/* ===== 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;
    }
}

/* THE SAME CORNER ON A PC (Christian 2026-10-31: "I would like it to be the same button on the PC ... placed the same place
   down in that corner, for consistency"). Sticky put it under the last card on a short list - halfway up the screen - so
   here it is absolute to <main> (MainLayout makes it relative, and nothing between them is positioned): bottom right of the
   CONTENT, above the footer line, never where the content happens to end - and not fixed to the window, which put it on top
   of an open notification panel. The lists keep room at the bottom (FoldAllFab leaves a spacer in the flow) so it never
   covers the last row. */
@media (min-width: 640px) {
    .giki-fab {
        position: absolute;
        right: 1.5rem;
        left: auto;
        margin-top: 0;
        bottom: 3.25rem !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;
    }
}

/* ── S/N 355 (chr §47) — THE PRINTABLE ASSET LABEL ─────────────────────────────────────────────────
   The situation on the floor: somebody registers a machine on their phone standing next to it, and
   needs the QR sticker ON it before they walk away. If that means a detour via a PC the label goes on
   tomorrow, or never - and an asset with no label has no identity in the system, which is the whole
   point of registering it.

   WHY THIS IS CSS AND NOT AN ENDPOINT. The row proposed GET api/assets/{id}/label returning a
   print-ready PDF, and that design predates two things. The QR is already generated ENTIRELY IN THE
   BROWSER (helpers.js gikiQr -> a vendored MIT lib -> a data: URL, no network), and MAUI became a
   Blazor Hybrid head on 2026-08-11, so the phone renders this same library in a WebView and inherits
   whatever the web does. A label is that QR plus a name at a known physical size - so it is a
   stylesheet, not a server, and there is no PDF library and no new API surface.

   MILLIMETRES, NOT PIXELS, and that is the one thing that must not drift: a label has a PHYSICAL size
   because it has to fit the stock in the printer. 62x29mm is the common Brother DK-11209 address
   label and is a DEFAULT rather than a decision - if a customer names their roll, this is a two-line
   change made once.

   WHAT THIS DELIBERATELY DOES NOT DO: talk to a Zebra or Brother in its own language (ZPL/EPL). It
   prints through the OS dialog, which is one of the two answers chr §47 asks us to choose between and
   the one the Hybrid head made cheap. Whether a given warehouse printer is reachable that way depends
   on it having a driver, AirPrint or Mopria - which is the half of his question this does NOT answer.
   Printer-native output stays its own row, to be built against a real printer rather than a guess. */
@media print {
    /* Hide the application, not by unmounting it - visibility keeps layout intact so nothing reflows
       and the label's own absolute position stays predictable. */
    body * {
        visibility: hidden;
    }

    .giki-print-label,
    .giki-print-label * {
        visibility: visible;
    }

    .giki-print-label {
        position: absolute;
        left: 0;
        top: 0;
        display: flex !important;
        align-items: center;
        gap: 2mm;
        box-sizing: border-box;
        width: 62mm;
        height: 29mm;
        padding: 2mm;
        background: #fff;
        color: #000;
    }

    /* The QR must stay SQUARE and stay crisp. image-rendering:pixelated matters on paper more than on
       screen: a QR is a bitmap of hard edges and smoothing it is what makes a scanner miss it. */
    .giki-print-label img {
        width: 25mm;
        height: 25mm;
        flex-shrink: 0;
        image-rendering: pixelated;
    }

    .giki-print-label-text {
        min-width: 0;
        overflow: hidden;
    }

    /* Two lines maximum, then clip. A label that overflows its stock prints half a word and looks
       broken; a name that is too long is better truncated than allowed to push the QR off the edge. */
    .giki-print-label-name {
        font-size: 3.5mm;
        font-weight: 700;
        line-height: 1.2;
        display: -webkit-box;
        -webkit-line-clamp: 2;
        -webkit-box-orient: vertical;
        overflow: hidden;
    }

    .giki-print-label-dept {
        margin-top: 1mm;
        font-size: 2.6mm;
        line-height: 1.2;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
    }

    @page {
        size: 62mm 29mm;
        margin: 0;
    }
}

/* Off-paper the label is not rendered at all. It exists in the DOM only so the print stylesheet has
   something to reveal - drawing it on screen would put a second, differently-sized QR beside the one
   the popover already shows. */
.giki-print-label {
    display: none;
}

/* ── LIST COLUMNS THAT FOLD ON THE CONTAINER, NOT THE VIEWPORT ──────────────────────────────────
   Added 2026-08-27. ModuleAtomList's own note records the limit this fixes: its column breakpoints
   were `md:` / `lg:`, which key off the VIEWPORT, while the width that decides whether a column
   fits is the CONTENT COLUMN's. With the Announcements panel open the content column is ~460 px on
   a 1542 px screen, so every `md:` column was still shown and the table was crushed.

   WHY THIS IS PLAIN CSS AND NOT A TAILWIND UTILITY, and it is not a preference:
   in a .razor file `@` opens a Razor expression, so `@container` must be written `@@container` —
   which is what Tailwind's scanner reads, and it emits nothing for it. The rule simply would not
   exist in output.css. Written here it is compiled by no one and always present.

   THE TWO WIDTHS ARE THE COLUMNS' OWN, not guesses: Relationship is pinned to 110 px by the
   colgroup and the name column needs ~460 to be readable beside it, hence 576. The three metadata
   columns (Triage, Created by, Created) add ~330, hence 768. */
.giki-listtable { container-type: inline-size; }

/* ⚠ NOTHING HERE USES `display: none`, AND THAT COST TWO MEASURED ROUNDS TO GET RIGHT (2026-08-28,
   on /survey at a container width of 763 px). Both obvious spellings are wrong, each in its own way:

     · `display: none` on the <col> — Chrome drops the column for cell mapping, so the CELLS land
       correctly, but its fixed-layout pass still counts it as an AUTO-width column and splits the
       free space with the real flexible column. The name column measured 243 px where it should
       have measured 485 — half the table paid to a column that renders nothing. ModuleAtomList has
       THREE such columns, so it was losing more than this table was.

     · `display: none` on the CELLS — the row then has fewer rendered cells than the colgroup has
       columns, and Chrome maps them in order: the last cell (Preview / Add) landed in the collapsed
       column and measured 0 px wide, with its buttons drawn outside the table.

   WHAT WORKS is to keep all four cells and all four columns, and to collapse the hidden one to a
   real zero: width 0 on the <col>, and visibility + padding + border zeroed on its cells. Widths
   then add up to the table's own — measured 485 + 110 + 0 + 168 = 763.

   The `!important` beats the inline `style="width: 110px"` in the markup, and it lives ONLY inside
   `@container not (…)` — the exact inverse of the shown state — so a visible column still takes the
   width written beside it in the .razor file, which is where a reader looks for it. */
th.giki-col-rel,
td.giki-col-rel,
th.giki-col-meta,
td.giki-col-meta { display: table-cell; }

/* THE PEARL COLUMN ON A PHONE (Christian 2026-09-11, the asset list): the column is written 260 px wide for the
   open strip, but below 576 px of container width the cell shows only the folded pearl button, and 260 px left the
   NAME column 30 px - one letter. MEASURED at a 382 px viewport: 48 + 30 + 0 + 260. Narrowed to what the folded
   button needs; the strip still opens on top of the row when pressed. Same `!important`-inside-the-inverse-query
   rule as giki-col-rel above, for the same reason. */
@container not (min-width: 576px) {
    col.giki-col-pearls { width: 64px !important; }
}

/* THE HUB'S PEARL COLUMN ON A PC (user test finding 56, 2026-10-15). Hub/Index.razor writes the pearl
   column as 28 %, measured on a ~1321 px table - a Hub without the docked department sidebar. With the
   sidebar (264 px) a 1440 px screen gives a ~1054 px table, 28 % = 295 px, minus the cell's 32 px
   padding = 263 px: under the 280 px at which .giki-pearl-shell folds the strip (further up this file),
   so every row showed only the overview button while the header drew all eight icons. Same remedy as
   the Assets list's col.giki-col-pearls above: hold the column at a width that fits the open strip
   (320 - 32 = 288 px shell, the same 288 the Assets list gives itself). Only in the band where 28 % is
   too little: from 960 px of table width (a 1366 px laptop with the sidebar docked is ~988) the name
   column still keeps >= 140 px; from 1143 px, 28 % is already 320 or more and the inline width stands.
   Below 960 nothing changes - the strip folds as before and the name column keeps its room. */
@container (min-width: 960px) and (max-width: 1142px) {
    col.giki-hub-col-pearls { width: 320px !important; }
}

/* THE TEMPLATES LIST'S PEARL COLUMN (user test finding 10, 2026-10-15): the list has SIX fixed columns (690 px)
   after Used and Last used were added, and the 28 % pearl column on a 1056 px content column (a 1440 px screen)
   left the NAME column 70 px - three letters. The 296 px bought nothing there: the cell's padding leaves the strip's
   shell 264 px, and the 280 px rule above folds the pearls anyway. So below 1376 px of container width the column
   takes only what the folded overview button needs, and the name gets the rest (302 px at 1056). 1376 is derived,
   not guessed: (690 + 302) / 0.72 - the width at which the 28 % column leaves the name the same 302 px, and where
   the returning column (385 px, shell 353) is wide enough for the strip to open. Same `!important`-inside-the-inverse-
   query rule as giki-col-pearls above, for the same reason; the wrapper carries the container, not the <table>
   (size containment does not apply to table boxes). */
.giki-templates-list { container-type: inline-size; }

@container not (min-width: 1376px) {
    .giki-templates-list col.giki-col-pearls { width: 64px !important; }
}

/* `overflow: hidden` as well (finding 99, 2026-09-25): hidden is not gone - a folded cell's text still takes its width,
   and in a column of zero it stuck out past the table's edge. SEEN on the board list at 375 px: the folded "SOURCE"
   heading, 45 px of it, gave the list a sideways scrollbar under its only row; the cell below it did not, because it
   carried `truncate`. Clipped here, a folded cell takes nothing, whatever its own classes say. */
@container not (min-width: 576px) {
    col.giki-col-rel { width: 0 !important; }

    th.giki-col-rel,
    td.giki-col-rel {
        visibility: hidden;
        overflow: hidden;
        padding: 0 !important;
        border: 0 !important;
        white-space: nowrap;
    }

    /* ...and no HEIGHT either - see the same rule for giki-col-meta below. */
    th.giki-col-rel > *,
    td.giki-col-rel > * {
        display: none !important;
    }
}

@container not (min-width: 768px) {
    col.giki-col-meta { width: 0 !important; }

    th.giki-col-meta,
    td.giki-col-meta {
        visibility: hidden;
        overflow: hidden;
        padding: 0 !important;
        border: 0 !important;
        white-space: nowrap;
    }

    /* A folded cell takes no HEIGHT either (sweep 2026-09-28, finding 7 - Christian: "the cards are huge"). Hidden
       and clipped, its content was still laid out: the "Created by" chip in a 0 px column broke "Christian Johansen"
       into one letter a line, 240 px tall, and every row of Contacts, LMS and Compliance stood 241 px high. MEASURED
       in the Windows app at 427 px, the tallest child per cell: name 20, pearls 24, the hidden chip 240. The cells
       stay (the colgroup maps columns by position), their content goes. */
    th.giki-col-meta > *,
    td.giki-col-meta > * {
        display: none !important;
    }
}

/* P2 suggestions (2026-09-06): the passages the lightbulb found, underlined in the editor while the
   drawer is open. A CSS Custom Highlight - see richTextEditor.highlightSuggestions in helpers.js for
   why it is not a span. Only colour, background and text-decoration are allowed on ::highlight. */
::highlight(giki-suggested) {
    background-color: color-mix(in srgb, var(--color-primary) 18%, transparent);
    text-decoration: underline dashed;
    text-decoration-color: var(--color-primary);
}

/* b47 (2026-09-06): what a new version of a PDF changed, while its review is open. ONE colour for new and
   changed words (Christian's choice 1); removed words live in the review panel. The Hub's info cyan, so it
   is neither a marking colour nor the pending amber. */
::highlight(giki-changed) {
    background-color: color-mix(in srgb, var(--color-info) 28%, transparent);
}

/* b51 (2026-09-06): an old version shown beside the text. Struck through = the old version had it, the text
   you have now does not. The new/changed side is ::highlight(giki-changed) above, on the editor. */
::highlight(giki-removed) {
    text-decoration: line-through;
    text-decoration-color: var(--color-danger);
    background-color: color-mix(in srgb, var(--color-danger) 12%, transparent);
}

/* Sidebar scroll (Christian 2026-09-06: "fjerne scrollbaren"). Hide the WebKit/Chrome scrollbar too;
   scrollbar-width:none on the element covers Firefox. Scrolling itself stays on (overflow-y:auto), so
   nothing is cut off on a short screen — only the visible bar is gone. */
.giki-nav-scroll::-webkit-scrollbar { display: none; }

/* ===== The step board inside the side panel shows EVERY step (Karl Damgaard, 2026-09-17) =====
   "I press Reported because I want to move this, and I get this fine row down here. The trouble is
   that when I make the window bigger, it does not grow with it, and it should, so I could see the
   full row of steps."

   Two things were wrong, and only the first was obvious. The panel's content was capped at 440px
   while the panel itself is 480-660 (fixed in the markup: the two step-board panels now take the
   panel's width). That was not enough: MEASURED at a 1666px window the panel is 549px and the seven
   steps of the equipment fault chain need 832 - the last two stayed behind a sideways scroll however
   wide the screen was, because the FORM has a maximum width and the window's extra pixels never
   reach it.

   So in this one place the board wraps instead of scrolling. Everywhere else it keeps the sideways
   board Christian asked for on 2026-09-13 ("should the steps be shown as a small board you scroll
   sideways" - yes): the popovers are 320-400px wide, where wrapping would give a tall stack. Here
   there is room for four or five per line and the whole chain is on screen at once, which is the
   thing he could not get.

   Scoped by the panel's own class, so no other call site of StepBoardPicker changes. */
/* SUPERSEDED 2026-10-24 (§387): the steps are now numbered ROWS (Shared/StepBoardPicker.razor), so every step is on
   screen in a panel of any width and there is nothing left to wrap. The rule that made the columns wrap here is gone. */

/* ===== 44 px under the finger, 28 px on the screen (Christian 2026-09-20, the mobile sweep) =====
   An icon-only button is drawn at 28 px (Btn.GhostIcon), and every alignment spacer in the app is written
   as h-7 w-7 to stand beside it - so making the button bigger on a phone would push those rows out of line.
   The ring below is invisible, costs no layout, and takes the press: 28 + 8 + 8 = 44, which is what the
   platforms ask for. Phones only; a desktop keeps the mouse's own small target.

   It lives here rather than as Tailwind variants because the scanner does not generate
   `max-md:before:content-['']` out of a C# string literal - MEASURED: absent from output.css after a rebuild,
   while `max-md:min-h-11` from the same constant file came through. */
/* ===== A PHONE HELD SIDEWAYS (Christian 2026-09-22) =====
   MEASURED at 812 x 375 on the Tasks board BEFORE this round: the board began at y = 351 of 375 and the first
   card at y = 533 - the whole viewport was toolbar and there was no work on the screen at all. The cause was
   that the toolbar's folded default asked about WIDTH only, and sideways a phone is 812 wide; the flag now asks
   about height too (Tasks/Index.razor.cs), which took the board to y = 225.

   This rule is the second half: the breadcrumb footer costs 40 px - 11 % of a landscape phone - to say where
   you already are. Below 500 px of height it is not drawn. At any normal height, a tablet's included, nothing
   changes. */
@media (max-height: 500px) {
    .giki-hide-short {
        display: none;
    }
}

/* ===== A SMALL SCREEN: narrower than sm (640 px), or at most 500 px tall (Christian 2026-09-29) =====
   The filter tray (Shared/FilterTray.razor, the .giki-ftray rules below) was for a phone held upright only - it asked
   about width. Held sideways, a phone is 667 or more wide and 375 tall, so it got the desktop rows: MEASURED on Projects
   at 667 x 375, the filters filled the whole screen and not one project showed. Christian, after a before/after
   picture: "yes, build both". A small screen is now narrow OR short - the short half is the same 500 px the footer
   (.giki-hide-short, above) and the Tasks toolbar's folded default already use.
   These two classes carry that ONE definition for markup: the pills and the phone rows are .giki-small-only, what they
   replace is .giki-hide-small. The C# side asks the same query (Tasks, Documents, Templates: _trayViewport). */

/* S/N 1219 - the message list keeps room at its end for the floating Mark as read / & close pair, so the newest message
   scrolls out from under it. The pair is .giki-hide-small, so the room goes with it on a small screen (below). */
.giki-floating-room {
    padding-bottom: 4rem;
}

@media (max-width: 639.98px), (max-height: 500px) {
    .giki-hide-small {
        display: none !important;
    }

    .giki-floating-room {
        padding-bottom: 0.75rem;
    }

    /* The Chats page's "Mark as read / & close / Reply" row: under the composer on a wide screen, ON TOP of it on a
       small one (Christian 2026-10-16 - "it can sit on top of the input field, it takes less room"). The wrapper is
       `relative`; the row covers the composer exactly and goes away on Reply. */
    .giki-above-composer-small {
        position: absolute;
        inset: 0;
        z-index: 10;
        display: flex;
        align-items: center;
        border-top: 0;
        background: var(--color-bg-card);
    }
    .giki-above-composer-small.hidden {
        display: none;
    }
}

@media (width > 639.98px) and (height > 500px) {
    .giki-small-only {
        display: none !important;
    }

    /* The Chats page's composer wrapper (Chat/Index.razor) is the bottom-most thing only here, where the Task | Chat
       pair under it is hidden; on a small screen the pair carries the home-indicator inset instead (2026-10-15). */
    .giki-wide-bottom-inset {
        padding-bottom: env(safe-area-inset-bottom);
    }
}

/* ===== The numbers board: a day the row does not ask (finding 53, Christian 2026-09-22) =====
   "If it falls on Tuesday and Thursday, the week part must show that no information comes in on the other
   days." A blank cell reads as "nobody answered"; a hatched one reads as "nothing is asked here", which is a
   different fact. Not a Tailwind arbitrary value, for the reason every other rule in this section is not. */
.giki-cell-off {
    background-image: repeating-linear-gradient(135deg, transparent 0 4px, color-mix(in srgb, var(--color-border) 55%, transparent) 4px 5px);
}

/* ===== A FINGER IS A FINGER, WHATEVER THE SCREEN IS WIDE (Christian 2026-09-22: "ja, tag de tre") =====
   These blocks were width-only, and a TABLET is 768 px - one pixel above the md breakpoint - so every rule
   below switched off on the one device that is nothing but touch. MEASURED at 768 x 1024 on the Tasks board
   before this line: 47 of the 48 controls on screen were drawn under 40 px. The question was never "is the
   screen narrow"; it is "is this a finger", and `pointer: coarse` is how CSS asks that. A mouse desktop is
   unaffected, and a touchscreen laptop gets the bigger targets, which is right.

   `.giki-tap-44` replaces `max-md:min-h-11` in Ctrl.Base, Btn.Primary, PrimaryNav, the long-press menu and the
   move sheet. Tailwind has no coarse-pointer variant, and the sweep has already been bitten once by a variant
   the scanner silently refused to emit - a rule that lives here cannot fail that way. */
@media (max-width: 767.98px), (pointer: coarse) {
    .giki-tap-44 {
        min-height: 44px;
    }
}

@media (max-width: 767.98px), (pointer: coarse) {
    .giki-hit-44 {
        position: relative;
    }

    .giki-hit-44::before {
        content: "";
        position: absolute;
        /* 44 WHATEVER THE BUTTON IS DRAWN AT. `inset: -8px` was written for the 28 px icon button and gives 44
           only there; the same class on the 22 px rail handles would have given 38 and on the 24 px pearl chip
           40 - short of the target while looking like it was solved. Centred and sized to at least 44, it is
           correct for every size it is put on, and still exactly 44 for the 28 px case it was written for.
           MEASURED 2026-09-22 on the rail handle: 22 x 22 drawn, 44 x 44 pressed. */
        left: 50%;
        top: 50%;
        width: max(100%, 44px);
        height: max(100%, 44px);
        transform: translate(-50%, -50%);
    }
}

/* ===== One column at a time on a phone (Christian 2026-09-20) =====
   "ret kanban'en så den viser én kolonne ad gangen på telefon". MEASURED at 375 px before this: a column is
   320 px wide (w-80) inside a 343 px content box, so the next column stood 23 px into the screen and a swipe
   could come to rest anywhere between two of them - neither column whole, and no way to tell which one you
   were on.

   Below md a column is exactly the width of the board's visible area (flex-basis 100% of the scroller's
   content box - NOT 100vw, which would be wrong the moment anything else takes width beside the board), and
   the scroller snaps to column starts. A swipe therefore moves one column and stops on it.

   Not a Tailwind pair, for the same reason .giki-col-narrow is not: an arbitrary width utility that the
   scanner fails to emit is a silent no-op, and a board that quietly keeps the old width looks like a bug in
   the feature rather than a missing class. From md: up nothing here applies - the desktop keeps its 352 px
   columns and its free scroll. */
@media (max-width: 767.98px) {
    .giki-board-row {
        scroll-snap-type: x mandatory;
        scroll-padding-left: 1rem;
    }

    .giki-board-row > .giki-board-col {
        flex: 0 0 100%;
        width: 100%;
        scroll-snap-align: start;
    }
}

/* ===== The rest of the mobile sweep: what a thumb has to hit (Christian 2026-09-20) =====
   MEASURED at 375 px on the Tasks board: 131 of 146 controls were drawn smaller than 40 px in one dimension.
   The toolbar pills and the icon buttons were dealt with in UiKit; these are the three groups that were left,
   and they are the ones a finger uses most: the menu, the top bar, and the dots on a card.

   Phones only. The desktop keeps every size the pages were designed around - the mouse hits 22 px without
   complaint, and growing these there would cost rows and columns of space for nothing. */
@media (max-width: 767.98px), (pointer: coarse) {
    /* The top bar's icon buttons: 36 → 44 inside a 56 px bar, so nothing moves. */
    .giki-appbar button,
    .giki-appbar a {
        min-height: 44px;
        min-width: 44px;
    }

    /* A card's tick and its chat / follow-up / poke dots. The tick goes 30 → 38 and each dot 22 → 30, with a
       real gap between them: three 22 px dots two pixels apart is one 70 px target as far as a thumb is
       concerned, and hitting "poke" when you meant "chat" is worse than a small control. */
    .giki-status-pills > button {
        height: 38px;
        width: 38px;
    }

    .giki-status-pills > div {
        height: 38px;
        gap: 6px;
        padding-left: 6px;
        padding-right: 6px;
    }

    /* The dots carry their size as an inline style (the Size parameter), so the only way in is the variable
       AtomStatusIcon reads: --giki-dot, with that parameter as its fallback. 22 → 30. */
    .giki-status-pills {
        --giki-dot: 30px;
    }
}

/* ===== Me Today's STATUS column is as wide as the pills it holds (user test finding 24, 2026-10-15) =====
   The column was pinned at 120 px in the markup, a number computed for the 10 px rings the trio replaced on
   2026-08-01. The cell needs 16 + 30 + 4 + 80 + 16 = 146 px with a mouse (tick 30, gap 4, trio
   4 + 1 + 3 x 22 + 2 x 2 + 1 + 4 = 80) and 16 + 38 + 4 + 116 + 16 = 190 px on a coarse pointer (the sweep above:
   tick 38, trio 6 + 1 + 3 x 30 + 2 x 6 + 1 + 6 = 116) - which Christian's touchscreen laptop IS (tailwind.css,
   measured 2026-08-03 and again 2026-10-15). MEASURED at 1440 px before this rule: the trio ran 54 px past the
   column line and elementFromPoint on the name's first letters returned the poke button. Here rather than a width
   utility so the column can follow the pointer the way the pills do; the table's fixed layout takes the difference
   from the percentage columns (measured: Name 384 -> 360, the 14 % columns 180 -> 168, table width unchanged).
   Below md the phone block further down owns the row and this does not apply. */
@media (min-width: 768px) {
    .giki-mt-table .giki-mt-status-col {
        width: 146px;
    }
}

@media (min-width: 768px) and (pointer: coarse) {
    .giki-mt-table .giki-mt-status-col {
        width: 190px;
    }
}

/* ===== Me Today on a phone: the tick and the trio on a line of their own, the name under it =====
   Christian 2026-09-28 (finding 8, in the Windows app - the TestFlight code at 402 pt): "the marking of the
   task done and chat/follow-up takes far too much on the strip - it must be smaller and sit on top, on a line,
   so you can see much more." SEEN in his screenshot at 427 px: the tick and the trio took ~170 px of every
   row and the name was cut after ~22 characters ("afsætte budget til udviklin…").
   So below md the table becomes rows of two lines: line 1 the tick, the trio and the date; line 2 the name at
   full width, up to two lines. The pills are drawn smaller than the mobile sweep's 38/30 (asked for), and an
   invisible margin round each button keeps the finger's target near its old size. Desktop keeps the table. */
@media (max-width: 767.98px) {
    .giki-mt-table,
    .giki-mt-table > tbody {
        display: block;
    }

    /* Every row, Virtualize's spacer rows included (they carry their height as a style). */
    .giki-mt-table tr {
        display: flex;
        flex-wrap: wrap;
    }

    .giki-mt-row > td {
        flex: 1 0 100%;
        max-width: none;
    }

    /* 8 + 32 + 4 + one line of name + 8: about the height the old one-line row had (~71 px measured in his
       screenshot), so the list shows as many rows as before while each name gets the whole width. */
    .giki-mt-row > td:first-child {
        padding-top: 0.5rem;
        padding-bottom: 0;
    }

    .giki-mt-row > td:nth-child(2) {
        padding-top: 0.25rem;
        padding-bottom: 0.5rem;
        white-space: normal;
    }

    /* Two classes deep: Tailwind's .truncate on the same span has equal weight and may load later. */
    .giki-mt-row .giki-mt-name {
        display: -webkit-box;
        -webkit-box-orient: vertical;
        -webkit-line-clamp: 2;
        overflow: hidden;
        white-space: normal;
    }

    /* The strip, wherever it now has a line of its own - Me Today, the project rows, a project's sub-tasks: drawn
       at 32/24, with an invisible margin that keeps the finger's target near the mobile sweep's 38/30. */
    :is(.giki-mt-row, .giki-proj-row, .giki-sub-row) .giki-status-pills > button {
        height: 32px;
        width: 32px;
    }

    :is(.giki-mt-row, .giki-proj-row, .giki-sub-row) .giki-status-pills > div {
        height: 32px;
        gap: 8px;
    }

    :is(.giki-mt-row, .giki-proj-row, .giki-sub-row) .giki-status-pills {
        --giki-dot: 24px;
    }

    :is(.giki-mt-row, .giki-proj-row, .giki-sub-row) .giki-status-pills button {
        position: relative;
    }

    :is(.giki-mt-row, .giki-proj-row, .giki-sub-row) .giki-status-pills button::after {
        content: "";
        position: absolute;
        inset: -6px -4px;
    }

    /* Projects (sweep 2026-09-28, finding 2 - SEEN at 427 px: the name got ~70 px, "Energio…", and the owner's name
       stood on end, one letter a line, because the progress bar's min-width took the rest). Line 1: the chevron,
       the strip and the actions; line 2: the name, its meta, the owner and the progress at full width. The same for
       the per-person rows under an open project. */
    .giki-proj-row {
        flex-wrap: wrap;
        row-gap: 0.5rem;
    }

    .giki-proj-row > .giki-proj-name {
        flex: 1 0 100%;
        order: 2;
    }

    .giki-proj-row > .giki-proj-actions {
        margin-left: auto;
    }

    .giki-proj-row .giki-proj-title {
        display: -webkit-box;
        -webkit-box-orient: vertical;
        -webkit-line-clamp: 2;
        overflow: hidden;
        white-space: normal;
    }

    /* A project's sub-tasks in its form (finding 3 - "Måling af kom…"): the strip, the people and the date on
       line 1, the name under them. */
    .giki-sub-row {
        flex-wrap: wrap;
        row-gap: 0.25rem;
    }

    .giki-sub-row > div:first-child {
        margin-right: auto;
    }

    .giki-sub-row > .giki-sub-name {
        flex: 1 0 100%;
        order: 2;
        display: -webkit-box;
        -webkit-box-orient: vertical;
        -webkit-line-clamp: 2;
        overflow: hidden;
        white-space: normal;
    }
}

/* ===== Room under the "Hide filters" pill (sweep 2026-09-28, finding 6) =====
   On a phone the pill floats at the bottom, fixed - Christian 2026-08-16: "it must always be down, so it can be got
   at" (Shared/ToolbarCollapseHandle.razor). SEEN at 427 px on Tasks: it lay over the second card, and the end of every
   list could only ever be read through it. The pill stays; the scrollers under it get room at their end, so any card
   can be scrolled clear of it. Only while a pill is on the page, only where it is shown (a small screen). */
@media (max-width: 639.98px), (max-height: 500px) {
    body:has(.giki-bottom-pill) :is(.giki-col-body, .giki-list-scroll) {
        padding-bottom: 4.5rem;
    }
}

/* ===== An open conversation takes the phone's screen (Christian 2026-09-29) =====
   MEASURED on his screenshot of the Greens chat: of 668 px, the messages had ~35. The idea strip, the Me Today / Chats
   switch and the footer are page chrome; WhatsApp shows none of it inside a chat, and neither does Giki now while a
   conversation is open on a phone (Chat/Index.razor marks the pane giki-chat-open). Back to the list brings them back. */
@media (max-width: 767.98px) {
    /* The same for the chat LIST (Christian 2026-09-29, on an iPhone SE: "the same must happen here as in the chat"),
       so the rule hangs on the whole chat page (Chat/Index.razor giki-chat-page). */
    body:has(.giki-chat-page) .giki-capture-strip,
    body:has(.giki-chat-page) footer.giki-hide-short {
        display: none !important;
    }

    /* The Me Today / Chats switch goes only inside a conversation, where the back arrow is the way out. On the list it
       stays: it is the way back to Me Today without the swipe, and the swipe must never stand alone (MobileSideSwitch).
       WhatsApp does the same - its tabs stand under the chat list and disappear inside a chat. */
    /* 2026-10-15, user test finding 49: `main >` because the task form's own Task/Chat switch (AtomFormModal.razor,
       the same MobileSideSwitch class) is hosted outside <main> and must stay: the form's chat panel sets the shared
       ActiveChat that marks the pane giki-chat-open, so the bare class hid the form's switch on every task opened
       over Chats on a phone, and the conversation could not be reached from the task. */
    body:has(.giki-chat-open) main > .giki-side-switch {
        display: none !important;
    }

    /* ...and to the screen's edges, as WhatsApp's does: the page's 16 px gutter went round the conversation card, and
       at an iPhone SE's 375 px those 32 px were what the name was missing ("Ledergru…", seen 2026-09-29). */
    body:has(.giki-chat-page) .giki-content-area {
        padding: 0 !important;
    }
}

/* ===== A list's sort and filter controls in a tray on a phone (Christian 2026-09-29) =====
   Seen on an iPhone SE: the filter rows filled the first screen (Projects, Contacts, Hub ...). Shared/FilterTray.razor
   wraps a page's controls; Tasks, Documents and Templates wrap the rows their ToolbarCollapseHandle folds. On a larger
   screen the wrapper is not there at all (display:contents), so a desktop is unchanged. On a SMALL screen (narrow or
   short - see .giki-small-only above) it is hidden, and opened it stands fixed just above the bottom pill
   (bottom-16 = 4rem, about 2.25rem tall), the screen's width less a small margin. No overflow on the tray where the
   height allows: a dropdown inside it has to be able to open past its edges. */
.giki-ftray {
    display: contents;
}

@media (max-width: 639.98px), (max-height: 500px) {
    .giki-ftray {
        display: none;
    }

    .giki-ftray.giki-ftray-open {
        display: flex;
        flex-direction: column; /* whole rows (Tasks, Projects) stack at full width */
        position: fixed;
        left: 0.5rem;
        right: 0.5rem;
        bottom: 7.5rem; /* clear of FoldAllFab too: fixed at bottom 4.5rem, 44 px tall (its rule above) */
        z-index: var(--z-sticky);
        background: var(--color-bg-card);
        border: 1px solid var(--color-border);
        border-radius: var(--radius-lg);
        box-shadow: 0 12px 32px rgb(0 0 0 / 0.35);
        padding: 0.25rem 0;
        /* A page's space-y-* gives its children a bottom margin (Tailwind v4, every child but the last), and on a fixed
           box that margin LIFTS it: on Findings the tray and the pill stood 16 px higher than everywhere else. */
        margin: 0;
    }

    /* Loose controls (a band of chips, a search, a sort) wrap as a row in the tray instead; the band's thin vertical
       dividers mean nothing there. */
    .giki-ftray.giki-ftray-open.giki-ftray-inline {
        flex-direction: row;
        flex-wrap: wrap;
        align-items: center;
        gap: 0.5rem;
        padding: 0.625rem;
    }

    .giki-ftray-inline > .w-px {
        display: none;
    }

    /* A search field in the tray gets a line of its own: squeezed in beside the chips it read "Search nam". */
    .giki-ftray-inline > :is(input[type="search"], input[type="text"], .relative:has(> input)) {
        flex: 1 1 100%;
        max-width: none;
    }

    .giki-ftray-inline > .relative > input {
        width: 100%;
    }

    /* ModuleChrome's toolbar band (Shared/ModuleChrome.razor) when ALL it holds is a filter tray: on a phone that is
       an empty 41 px band with a line under it. It folds to nothing - but stays in the DOM, since the pill is inside. */
    .giki-mc-toolbar:not(:has(> :not(.giki-ftray, .giki-ftray-pill))) {
        min-height: 0;
        padding: 0;
        border-bottom-width: 0;
    }

    /* A page that scrolls in the content area itself gets room at its end, so its last row can be scrolled clear
       of the pill (the lists with their own scroller already have it: .giki-col-body, .giki-list-scroll above).
       NOT a page whose list scrolls itself: there the padding stood UNDER the list as an empty band, and the list
       ended 80 px above the screen's foot (Projects and Media, measured 2026-09-29 at 375 x 667) - those lists carry
       giki-list-scroll and get the room at their own end instead. */
    body:has(.giki-ftray-pill) .giki-content-area:not(:has(.giki-list-scroll)) {
        padding-bottom: 5rem;
    }

    /* A sheet opened from inside the tray (Tasks' person filter is a BottomSheet) lies over the screen's bottom; the pill
       would stand on top of its list, so it steps aside while a sheet is open. */
    body:has(.bottom-sheet-backdrop:not(.giki-sheet-hidden)) .giki-bottom-pill {
        visibility: hidden;
    }

    /* ...and while cards are selected (Tasks/KanbanView.razor .giki-bulk-bar): the bulk bar stands at the bottom of the
       board, and the pill lay across its buttons ("Mark done", "Move to…" - seen at 375 px, 2026-09-29, once the select
       box sat in the card's corner and selecting on a phone became easy). The pill comes back with the last deselect. */
    body:has(.giki-bulk-bar) .giki-bottom-pill {
        visibility: hidden;
    }

    /* Assets' round "+" (inline-styled at bottom 24px, Assets/Index.razor) covered the footer's connection pill on a
       phone (iPhone SE sweep 2026-09-29): it stands above the footer here, clear of the filter pill at its left. */
    .giki-asset-fab {
        bottom: 3.5rem !important;
    }
}

/* SIDEWAYS the height is not there for a tall tray: from its place above the pill, Projects' tray rose under the fixed
   header (h-14) and hid its department chips (seen in the before/after preview at 667 x 375, 2026-09-29). On a short
   screen it stops a little under the header and scrolls. What opens from inside a tray is not clipped by that - READ
   2026-09-29: the sorts are native selects (SortSelect, HubFilterBand), the projects picker is a fixed overlay below
   lg (ProjectsPickerPopover), and Tasks' person filter is a BottomSheet portalled to the page (DesktopAnchor). */
@media (max-height: 500px) {
    .giki-ftray.giki-ftray-open {
        max-height: calc(100dvh - 7.5rem - 3.75rem);
        overflow-y: auto;
    }
}

/* ===== The task form's footer on a phone: the tools behind ⋯ (Christian 2026-09-29) =====
   Measured at 375 x 667: the footer (two lines of tools, a two-line note, Close / I can't do it, Mark Complete) took
   ~220 px and the task's own text was not on screen. On a small screen the tools row (AtomForm/AtomFormModalFooter.razor
   .giki-ftools) is hidden and the ⋯ in the pills' line opens it as a tray of named tiles just above the footer, like the
   chat composer's + tray; "I can't do it" is its first tile. The owner note keeps its place right above Mark Complete
   (finding 46), on one line. Close and a wide Mark Complete share the bottom line. On a larger screen nothing changes.
   A SMALL screen since 2026-09-29: narrower than sm, or at most 500 px tall - a phone held sideways, where the form is
   one panel at a time too (the modal rule above). */
@media (max-width: 639.98px), (max-height: 500px) {
    .giki-ftools {
        display: none !important;
    }

    .giki-ftools.giki-ftools-open {
        display: grid !important;
        grid-template-columns: repeat(3, minmax(0, 1fr));
        gap: 0.375rem;
        position: absolute;
        bottom: calc(100% + 0.5rem);
        left: 0.75rem;
        right: 0.75rem;
        width: auto; /* the row's own w-full would override left/right and push the tray 12 px past the screen */
        z-index: 30;
        margin: 0;
        padding: 0.625rem;
        border: 1px solid var(--color-border);
        border-radius: 1rem;
        background: var(--color-bg-card);
        box-shadow: 0 12px 32px rgb(0 0 0 / 0.35);
    }

    .giki-ftools-open > div {
        width: 100%;
    }

    /* each tool a named tile: the word under the icon, the icon in a round well (a phone has no hover for a tooltip) */
    .giki-ftools-open > :is(button, a),
    .giki-ftools-open > div > :is(button, a):first-child {
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: 0.375rem;
        width: 100%;
        height: auto;
        padding: 0.625rem 0.25rem;
        border-radius: 0.75rem;
        font-size: 12px;
        line-height: 1.2;
        text-align: center;
        white-space: normal;
    }

    .giki-ftools-open > :is(button, a) > :is(svg, i):first-child,
    .giki-ftools-open > div > :is(button, a):first-child > :is(svg, i):first-child {
        box-sizing: content-box;
        flex-shrink: 0;
        width: 20px;
        height: 20px;
        padding: 12px;
        border-radius: 9999px;
        background: var(--color-bg-muted);
        color: var(--color-primary);
    }

    .giki-ftools-open > .giki-ftools-cant,
    .giki-ftools-open > .giki-ftools-cant > svg:first-child {
        color: var(--color-error);
    }

    /* History and New meeting drop their word below md in the row; in the tray every tile says what it is */
    .giki-ftools-open span.max-md\:hidden {
        display: inline !important;
    }

    .giki-owner-note-lines {
        display: flex;
        align-items: baseline;
        gap: 0.375rem;
    }

    .giki-owner-note-lines > :last-child::before {
        content: "\00B7";
        margin-right: 0.375rem;
        color: var(--color-text-muted);
        font-weight: 400;
    }

    /* ⋯, Close and Mark Complete on one line: the primary takes what is left (it wraps only if it cannot fit) */
    .giki-form-actions.giki-form-actions > .giki-form-action-primary {
        flex: 1 1 8rem;
        min-height: 2.75rem;
    }

    /* The footer rows' own phone layout. It stood as max-sm: utilities in AtomFormModalFooter.razor and moved here
       2026-09-29, so the one definition of a small screen reaches it. Doubled selectors: output.css loads after this file. */
    .giki-form-footer.giki-form-footer {
        position: relative; /* the tools tray is anchored to this row */
        padding-left: 0.75rem;
        padding-right: 0.75rem;
    }

    .giki-form-actions-full.giki-form-actions-full,
    .giki-form-view-col.giki-form-view-col {
        margin-left: 0;
        width: 100%;
    }

    .giki-form-view-col.giki-form-view-col {
        align-items: stretch;
    }

    .giki-form-actions-anchor.giki-form-actions-anchor {
        position: relative;
    }

    /* the "I can't do it" popover anchors to the button row, not to its own (hidden) button, so it fits the screen */
    .giki-cant-anchor.giki-cant-anchor {
        position: static;
    }
}

/* SIDEWAYS THE TOOLS TRAY IS ONE ROW (Christian 2026-09-29, from a before/after picture: "yes, build it that way"). In
   three columns the tiles stood three rows tall and rose under the form's header, which hid the first row (I can't do
   it, History, QR); a phone held sideways has the width for every tool side by side. The tiles start at the top, so a
   label on two lines ("New meeting") does not lift its icon out of line with the others. */
@media (max-height: 500px) {
    .giki-ftools.giki-ftools.giki-ftools-open {
        grid-template-columns: none;
        grid-auto-flow: column;
        grid-auto-columns: minmax(0, 1fr);
        align-items: start;
    }
}

/* ===== Numbers board: four frozen, adjustable columns (Christian 2026-10-21, recording 8) =====
   The four left columns stick to the page's left edge while the day cells scroll sideways; the widths are the
   --bc1..--bc4 variables on .giki-board-grid (Boards.razor.js). A cell needs its own background, or the numbers show
   through it as they slide under. The last frozen column carries the shadow that says "the rest moves". */
.giki-board-frozen { position: sticky; z-index: 5; background: var(--color-bg-card); }
.giki-board-frozen-head { z-index: 6; background: var(--color-bg-canvas); }
.giki-board-frozen-last { box-shadow: 6px 0 10px -6px rgba(0, 0, 0, .22); }
.giki-board-resize {
    position: absolute; top: 4px; bottom: 4px; right: -3px; width: 6px; border-radius: 3px; cursor: col-resize;
    background: var(--color-primary); opacity: 0; transition: opacity .15s;
}
th:hover > .giki-board-resize, .giki-board-resize:active { opacity: .6; }
th.giki-board-frozen { position: sticky; }
/* overflow: clip - clips like hidden, but starts no scroll container, so a sticky cell inside still sticks to the page. */
.giki-overflow-clip { overflow: clip; }

/* The "New line in ..." band on the numbers board: as wide as the visible part of the pane that scrolls, and stuck at its own
   start, so it stays put however far the board is scrolled (2026-10-22, recording 11). The two variables are set by
   Boards.razor.js; without them (no script yet) it falls back to the plain full width it had. */
.giki-board-band {
    position: sticky;
    left: var(--board-inset, 0px);
    /* never wider than its group: a board narrower than the pane does not scroll, and the band must not stick out */
    width: min(100%, calc(var(--board-view, 100%) - 2 * var(--board-inset, 0px)));
    max-width: none;
}
