/*
 * The Detection service page (templates/public/services/detection.html).
 *
 * The page is a title and one diagram: the plugin on one side, the cloud on the other, and the wire
 * between them, played on a loop that works through a queue -- the player at the front is checked,
 * then leaves it and everyone behind them moves up, and the next round starts on whoever that
 * brought forward. Everything here is CSS keyframes on markup that is already in the page - no script -
 * because the site swaps pages with htmx and view transitions, and a pure-CSS animation restarts on
 * its own the moment the new markup lands.
 *
 * The page frame (.service, .service__title) comes from public/services.css, so this file only
 * owns the diagram. It keeps the flat look of the rest of the public site: the panel color, no
 * outlines around the panels, no shadows or gradients beyond the ones that carry meaning.
 */
/* ---------------------------------------------------------------- the round trip */

/*
 * One round trip per player, taken in queue order: the plugin sends what the player at the front of
 * the queue did and the cloud answers, that player is dealt with and drops out of the queue, the
 * rest move up a seat, and the round begins again on the one now at the front -- for however many
 * players templates/public/services/detection.html was given, and then from the top.
 *
 * --round is one player's round trip and --cycle is all of them, which is why --players has to
 * reach this file from the template. Anything that behaves the same whoever is being checked -- the
 * dot on each rail, the "Processing..." line -- animates on --round and simply repeats, and its
 * keyframes live here. Anything that belongs to one particular player -- that player's row, the
 * verdict it ends on, the result it produced -- animates on --cycle with the keyframes written
 * for the FIRST round only and is pushed into its own round by an animation-delay of --r rounds;
 * those keyframe lists are rendered with the page, because the share of the cycle one round
 * occupies is not known until the player list is.
 *
 * PHASES of a round, as a share of one --round. The animations here use them as written; the
 * generated ones divide them by the player count, which is all `detection_flow.at()` does:
 *
 *      0%  -  18%   the player's data leaves the plugin and travels to the cloud
 *     10%  -  52%   the cloud is processing
 *     54%  -  70%   the result travels back down to the plugin
 *     72%  -  95%   the plugin acts on it
 *     95%  - 100%   the queue moves up a seat, which starts the next player's round
 */
/* The page ends with the diagram, so it drops the gap .service leaves for a following section. */
.service {
    margin-bottom: 0;
}

.flow {
    --round: 5.2s;
    /* --players is set on the element by the template; the fallback is only for a stray .flow. */
    --cycle: calc(var(--round) * var(--players, 3));

    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(190px, 0.85fr) minmax(0, 1fr);
    align-items: center;
    max-width: 900px;
    margin: 90px auto 0;
}

/* The plugin and the cloud are drawn as the same kind of box, because they are peers on a wire. */
.node {
    padding: 1.1rem 1.2rem 1.3rem;
    background: var(--panel);
    border-radius: 14px;
}

.node__head {
    padding-bottom: 12px;
}

.node__title {
    font-family: var(--display);
    font-size: 1.05rem;
    font-weight: 700;
    letter-spacing: -0.01em;
}

/* ---------------------------------------------------------------- the plugin's side */

/*
 * The queue the plugin is working through. The seat at the front is the top one and it is always
 * the row sitting in it that is being checked, so what moves between rounds is the queue itself:
 * .players is a window a fixed few seats tall -- see --seats -- and .players__strip -- the list,
 * plus a second copy of it standing behind -- is carried up through that window one seat per round.
 * A row leaving the front is simply carried out of the top of the window, and a roster longer than
 * the window waits below its bottom edge until the rounds ahead of it have carried it into view.
 */
.players {
    --row: 38px;
    --gap: 8px;
    /* One seat: what the strip is carried by at the end of every round. The `queue` list uses it. */
    --step: calc(var(--row) + var(--gap));
    /*
     * How many seats the window is tall, which is how many players are on screen at any one moment.
     * It is a fixed number rather than the length of the roster: the queue is worked through a
     * player at a time whether the server has five on it or fifty, so the box stays the size of the
     * diagram and the rest of the roster waits below the edge for its turn to be carried up. A
     * shorter roster than that is not padded out with empty seats, hence the min().
     */
    --seats: 5;

    height: calc(min(var(--players, 3), var(--seats)) * var(--step) - var(--gap));
    margin-top: 14px;
    overflow: hidden;
}

.players__strip {
    display: flex;
    flex-direction: column;
    gap: var(--gap);
    margin: 0;
    padding: 0;
    list-style: none;
    /*
     * `queue` is one of the generated lists -- a step per player -- because how far a seat is as a
     * share of the cycle is not known until the player count is. See the note at the top.
     */
    animation: queue var(--cycle) cubic-bezier(0.4, 0, 0.2, 1) infinite;
}

/*
 * The answer is the row itself: once the result is back the seat it is sitting in turns green or
 * red and stays that way until it leaves the top of the queue, which is the whole of what a round
 * says. Whichever verdict it ends on it is drawn the same way, so the two differ only by the colour
 * they resolve to, and the `checking` list -- rendered with the page, one per round -- reads them
 * from the row rather than naming a colour itself.
 *
 * The fill is mixed into the lit seat colour rather than laid over it as a wash, so the row reads
 * as a solid block of the verdict at the size it is drawn, and the name is carried in the same hue
 * at full strength so it stays legible against it.
 */
.player--clean {
    --outcome-fill: color-mix(in srgb, var(--good) 22%, var(--raise-2));
    --outcome-text: var(--good);
}

.player--flagged {
    --outcome-fill: color-mix(in srgb, var(--accent-solid) 22%, var(--raise-2));
    --outcome-text: var(--accent-solid);
}

/*
 * A row is lit while it is the one at the front being checked and is left holding its verdict; the
 * delay puts that in this player's round. Its height is fixed rather than a minimum because --step is measured from it: a row that
 * grew would put the queue out of step with the distance it is carried.
 */
.player {
    display: flex;
    align-items: center;
    gap: 10px;
    flex: none;
    height: var(--row);
    padding: 7px 10px;
    background: var(--raise);
    border-radius: 8px;
    color: var(--text-dim);
    animation: checking var(--cycle) ease-in-out infinite;
    animation-delay: calc(var(--r) * var(--round));
}

/*
 * The second copy of the list: the rows standing behind the queue, waiting their turn to reach the
 * front, which they never do within a cycle -- they are only there so the seats the real rows leave
 * are not empty. They take no turn and carry no verdict, so they hold still, and they are drawn
 * exactly like an unchecked row because at the moment the cycle wraps that is what they stand in
 * for.
 */
.player--waiting {
    animation: none;
}

/*
 * checking, result and verdict-hue are the per-player lists, and are rendered with the page
 * rather than defined here -- see the note at the top of this section.
 */

/*
 * The player's face, as their skin wears it. Where it is fetched from is the template's business --
 * it arrives as a url on the player - so all that is decided here is the size, which is fixed in
 * both directions: the row is one seat tall and the queue is carried by the seat, so a face that
 * loaded a pixel taller than it said it would would put the whole strip out of step. The tile keeps
 * its colour if the image never arrives.
 */
.player__skin {
    width: 22px;
    height: 22px;
    flex: none;
    border-radius: 4px;
    background: var(--raise-2);
    object-fit: cover;
}

/* Colour comes from the row, which brightens the name while that player is the one being checked. */
.player__name {
    color: inherit;
    font-family: var(--mono);
    font-size: 0.85rem;
}

/* ---------------------------------------------------------------- the wire */

/* Two rails, one per direction, so the trip reads as a loop rather than a line. */
.channel {
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: 76px;
    padding: 0 10px;
}

.rail {
    position: relative;
    height: 2px;
    background: var(--edge);
    border-radius: 2px;
}

/* The arrowhead that says which way this rail runs. */
.rail::after {
    content: "";
    position: absolute;
    top: 50%;
    width: 7px;
    height: 7px;
    border-top: 2px solid var(--edge-2);
    border-right: 2px solid var(--edge-2);
}

.rail--up::after {
    right: 0;
    transform: translateY(-50%) rotate(45deg);
}

.rail--down::after {
    left: 0;
    transform: translateY(-50%) rotate(-135deg);
}

.rail__label {
    position: absolute;
    left: 50%;
    transform: translateX(-50%);
    color: var(--dim);
    font-family: var(--mono);
    font-size: 0.68rem;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    white-space: nowrap;
}

.rail--up .rail__label {
    bottom: 12px;
}

.rail--down .rail__label {
    top: 12px;
}

/*
 * What actually moves: one dot per rail, out on the first part of the round and back on the second,
 * so the two are never travelling at the same time. The dot is positioned by `left` rather than a
 * transform so the distance is a percentage of the rail, which lets the same keyframes work at
 * every viewport width.
 */
.packet {
    position: absolute;
    top: 50%;
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: var(--text-dim);
    box-shadow: 0 0 10px 1px rgb(236 235 232 / 0.25);
    opacity: 0;
    transform: translate(-50%, -50%);
    animation: send var(--round) linear infinite;
}

@keyframes send {
    0% { left: 0; opacity: 0; }
    2% { left: 4%; opacity: 1; }
    16% { left: 96%; opacity: 1; }
    18%, 100% { left: 100%; opacity: 0; }
}

/*
 * The colour of an answer, which the returning dot takes from the rail's own `color`. It steps to
 * the verdict of the round about to be decided at the moment that round starts processing - late
 * enough that nothing gives the answer away while the player data is still on its way out.
 */
.rail--down {
    color: var(--good);
    animation: verdict-hue var(--cycle) step-end infinite;
}

.rail--down .packet {
    background: currentColor;
    box-shadow: 0 0 10px 1px color-mix(in srgb, currentColor 45%, transparent);
    animation-name: reply;
}

@keyframes reply {
    0%, 54% { left: 100%; opacity: 0; }
    56% { left: 96%; opacity: 1; }
    68% { left: 4%; opacity: 1; }
    70%, 100% { left: 0; opacity: 0; }
}

/* ---------------------------------------------------------------- the cloud's side */

/*
 * What the cloud is doing, drawn as the console it would actually be read on: a flat black screen
 * inside the panel, one line per thing that has happened so far in the round, filled from the top
 * the way a terminal fills. It says the same three things every round -- what was asked, that it is
 * working, what it found -- so the box never changes height and never scrolls.
 *
 * Console output does not fade in, so every line here switches with step-end: it is either on the
 * screen or it is not.
 */
.terminal {
    display: grid;
    align-content: start;
    min-height: 237px;
    padding: 14px 16px;
    background-color: rgb(3, 5, 2);
    font-family: var(--mono);
    font-size: 0.75rem;
    line-height: 1.6;
    text-align: left;
}

/* Every player's version of a line is stacked in the same row, so the row keeps its height. */
.terminal__line {
    display: grid;
    min-height: 1.6em;
    margin: 0;
}

.terminal__text {
    grid-area: 1 / 1;
    justify-self: start;
    color: var(--text-dim);
    /* The columns in a line are spaces, as they would be in real output, so they have to survive. */
    white-space: pre;
    opacity: 0;
}

.terminal__sigil {
    color: var(--dim);
}

/*
 * command and result are the per-player lists, rendered with the page rather than defined here --
 * see the note at the top of the file.
 */
.terminal__text--command {
    color: var(--text);
    animation: command var(--cycle) step-end infinite;
    animation-delay: calc(var(--r) * var(--round));
}

.terminal__text--work {
    animation: processing var(--round) step-end infinite;
}

@keyframes processing {
    0%, 9% { opacity: 0; }
    10%, 52% { opacity: 1; }
    53%, 100% { opacity: 0; }
}

.terminal__text--result {
    animation: result var(--cycle) step-end infinite;
    animation-delay: calc(var(--r) * var(--round));
}

.terminal__text--result.terminal__text--clean {
    color: var(--good);
}

.terminal__text--result.terminal__text--flagged {
    color: var(--accent-solid);
}

/* Waiting for the next command, which is the one thing on the page that is not on the round's clock. */
.terminal__cursor {
    display: block;
    width: 7px;
    height: 1.1em;
    background: var(--text-dim);
    animation: blink 1.1s step-end infinite;
}

@keyframes blink {
    0%, 50% { opacity: 1; }
    51%, 100% { opacity: 0; }
}

/* ---------------------------------------------------------------- narrow viewports */

@media (max-width: 900px) {
    /*
     * Stacked, the round trip runs top to bottom, so the rails turn with it: same phases, same
     * delays, only the axis the dots travel along changes.
     */
    .flow {
        grid-template-columns: minmax(0, 1fr);
        max-width: 420px;
        margin-top: 60px;
    }

    .channel {
        flex-direction: row;
        justify-content: center;
        gap: 90px;
        height: 130px;
        padding: 10px 0;
    }

    .rail {
        width: 2px;
        height: 100%;
    }

    .rail::after {
        top: auto;
        left: 50%;
    }

    .rail--up::after {
        right: auto;
        bottom: 0;
        transform: translateX(-50%) rotate(135deg);
    }

    .rail--down::after {
        top: 0;
        left: 50%;
        transform: translateX(-50%) rotate(-45deg);
    }

    .rail__label {
        top: 50%;
        left: auto;
        transform: translateY(-50%);
    }

    .rail--up .rail__label {
        right: 12px;
        bottom: auto;
    }

    .rail--down .rail__label {
        top: 50%;
        left: 12px;
    }

    .packet {
        left: 50%;
        animation-name: send-down;
    }

    .rail--down .packet {
        animation-name: reply-up;
    }

    @keyframes send-down {
        0% { top: 0; opacity: 0; }
        2% { top: 4%; opacity: 1; }
        16% { top: 96%; opacity: 1; }
        18%, 100% { top: 100%; opacity: 0; }
    }

    @keyframes reply-up {
        0%, 54% { top: 100%; opacity: 0; }
        56% { top: 96%; opacity: 1; }
        68% { top: 4%; opacity: 1; }
        70%, 100% { top: 0; opacity: 0; }
    }

    .terminal {
        min-height: 0;
        margin-top: 4px;
    }
}

/*
 * With motion turned off the page still has to say the same thing, so nothing is merely stopped:
 * every element is pinned to the end of its own story. The flow settles on the round that carries
 * the point -- the one where the answer came back guilty -- while every row is left holding its own
 * verdict, so the seats the window holds are all readable at once instead of one round of them.
 */
@media (prefers-reduced-motion: reduce) {
    .players__strip,
    .player,
    .packet,
    .rail--down,
    .terminal__text,
    .terminal__cursor {
        animation: none !important;
    }

    /* Every row that takes a turn is left on its answer; the copies behind never had one. */
    .player--clean,
    .player--flagged {
        background: var(--outcome-fill);
        color: var(--outcome-text);
    }

    .packet {
        left: 50%;
        opacity: 1;
    }

    .rail--down {
        color: var(--accent-solid);
    }

    /*
     * Only the flagged round is left on the console; the other two players' lines would sit on top
     * of it. What is left is a finished command: what was asked, and what came back.
     */
    .terminal__text--flagged {
        opacity: 1;
    }

    .terminal__cursor {
        opacity: 1;
    }
}
