/* ============================================================
   grammar.css — Grammar mode styles.

   All UI that lives under body.grammar-active. Mirrors the
   vocab.css conventions (trigger highlight, list swap, mobile
   height clamp). Pairs with js/grammar-study.js.

   First-slice scope: toolbar button + left-column lesson list.
   Middle (exercise) and right (rules) columns are scaffolded
   in a follow-up pass.
   ============================================================ */

/* Grammar toggle — when active, mirror the Trace/Recall accent
   highlight so the user has a single, unambiguous "you are here"
   indicator in the toolbar. Matches .vocab-trigger.active for
   visual symmetry between the two left-column mode triggers. */
.grammar-trigger.active {
    background: transparent;
    color: var(--accent);
    border: 1.5px solid var(--accent);
    font-weight: 700;
}
@media (hover: hover) {
    .grammar-trigger.active:hover {
        background: var(--accent-bg);
        color: var(--accent);
        border-color: var(--accent);
    }
}

/* While grammar mode is active, demote the canvas-side
   Trace/Recall highlight — the user isn't in either kana mode
   right now. The buttons remain clickable; clicking either also
   exits grammar mode (handler wired in grammar-study.js). */
body.grammar-active .mode-toggle-btn.active {
    background: transparent;
    color: var(--text-muted);
    border: var(--dash-soft);
    font-weight: 600;
}

/* Default state: list hidden so the existing grid + subheaders
   show exactly as they did before this feature existed. */
.grammar-list { display: none; }

body.grammar-active #char-grid,
body.grammar-active .grid-subcol-headers {
    display: none;
}
body.grammar-active .grammar-list {
    display: block;
}

.grammar-list {
    padding: 2px 0;
}

/* In grammar mode the list owns the scroll so the column header
   stays put while lessons scroll underneath — same trick the
   vocab list uses. */
body.grammar-active .col-left .panel-body {
    overflow: hidden;
    padding: 0;
    display: flex;
    flex-direction: column;
}

body.grammar-active .grammar-list {
    flex: 1 1 0;
    min-height: 0;
    overflow-y: auto;
}

/* Default .col-center has 30px top/bottom padding for the
   tagline/carousel chrome (set in panels.js). Grammar mode doesn't
   use those, so the 30px strands the back-button + tally row well
   below the panel's top edge. Trim it across all breakpoints. */
body.grammar-active .col-center {
    padding-top: 6px;
}

@media (max-width: 900px) {
    body.grammar-active .grammar-list {
        flex: none;
        height: 60vh;
    }
    /* Mobile center column has no fixed height (it's just width:100% in
       the tab layout). Without an explicit height, the active-exercise
       engines (sentence canvas, particle-fill, match, etc.) — all
       flex:1 1 0; min-height:0 — collapse to zero. Pin the column to a
       viewport-relative height so they have room to render.
       !important needed because the mobile Kana fix in panels.js sets
       `.col-center { height:auto !important; min-height:0 !important }`
       to beat a stale inline lock — that override would otherwise also
       beat this grammar-mode minimum. */
    body.grammar-active .col-center {
        min-height: 65vh !important;
        height: auto !important;
    }
    /* …but the lesson-home exercise-picker has natural-height content
       (a stack of option cards), so the 65vh floor would just leave
       the bottom half empty. When the picker is the visible state,
       drop the floor and let the column hug the cards. */
    body.grammar-active .col-center:has(#grammar-lesson-home:not([hidden])) {
        min-height: 0 !important;
    }
    /* …and the panel itself must size to its content rather than
       fill its parent. Default is `flex: 1 1 0; min-height: 0`,
       which collapses to zero the moment col-center has no fixed
       height — leaving the column visibly stubbed-out at ~30px. */
    body.grammar-active .col-center:has(#grammar-lesson-home:not([hidden])) .grammar-exercise-panel {
        flex: 0 1 auto;
    }
    /* Match has the same situation as the picker: the board is
       now content-sized (`flex: 0 0 auto`) so the 65vh floor would
       just stack a giant void under the pills. Drop the floor and
       collapse the flex chain so the column hugs the match content. */
    body.grammar-active .col-center:has(.grammar-match-active:not([hidden])) {
        min-height: 0 !important;
    }
    body.grammar-active .col-center:has(.grammar-match-active:not([hidden])) .grammar-exercise-panel,
    body.grammar-active .col-center:has(.grammar-match-active:not([hidden])) .grammar-match-active {
        flex: 0 1 auto;
    }
    /* Sentence Reconstruction (and its Write-mode 280x280 canvas)
       are also content-sized — build-area has min-height:84px and
       no flex grow, token-pool wraps naturally, hw-frame is a
       fixed 280px box. The 65vh floor + `margin-top: auto` on the
       actions row was pushing Check/Hint to the bottom of an empty
       65vh column, leaving a void between the token pool and the
       actions. Same collapse pattern as Match. */
    body.grammar-active .col-center:has(#grammar-exercise-active:not([hidden])) {
        min-height: 0 !important;
    }
    body.grammar-active .col-center:has(#grammar-exercise-active:not([hidden])) .grammar-exercise-panel,
    body.grammar-active .col-center:has(#grammar-exercise-active:not([hidden])) .grammar-exercise-active {
        flex: 0 1 auto;
    }
    /* Kanji Reading (sliding-queue match) — board now sizes to
       content, same as the regular Match. Drop the 65vh floor +
       collapse the flex chain so the panel hugs the rows. */
    body.grammar-active .col-center:has(.grammar-kanji-match-active:not([hidden])) {
        min-height: 0 !important;
    }
    body.grammar-active .col-center:has(.grammar-kanji-match-active:not([hidden])) .grammar-exercise-panel,
    body.grammar-active .col-center:has(.grammar-kanji-match-active:not([hidden])) .grammar-kanji-match-active {
        flex: 0 1 auto;
    }
    /* Particle Fill — sentence skeleton + particle pool are both
       content-sized, no flex:1 children that need to fill the column.
       Same collapse template as the rest. */
    body.grammar-active .col-center:has(#grammar-particle-active:not([hidden])) {
        min-height: 0 !important;
    }
    body.grammar-active .col-center:has(#grammar-particle-active:not([hidden])) .grammar-exercise-panel,
    body.grammar-active .col-center:has(#grammar-particle-active:not([hidden])) .grammar-particle-active {
        flex: 0 1 auto;
    }
    /* Cryptex (Conjugation Drill) — stage is flex:1 1 0 with
       justify-content:center, which under the 65vh floor split the
       slack into voids above the cryptex frame AND below the Reveal
       button. Collapse stage + active + panel to content size so the
       prompt → frame → readout stack is tight. The EXPLORE mode-
       toggle is absolutely pinned to the stage's bottom-right, so
       it moves up alongside the readout — still discoverable, no
       longer marooned at the bottom of an empty column. */
    body.grammar-active .col-center:has(.grammar-cryptex-active:not([hidden])) {
        min-height: 0 !important;
    }
    body.grammar-active .col-center:has(.grammar-cryptex-active:not([hidden])) .grammar-exercise-panel,
    body.grammar-active .col-center:has(.grammar-cryptex-active:not([hidden])) .grammar-cryptex-active,
    body.grammar-active .col-center:has(.grammar-cryptex-active:not([hidden])) .grammar-cryptex-stage {
        flex: 0 1 auto;
    }
    /* Multiple Choice — verb card + choice grid are content-sized,
       no children that need to fill the panel. Same collapse template. */
    body.grammar-active .col-center:has(#grammar-mc-active:not([hidden])) {
        min-height: 0 !important;
    }
    body.grammar-active .col-center:has(#grammar-mc-active:not([hidden])) .grammar-exercise-panel,
    body.grammar-active .col-center:has(#grammar-mc-active:not([hidden])) .grammar-mc-active {
        flex: 0 1 auto;
    }
    /* Verb Wheel — field has its own min-height (280px mobile / 360px
       desktop) so collapsing the flex chain lets the wheel sit at its
       natural size with the readout right below. Without this collapse
       the active container fills the 65vh column and stretches the
       field even taller than min-height, dwarfing the readout. */
    body.grammar-active .col-center:has(.grammar-vwheel-active:not([hidden])) {
        min-height: 0 !important;
    }
    body.grammar-active .col-center:has(.grammar-vwheel-active:not([hidden])) .grammar-exercise-panel,
    body.grammar-active .col-center:has(.grammar-vwheel-active:not([hidden])) .grammar-vwheel-active,
    body.grammar-active .col-center:has(.grammar-vwheel-active:not([hidden])) .grammar-vwheel-stage {
        flex: 0 1 auto;
    }
}

/* ── Section header (e.g. "Basic Syntax") ─────────────────── */
.grammar-section-header {
    font-size: 0.72rem;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--text-faint);
    padding: 14px 12px 6px;
    font-weight: 700;
}
.grammar-section-header:first-child {
    padding-top: 6px;
}

/* ── Lesson row ──────────────────────────────────────────── */
.grammar-lesson-row {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    padding: 10px 12px;
    margin: 0 4px 4px;
    border-radius: 6px;
    border: 1px solid transparent;
    cursor: pointer;
    line-height: 1.25;
    transition: background 0.12s, border-color 0.12s;
}

.grammar-lesson-row.selected {
    background: var(--accent-bg);
    border-color: var(--accent);
}

@media (hover: hover) {
    .grammar-lesson-row:hover {
        background: var(--accent-bg);
        border-color: var(--subtle-border);
    }
}

.grammar-lesson-name {
    font-size: 0.95rem;
    font-weight: 600;
    color: var(--text);
}

.grammar-lesson-subtitle {
    font-family: 'Noto Sans JP', sans-serif;
    font-size: 0.85rem;
    color: var(--text-faint);
    margin-top: 2px;
}

.grammar-list-error {
    padding: 16px;
    color: var(--text-faint);
    font-size: 0.85rem;
    font-style: italic;
    text-align: center;
}

/* ── Center-column takeover ──────────────────────────────── */
/* Hidden by default; revealed via body.grammar-active. Mirrors
   the .vocab-decks-panel pattern. */
.grammar-exercise-panel { display: none; }

/* The [hidden] attribute is the source-of-truth for empty / active /
   complete view swaps inside the panel — without this override, any
   explicit display:flex on a sibling state defeats hidden. */
.grammar-exercise-panel [hidden] { display: none !important; }

body.grammar-active .col-center .mode-tagline,
body.grammar-active .col-center .card-wrapper,
body.grammar-active .col-center .canvas-header-strip,
body.grammar-active .col-center .carousel-info-panel,
body.grammar-active .col-center .canvas-action-slot,
body.grammar-active .col-center .vocab-decks-panel {
    display: none !important;
}

body.grammar-active .grammar-exercise-panel {
    display: flex;
    flex-direction: column;
    flex: 1 1 0;
    min-height: 0;
    padding: 4px 16px 12px;
    gap: 10px;
    overflow-y: auto;
}

/* Active exercise view: spread the four stacked regions (header,
   build area, token pool, actions) evenly down the column so the
   layout doesn't hug the top. The action row sits at the bottom
   thanks to `margin-top: auto` on .grammar-exercise-actions. */
.grammar-exercise-active {
    display: flex;
    flex-direction: column;
    gap: 22px;
    flex: 1 1 0;
    min-height: 0;
    padding-top: 0;
}

body.grammar-active .col-center {
    display: flex;
    flex-direction: column;
}

/* ── Particle-fill exercise ────────────────────────────────
   Fixed sentence skeleton with particle slots blanked out. User
   taps a blank to activate, then taps a particle to fill it.
   Shares the .grammar-exercise-active gap/padding sizing so the
   header + actions feel consistent with sentence reconstruction. */
.grammar-particle-active {
    display: flex;
    flex-direction: column;
    gap: 22px;
    flex: 1 1 0;
    min-height: 0;
}

.grammar-particle-sentence {
    display: flex;
    flex-wrap: wrap;
    gap: 8px 6px;
    justify-content: center;
    align-items: baseline;
    padding: 14px 12px;
    /* Recessed well — same treatment as build area / match pills. */
    background: var(--bg-outer);
    box-shadow: inset 0 1px 2px rgba(31, 26, 26, 0.08);
    border: 1.5px dashed var(--subtle-border);
    border-radius: 12px;
    font-family: 'Noto Sans JP', sans-serif;
    font-size: 1.1rem;
    color: var(--text);
    line-height: 1.4;
}

/* Plain content tokens (non-blank, non-period). Just inline text. */
.grammar-particle-token {
    padding: 2px 2px;
}

/* Sentence-ending period gets a slight gap reset. */
.grammar-particle-period {
    margin-left: -4px;
    margin-right: 10px;
}

/* Blank pill — empty by default, tappable. When the user fills it
   with a particle, the pill expands to display the character. */
.grammar-particle-blank {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 56px;
    min-height: 36px;
    padding: 4px 14px;
    background: var(--accent-bg);
    border: 1.5px dashed var(--accent);
    border-radius: 8px;
    font-family: 'Noto Sans JP', sans-serif;
    font-weight: 500;
    color: var(--accent);
    cursor: pointer;
    transition: background 0.12s, border-style 0.12s, border-color 0.12s;
}

.grammar-particle-blank.active {
    background: var(--accent);
    color: #fff;
}
.grammar-particle-blank.filled {
    border-style: solid;
}
.grammar-particle-blank.wrong {
    animation: grammarShake 0.4s ease-in-out;
    border-color: var(--danger, #d23);
    color: var(--danger, #d23);
}

@media (hover: hover) {
    .grammar-particle-blank:not(.active):hover {
        background: var(--bg);
        border-color: var(--accent);
    }
}

/* Particle pool — the three options. Tap any to fill the active
   blank; tap an "in use" particle to do nothing (or remove it). */
.grammar-particle-pool {
    display: flex;
    gap: 12px;
    justify-content: center;
    flex-wrap: wrap;
}

.grammar-particle-chip {
    font-family: 'Noto Sans JP', sans-serif;
    font-size: 1.1rem;
    font-weight: 500;
    color: var(--text);
    /* Recessed well — same treatment as tokens / match pills. */
    background: var(--bg-outer);
    box-shadow: inset 0 1px 2px rgba(31, 26, 26, 0.08);
    border: 1px dashed var(--subtle-border);
    border-radius: 8px;
    padding: 8px 18px;
    cursor: pointer;
    transition: background 0.12s, border-color 0.12s, transform 0.08s;
}

@media (hover: hover) {
    .grammar-particle-chip:hover {
        background: var(--accent-bg);
        border-color: var(--accent);
    }
}
.grammar-particle-chip:active { transform: translateY(1px); }

/* Mobile compaction — same template as Match/Kanji-Reading. */
@media (max-width: 600px) {
    .grammar-particle-sentence {
        font-size: 1rem;
        padding: 12px 10px;
        gap: 6px 4px;
    }
    .grammar-particle-blank {
        min-width: 48px;
        min-height: 32px;
        padding: 3px 12px;
    }
    .grammar-particle-pool {
        gap: 8px;
    }
    .grammar-particle-chip {
        font-size: 1rem;
        padding: 6px 14px;
    }
}

/* ── Verb Wheel exercise ─────────────────────────────────
   Coupled wheel-and-crossword puzzle for u/ru/irr-verb negative
   conjugation. Suffix arms fan around a hub on the right; verb
   stems scroll in a crossword reel on the left. Spinning visits
   every verb-class and makes the per-consonant rule visible.
   Visual is lifted from the designer's wheel.css under
   share/from designer/Verb Wheel/, with all class names
   namespaced under .grammar-vwheel-active. */
.grammar-vwheel-active {
    display: flex;
    flex-direction: column;
    gap: 14px;
    flex: 1 1 0;
    min-height: 0;
    --vw-bg:          var(--bg);
    --vw-panel:       var(--panel-bg, var(--bg));
    --vw-ink:         var(--text);
    --vw-ink-soft:    var(--text-faint, #6f685b);
    --vw-ink-faint:   var(--text-faint, #a59d8c);
    --vw-line:        var(--subtle-border);
    --vw-tile-fill:   var(--accent-bg);
    --vw-tile-line:   var(--subtle-border);
    --vw-tile-shadow: rgba(60,48,30,.13);
    --vw-dot-line:    #c7baa2;
    --vw-dot-empty:   #d6cab5;
    --vw-dot-ink:     #a89c85;
    --vw-hub:         #383735;
    --vw-accent:      var(--accent);
    --vw-accent-glow: color-mix(in srgb, var(--vw-accent) 18%, transparent);
    /* Geometry pixel values are written DIRECTLY by JS autoFit().
       Don't use calc() with a --vw-scale here: getComputedStyle on
       custom properties returns the unresolved calc() string, which
       parseFloat() can't read — the engine math would silently fall
       back to defaults while the rendered tiles use the scaled size,
       and the wheel would visibly fly apart. JS owns the scale. */
    --tile:           48px;
    --inner-r:        122px;
    --tile-gap:       58px;
    --hub-size:       96px;
    --hub-inset:      0px;
    --cc-cell:        50px;
}

.grammar-vwheel-stage {
    position: relative;
    display: flex;
    flex-direction: column;
    gap: 6px;
    flex: 1 1 auto;
    min-height: 0;
}

/* Verb-wheel prompt: one short line, no bold. The shared
   .grammar-exercise-prompt allows wrap (sentence reconstruction
   needs it) and uses font-weight: 600, so we override locally
   to keep the wheel's prompt a calm, single-line instruction
   ("Spin to '<form>'.") that can't shift layout. */
.grammar-vwheel-active .grammar-exercise-prompt {
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    font-size: 1.02rem;
    font-weight: 400;
    line-height: 1.4;
    color: var(--vw-ink-soft);
}

.grammar-vwheel-field {
    position: relative;
    flex: 1 1 auto;
    min-height: 360px;
    touch-action: none;
    user-select: none;
    -webkit-user-select: none;
    cursor: grab;
    overflow: hidden;
}
.grammar-vwheel-field:active { cursor: grabbing; }
.grammar-vwheel-scene { position: absolute; inset: 0; }

.grammar-vwheel-anchor {
    position: absolute;
    top: 50%;
    right: var(--hub-inset);
    width: 0;
    height: 0;
}

.grammar-vwheel-hub {
    position: absolute;
    top: 0;
    left: 0;
    width: var(--hub-size);
    height: var(--hub-size);
    transform: translate(-50%, -50%);
    border-radius: 50%;
    background: radial-gradient(120% 120% at 36% 30%,
        color-mix(in oklab, var(--vw-hub) 74%, #ffffff) 0%,
        var(--vw-hub) 52%,
        color-mix(in oklab, var(--vw-hub) 82%, #000000) 100%);
    box-shadow:
        0 18px 30px -14px rgba(40,34,24,.45),
        0 2px 6px rgba(40,34,24,.18),
        inset 0 2px 3px rgba(255,255,255,.08);
    z-index: 4;
}
.grammar-vwheel-hub::after {
    content: "";
    position: absolute;
    left: 50%; bottom: -14px;
    transform: translateX(-50%);
    width: 78%; height: 22px;
    border-radius: 50%;
    background: radial-gradient(closest-side, rgba(40,34,24,.22), transparent 78%);
    z-index: -1;
}

.grammar-vwheel-armset { position: absolute; top: 0; left: 0; z-index: 3; }
.grammar-vwheel-tile {
    position: absolute;
    top: 0; left: 0;
    width: var(--tile);
    height: var(--tile);
    display: grid;
    place-content: center;
    font-family: 'Klee One', 'Noto Sans JP', serif;
    font-weight: 600;
    font-size: 1.4rem;
    color: var(--vw-ink);
    background: var(--vw-tile-fill);
    border: 1.5px solid var(--vw-tile-line);
    border-radius: 12px;
    box-shadow: 0 2px 5px var(--vw-tile-shadow);
    transition: transform .30s cubic-bezier(.34,1.2,.5,1),
                opacity .25s ease,
                border-color .2s, box-shadow .2s;
}
.grammar-vwheel-tile-active {
    border-color: var(--vw-accent);
    box-shadow: 0 4px 11px -2px rgba(60,48,30,.18),
                0 0 0 3px var(--vw-accent-glow);
}
.grammar-vwheel-field.grammar-vwheel-dragging .grammar-vwheel-tile,
.grammar-vwheel-field.grammar-vwheel-dragging .grammar-vwheel-creel,
.grammar-vwheel-field.grammar-vwheel-dragging .grammar-vwheel-cell,
.grammar-vwheel-field.grammar-vwheel-dragging .grammar-vwheel-anchor {
    transition: none !important;
}

.grammar-vwheel-creel {
    position: absolute;
    display: grid;
    z-index: 3;
    transition: transform .30s cubic-bezier(.34,1.2,.5,1);
    font-family: 'Klee One', 'Noto Sans JP', serif;
    font-weight: 600;
}
.grammar-vwheel-cell {
    width: var(--cc-cell);
    height: var(--cc-cell);
    display: grid;
    place-content: center;
    font-size: 1.38rem;
    color: var(--vw-ink);
    border-radius: 12px;
    transition: transform .30s cubic-bezier(.34,1.2,.5,1),
                border-color .2s, box-shadow .2s;
}
.grammar-vwheel-cell-stem {
    background: var(--vw-tile-fill);
    border: 1.5px solid var(--vw-tile-line);
    box-shadow: 0 2px 5px var(--vw-tile-shadow);
}
.grammar-vwheel-cell-ending {
    background: transparent;
    border: 2.5px dotted var(--vw-dot-line);
    color: var(--vw-dot-ink);
}
.grammar-vwheel-cell-empty {
    background: transparent;
    border: 2.5px dotted var(--vw-dot-empty);
}
.grammar-vwheel-cell-row-active {
    border-color: var(--vw-accent);
    box-shadow: 0 2px 5px var(--vw-tile-shadow),
                0 0 0 3px var(--vw-accent-glow);
}

/* Readout: stacked two rows (conjugation on top, meaning row below).
   Fixed total height so revealing the gloss can't shift the wheel.
   The meaning row reserves space even when the gloss is hidden — the
   Reveal button sits in that reserved space. */
.grammar-vwheel-readout {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: flex-start;
    gap: 6px;
    padding-top: 4px;
    min-height: 84px;
    max-height: 84px;
    overflow: hidden;
}
.grammar-vwheel-ro-form-row {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 12px;
    white-space: nowrap;
}
.grammar-vwheel-ro-meaning-row {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 10px;
    min-height: 28px;
    max-width: 100%;
    white-space: nowrap;
    overflow: hidden;
}
.grammar-vwheel-ro-dict {
    font-family: 'Klee One', 'Noto Sans JP', serif;
    font-weight: 600;
    font-size: 1.4rem;
    color: var(--vw-ink-soft);
    flex-shrink: 0;
}
.grammar-vwheel-ro-arrow {
    color: var(--vw-ink-faint);
    font-size: 1.25rem;
    flex-shrink: 0;
}
.grammar-vwheel-ro-form {
    font-family: 'Klee One', 'Noto Sans JP', serif;
    font-weight: 600;
    font-size: 2rem;
    line-height: 1;
    letter-spacing: 0.02em;
    color: var(--vw-ink);
    flex-shrink: 0;
}
.grammar-vwheel-ro-form .grammar-vwheel-suffix {
    color: var(--vw-accent);
}
.grammar-vwheel-ro-gloss {
    color: var(--vw-ink-soft);
    font-size: 1rem;
    font-style: italic;
    min-width: 0;
    flex-shrink: 1;
    overflow: hidden;
    text-overflow: ellipsis;
}
.grammar-vwheel-reveal-btn {
    font-family: inherit;
    font-size: 0.8rem;
    font-weight: 500;
    color: var(--vw-ink-soft);
    background: transparent;
    /* Dashed 1px to match the site's chip language (action buttons,
       set cards, etc.). */
    border: 1px dashed var(--vw-line);
    border-radius: 999px;
    padding: 3px 12px;
    cursor: pointer;
    letter-spacing: 0.02em;
    transition: background 0.12s, border-color 0.12s, color 0.12s;
}
@media (hover: hover) {
    .grammar-vwheel-reveal-btn:hover {
        background: var(--vw-tile-fill);
        border-color: var(--vw-accent);
        color: var(--vw-ink);
    }
}

.grammar-vwheel-nudge {
    position: absolute;
    left: 44%;
    top: 78%;
    transform: translate(-50%, -50%);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 2px;
    color: var(--vw-ink-faint);
    pointer-events: none;
    z-index: 6;
    transition: opacity 0.4s ease;
}
.grammar-vwheel-nudge-chev {
    font-size: 1.4rem;
    animation: grammarVwheelBob 1.6s ease-in-out infinite;
}
.grammar-vwheel-nudge-txt {
    font-size: 0.72rem;
    letter-spacing: 0.14em;
    text-transform: uppercase;
}
@keyframes grammarVwheelBob {
    0%, 100% { transform: translateY(3px); opacity: 0.55; }
    50%      { transform: translateY(-4px); opacity: 1; }
}
.grammar-vwheel-field.grammar-vwheel-touched .grammar-vwheel-nudge {
    opacity: 0;
}

@media (max-width: 760px) {
    /* Mobile sizing for the readout. Geometry vars are owned by JS
       autoFit() — the field width drives them, not the viewport. */
    .grammar-vwheel-field { min-height: 280px; }
    .grammar-vwheel-ro-form { font-size: 1.7rem; }
    .grammar-vwheel-ro-dict { font-size: 1.2rem; }
}

/* ── Multiple-choice exercise ────────────────────────────
   Single-tap classification quiz: a big verb on top, three
   class-name buttons below. Each verb is one internal page;
   the engine paginates through every L4 vocab entry tagged
   with ru_verb / u_verb / irregular_verb. */
.grammar-mc-active {
    display: flex;
    flex-direction: column;
    gap: 22px;
    flex: 1 1 0;
    min-height: 0;
}

.grammar-mc-verb {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 8px;
    padding: 24px 18px;
    /* Recessed well — same treatment as particle sentence / build area. */
    background: var(--bg-outer);
    box-shadow: inset 0 1px 2px rgba(31, 26, 26, 0.08);
    border: 1.5px dashed var(--subtle-border);
    border-radius: 12px;
    font-family: 'Noto Sans JP', sans-serif;
    font-size: 1.8rem;
    font-weight: 500;
    color: var(--text);
    line-height: 1.2;
    min-height: 96px;
}

/* Verb meaning (stanza mode). Muted + smaller so the verb itself
   stays the focal point of the question. */
.grammar-mc-verb-gloss {
    font-family: inherit;
    font-size: 0.95rem;
    font-weight: 400;
    color: var(--text-faint, #888);
    margin-top: 2px;
}

.grammar-mc-choices {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 10px;
}

/* If the final button would land alone in a row (odd total count),
   center it at the same width as the other buttons (instead of
   stretching full row width). Covers verb-class (3 → 2 + 1-centered)
   and verb-form (5 → 2 + 2 + 1-centered). The width matches a single
   grid column: half the row minus half the gap. */
.grammar-mc-choice:last-child:nth-child(odd) {
    grid-column: 1 / -1;
    justify-self: center;
    width: calc(50% - 5px);
}

.grammar-mc-choice {
    font-family: 'Noto Sans JP', sans-serif;
    font-size: 1rem;
    font-weight: 500;
    color: var(--text);
    /* Recessed well — same treatment as tokens / match pills. */
    background: var(--bg-outer);
    box-shadow: inset 0 1px 2px rgba(31, 26, 26, 0.08);
    border: 1px dashed var(--subtle-border);
    border-radius: 10px;
    padding: 10px 14px;
    cursor: pointer;
    text-align: center;
    transition: background 0.12s, border-color 0.12s, color 0.12s, transform 0.08s;
}

@media (hover: hover) {
    .grammar-mc-choice:hover:not(:disabled) {
        background: var(--accent-bg);
        border-color: var(--accent);
    }
}

.grammar-mc-choice:active:not(:disabled) { transform: translateY(1px); }

.grammar-mc-choice.correct {
    background: var(--accent);
    border: 1.5px solid var(--accent);
    color: #fff;
    box-shadow: none;
    cursor: default;
}

.grammar-mc-choice.wrong {
    border: 1.5px solid var(--danger, #d23);
    color: var(--danger, #d23);
    box-shadow: none;
    animation: grammarShake 0.4s ease-in-out;
    cursor: not-allowed;
    opacity: 0.7;
}

/* Mobile compaction — verb headline shrinks slightly to leave more
   room for the choice grid; choices tighten padding. */
@media (max-width: 600px) {
    .grammar-mc-verb {
        font-size: 1.4rem;
        padding: 18px 14px;
        min-height: 76px;
    }
    .grammar-mc-choice {
        font-size: 0.92rem;
        padding: 8px 12px;
    }
    .grammar-mc-choices {
        gap: 8px;
    }
}

.grammar-mc-choice:disabled:not(.correct):not(.wrong) {
    opacity: 0.5;
    cursor: not-allowed;
}

/* ── Cryptex exercise ────────────────────────────────────
   Two horizontal rings — top = bases, bottom = suffixes.
   Center "window" shows whichever cells the user has spun in.
   Cells are rendered in a 7-wide window via translateX; the renderer
   handles wrap-around (cells beyond index 6 use modular logic). */
.grammar-cryptex-active {
    display: flex;
    flex-direction: column;
    gap: 14px;
    flex: 1 1 0;
    min-height: 0;
    padding-top: 4px;
}
.grammar-cryptex-active .grammar-exercise-prompt {
    font-size: 0.9rem;
    font-weight: 500;
    color: var(--text-muted);
    padding: 0 8px;
}

.grammar-cryptex-stage {
    position: relative;
    flex: 1 1 0;
    min-height: 0;
    display: flex;
    flex-direction: column;
    gap: 14px;
    align-items: center;
    justify-content: center;
    padding: 0 8px;
}

/* Mode toggle pinned to the bottom-right corner of the stage. Quiet
   pill button — same dashed-accent language as the other actions. */
.grammar-cryptex-mode-toggle {
    position: absolute;
    bottom: 0;
    right: 8px;
    z-index: 2;
    font-size: 0.7rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--text-muted);
    background: transparent;
    border: 1.5px solid var(--subtle-border);
    border-radius: 999px;
    padding: 4px 10px;
    cursor: pointer;
    transition: color 0.12s, border-color 0.12s, background 0.12s;
}
@media (hover: hover) {
    .grammar-cryptex-mode-toggle:hover {
        color: var(--accent);
        border-color: var(--accent);
        background: var(--accent-bg);
    }
}

/* Cryptex frame — the "case" around the two side-by-side rings.
   gap:0 so the base row's last char abuts the suffix row's first char
   and the assembled word reads as one continuous sequence in the
   center window. Dark caps run across the top and bottom like the
   lid bands of a real cryptex barrel. */
.grammar-cryptex-frame {
    position: relative;
    display: flex;
    gap: 0;
    justify-content: center;
    align-items: stretch;
    background: var(--bg);
    border-radius: 12px;
    padding: 20px 18px;
    background-image:
        linear-gradient(to bottom, var(--text) 0, var(--text) 10px, transparent 10px),
        linear-gradient(to top,    var(--text) 0, var(--text) 10px, transparent 10px);
    background-repeat: no-repeat;
    background-position: top center, bottom center;
    background-size: 86% 10px, 86% 10px;
}

/* Ring widths sized to the longest content row. Base ring caps at 5
   chars (e.g., あたらしい / おもしろい). Suffix ring caps at 6 chars
   (e.g., じゃなかった / くなかった). overflow:hidden clips spinning rows. */
.grammar-cryptex-ring {
    position: relative;
    height: 308px;       /* 7 rows × 44px pitch */
    overflow: hidden;
    touch-action: none;
    user-select: none;
}
/* Widths chosen to seat the longest possible row + a slight margin:
   base ring max = 4 chars (e.g. ハンサム, あたらし), suffix ring max = 6
   chars (じゃなかった). Cells are 36px + 2px margin = 38px pitch. */
#grammar-cryptex-ring-base   { width: 160px; }
#grammar-cryptex-ring-suffix { width: 240px; }

.grammar-cryptex-strip {
    position: absolute;
    left: 0;
    right: 0;
    top: 50%;
    display: flex;
    flex-direction: column;
    transform: translateY(0);
    will-change: transform;
}
.grammar-cryptex-strip.snap {
    transition: transform 0.22s ease-out;
}

/* Each row is a horizontal sequence of single-character cells. The
   base ring anchors rows to the right edge so the last char hugs the
   suffix ring; the suffix ring anchors to the left edge so it picks up
   right where the base left off. */
.grammar-cryptex-row {
    display: flex;
    flex-direction: row;
    gap: 0;
    height: 40px;
    margin: 2px 0;
    align-items: center;
}
#grammar-cryptex-strip-base   .grammar-cryptex-row { justify-content: flex-end; }
#grammar-cryptex-strip-suffix .grammar-cryptex-row { justify-content: flex-start; }

/* Per-character cell. No gaps between siblings — characters butt up
   against each other so a multi-char form reads as one word. */
.grammar-cryptex-cell {
    flex: 0 0 auto;
    width: 36px;
    height: 36px;
    display: flex;
    align-items: center;
    justify-content: center;
    background: var(--accent-bg);
    border: 1.5px solid var(--subtle-border);
    border-radius: 6px;
    margin: 0 1px;
    font-family: 'Noto Sans JP', sans-serif;
    font-size: 1rem;
    color: var(--text-muted);
    transition: color 0.15s, background 0.15s, border-color 0.15s;
}

/* Center row — the one in the cryptex window. Only the real character
   cells light up; fillers in the center row stay neutral so they read
   as empty slots rather than active glyphs. */
.grammar-cryptex-row.center .grammar-cryptex-cell:not(.filler) {
    color: var(--accent);
    background: var(--bg);
    border-color: var(--accent);
    font-weight: 700;
}

/* Filler cell — same chrome as a regular cell but no glyph and a hair
   lighter so the eye reads it as a placeholder, not a missing char. */
.grammar-cryptex-cell.filler {
    background: var(--accent-bg);
    border-color: var(--subtle-border);
    opacity: 0.6;
}

/* Horizontal window band across both rings at the center, so the
   center row reads as a single readout slot. */
.grammar-cryptex-window {
    position: absolute;
    left: 12px;
    right: 12px;
    top: 50%;
    transform: translateY(-50%);
    height: 46px;
    border-top: 2px dashed var(--accent);
    border-bottom: 2px dashed var(--accent);
    pointer-events: none;
    border-radius: 4px;
}

.grammar-cryptex-readout {
    text-align: center;
    display: flex;
    flex-direction: column;
    gap: 4px;
    min-height: 60px;
    justify-content: center;
}

.grammar-cryptex-assembled {
    font-family: 'Noto Sans JP', sans-serif;
    font-size: 1.6rem;
    font-weight: 700;
    color: var(--text);
    min-height: 1.4em;
}
.grammar-cryptex-assembled.invalid {
    color: var(--danger, #d23);
    text-decoration: line-through;
    text-decoration-color: rgba(0,0,0,0.25);
    opacity: 0.75;
}

.grammar-cryptex-meaning {
    font-size: 0.95rem;
    color: var(--text-muted);
    min-height: 1.2em;
}

/* Cryptex meaning row: pill button + revealed gloss share a fixed-
   height row so toggling the gloss can't shift the readout. */
.grammar-cryptex-meaning-row {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 10px;
    min-height: 28px;
    max-width: 100%;
    white-space: nowrap;
    overflow: hidden;
}
.grammar-cryptex-reveal-btn {
    font-family: inherit;
    font-size: 0.8rem;
    font-weight: 600;
    color: var(--text-faint, var(--text));
    background: transparent;
    border: 1.5px solid var(--subtle-border);
    border-radius: 999px;
    padding: 3px 12px;
    cursor: pointer;
    letter-spacing: 0.02em;
    transition: background 0.12s, border-color 0.12s, color 0.12s;
}
@media (hover: hover) {
    .grammar-cryptex-reveal-btn:hover {
        background: var(--accent-bg);
        border-color: var(--accent);
        color: var(--text);
    }
}

.grammar-cryptex-stage.matched .grammar-cryptex-assembled {
    color: var(--accent);
}
.grammar-cryptex-stage.matched .grammar-cryptex-frame {
    box-shadow: 0 0 0 2px var(--accent-bg), 0 0 0 4px var(--accent);
    transition: box-shadow 0.2s;
}

@media (max-width: 600px) {
    .grammar-cryptex-ring {
        height: 252px;       /* 7 × 36 pitch */
    }
    #grammar-cryptex-ring-base   { width: 150px; }
    #grammar-cryptex-ring-suffix { width: 186px; }
    .grammar-cryptex-row {
        height: 32px;
        margin: 2px 0;
    }
    .grammar-cryptex-cell {
        width: 30px;
        height: 30px;
        font-size: 0.9rem;
    }
    .grammar-cryptex-window { height: 38px; }
    .grammar-cryptex-assembled { font-size: 1.3rem; }
}

/* ── Match exercise ─────────────────────────────────────
   Two columns of pill rows joined by a user-drawn line on the
   SVG overlay. The overlay covers the whole board and ignores
   pointer events so taps still register on the dots underneath. */
.grammar-match-active {
    display: flex;
    flex-direction: column;
    gap: 10px;
    flex: 1 1 0;
    min-height: 0;
    padding-top: 2px;
    /* Anchor for the absolutely-positioned prompt below. */
    position: relative;
}

/* The match drill's instruction ("Draw a line between each sentence
   and its meaning") only matters until the user actually starts —
   after that it's noise stealing canvas height. Pull it out of the
   header flow and pin it to the actions row at the bottom, where it
   occupies the same visual slot as the Clear button. Default: prompt
   visible, Clear hidden. First correct pair locks → prompt hidden,
   Clear appears in its place. */
.grammar-match-active .grammar-exercise-prompt {
    position: absolute;
    left: 50%;
    bottom: 12px;
    transform: translateX(-50%);
    margin: 0;
    padding: 10px 22px;
    font-size: 0.78rem;
    font-weight: 500;
    letter-spacing: 0.06em;
    color: var(--text-faint);
    text-align: center;
    white-space: nowrap;
    pointer-events: none;
    z-index: 1;
}

/* Clear button is hidden until the user starts pairing — its only
   purpose is to undo locked pairs, so it has nothing to do at t=0. */
.grammar-match-active .grammar-match-clear-btn {
    display: none;
}

/* First correct pair locks: swap the bottom slot from instruction
   text to the Clear button. */
.grammar-match-active:has(.grammar-match-row.locked) .grammar-exercise-prompt {
    display: none;
}
.grammar-match-active:has(.grammar-match-row.locked) .grammar-match-clear-btn {
    display: inline-flex;
}

.grammar-match-active .grammar-exercise-actions {
    padding-top: 0;
}

.grammar-match-board {
    position: relative;
    display: flex;
    gap: 60px;
    padding: 4px 4px 6px;
    /* Block the page from claiming touches inside the board as a
       vertical pan. The rows themselves have touch-action:none, but
       touches that LAND in the inter-row / inter-column gaps would
       otherwise commit to a pan before the user's finger reaches
       the row — visible on mobile as "drag from the dot scrolls
       the page instead of starting the connector line." */
    touch-action: none;
    /* Board sizes to its tallest column instead of filling the panel.
       The prior behavior — `flex: 1 1 0; min-height: 0` plus
       `overflow: hidden` — capped the board at the panel height and
       clipped the first/last rows whenever total content (rows + gaps)
       exceeded that cap (typical case: 8 pairs of 2-line Japanese
       sentences on a ~700px viewport). Sizing to content guarantees
       every row is visible; if the resulting board pushes the panel
       past its frame, the panel's own `overflow-y:auto` takes over. */
    flex: 0 0 auto;
    /* SVG live-line still respects the board bounds because it's
       absolutely positioned `inset:0` inside this same box. */
    overflow: hidden;
}

.grammar-match-col {
    flex: 1 1 0;
    display: flex;
    flex-direction: column;
    /* Center the (now content-sized) rows in the board so short
       sets don't top-load the column. */
    justify-content: center;
    gap: 6px;
    min-width: 0;
    min-height: 0;
}

/* Rows size to their content rather than stretching to fill the
   column. With `flex: 1 1 0` (the prior behavior), 8 rows in a
   65vh board got ~80px each — and full-pill caps made them read as
   oversized tablets. Now padding + content drive the height, and
   the 10px radius gives a quiet rounded card instead of a pill. */
.grammar-match-row {
    position: relative;
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    /* Recessed well: row sits in the page-tone (slightly darker than
       the canvas card behind it) so the pill stands out against the
       cream panel. Inset shadow at the top edge sells the "sunk into
       the page" depth without adding a heavy border. */
    background: var(--bg-outer);
    box-shadow: inset 0 1px 2px rgba(31, 26, 26, 0.08);
    border: 1px dashed var(--subtle-border);
    border-radius: 10px;
    padding: 8px 14px;
    font-family: 'Noto Sans JP', sans-serif;
    font-size: 1rem;
    color: var(--text);
    /* Whole bubble is the touch target — crosshair makes the affordance
       obvious. touch-action:none stops the page from scrolling while
       the user drags within a row. */
    cursor: crosshair;
    touch-action: none;
    transition: border-color 0.15s, background 0.15s, opacity 0.18s;
}

@media (hover: hover) {
    .grammar-match-row:not(.locked):hover {
        border-color: var(--accent);
    }
}

.grammar-match-row.locked { cursor: default; }

.grammar-match-col-left .grammar-match-row {
    padding-right: 22px;
    justify-content: flex-start;
}
.grammar-match-col-right .grammar-match-row {
    padding-left: 22px;
    justify-content: flex-end;
    text-align: right;
}

/* Furigana annotation inside a match row — small kana reading sitting
   above the kanji. Mirrors the .grammar-token ruby treatment. */
.grammar-match-row ruby rt {
    font-size: 0.55em;
    color: var(--text-faint);
    font-weight: normal;
    line-height: 1;
}

/* Connector dot. Each row has exactly one dot; left rows place it on
   the right edge, right rows on the left edge. The dot is the
   pointer target, not the row, so the user has a clear "handle". */
.grammar-match-dot {
    position: absolute;
    top: 50%;
    width: 16px;
    height: 16px;
    border: 1.5px solid var(--subtle-border);
    border-radius: 50%;
    background: var(--bg);
    transform: translateY(-50%);
    cursor: crosshair;
    transition: border-color 0.15s, background 0.15s, transform 0.1s;
    touch-action: none;
}
.grammar-match-col-left .grammar-match-dot { right: -8px; }
.grammar-match-col-right .grammar-match-dot { left: -8px; }

@media (hover: hover) {
    .grammar-match-dot:hover {
        border-color: var(--accent);
        background: var(--accent-bg);
    }
}

/* Visual state when the user is actively dragging from a dot. */
.grammar-match-dot.active {
    border-color: var(--accent);
    background: var(--accent);
}

/* Once a row is correctly paired, lock it: solid accent border
   (1.5px to read as more permanent than the dashed draft state),
   accent-tinted fill, no pointer events. */
.grammar-match-row.locked {
    border: 1.5px solid var(--accent);
    background: var(--accent-bg);
    box-shadow: none;
    cursor: default;
}
.grammar-match-row.locked .grammar-match-dot {
    border-color: var(--accent);
    background: var(--accent);
    pointer-events: none;
    cursor: default;
}

/* Wrong-pair flash — short red glow on the two rows the user tried
   to connect. Mirrors the grammar-build-area .shake animation. */
.grammar-match-row.wrong {
    animation: grammarShake 0.4s ease-in-out;
    border-color: var(--danger, #d23);
}

.grammar-match-svg {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    pointer-events: none;
}

/* In-flight stroke the user is drawing — accent dashed so it reads
   as "intent, not committed." */
.grammar-match-svg .live-line {
    fill: none;
    stroke: var(--accent);
    stroke-width: 2.5;
    stroke-linecap: round;
    stroke-dasharray: 6 5;
    opacity: 0.85;
}

/* Locked connector — solid accent line; persists once a correct
   pair is established. */
.grammar-match-svg .locked-line {
    fill: none;
    stroke: var(--accent);
    stroke-width: 2.5;
    stroke-linecap: round;
}

@media (max-width: 600px) {
    /* Tighter col gap so each pill column gets more width — Japanese
       sentences then wrap less often, which is the main driver of
       row height (each extra line ~16px). */
    .grammar-match-board {
        gap: 20px;
        padding: 2px 0 4px;
    }
    .grammar-match-col {
        gap: 4px;
    }
    /* Compact pill: smaller text, tighter padding, tighter line-height.
       8 pairs at this size land at ~330px of board content — well
       within the 65vh column floor on a typical phone (≥500px). */
    .grammar-match-row {
        font-size: 0.8rem;
        padding: 4px 10px;
        line-height: 1.25;
        border-radius: 8px;
    }
    .grammar-match-col-left .grammar-match-row { padding-right: 16px; }
    .grammar-match-col-right .grammar-match-row { padding-left: 16px; }
}

/* ── Kanji-Match exercise (sliding queue) ───────────────────
   Same line-drawing shell as Match but only the TOP left row is
   active. Other 7 left rows are queue preview (dimmed, not
   interactive). Right column shows 8 readings — 1 correct +
   7 distractors — and refreshes after every answer. ON-reading
   rows get a tinted border (uses --accent so red light /
   blue dark matches the ON label in kanji-details). */
.grammar-kanji-match-active {
    display: flex;
    flex-direction: column;
    gap: 10px;
    flex: 1 1 0;
    min-height: 0;
    padding-top: 2px;
}

.grammar-kanji-match-active .grammar-exercise-prompt {
    font-size: 0.9rem;
    font-weight: 500;
    color: var(--text-muted);
    padding: 0 8px;
    margin: 0;
    /* Only opacity transitions — the layout space stays so the board
       doesn't shift / resize when the prompt fades. The empty space
       just becomes invisible padding above the columns. */
    transition: opacity 0.4s ease;
}
.grammar-kanji-match-active .grammar-exercise-prompt.faded {
    opacity: 0;
}

.grammar-kanji-match-active .grammar-exercise-actions {
    padding-top: 0;
}

.grammar-kmatch-board {
    position: relative;
    display: flex;
    gap: 120px;
    padding: 4px 4px 6px;
    /* See .grammar-match-board — same fix for the kanji-match drag UX
       on mobile: kill page-pan inside the board so dragging from a
       dot never gets stolen by browser scroll. */
    touch-action: none;
    /* Sizes to its tallest column, same as .grammar-match-board. The
       prior `flex: 1 1 0` cap + the row max-height calc was clipping
       the first/last rows on narrow viewports (65vh - header - actions
       < what 8 rows need at the 44px min-height). Centering the board
       in the available space (justify-content on cols below) keeps the
       sliding queue feeling anchored when the panel has spare room. */
    flex: 0 0 auto;
    /* Live-line and sliding-row exit animation both respect this clip. */
    overflow: hidden;
}

.grammar-kmatch-col {
    flex: 1 1 0;
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: 6px;
    min-width: 0;
    min-height: 0;
}

.grammar-kmatch-row {
    position: relative;
    flex: 0 0 auto;
    min-height: 44px;
    display: flex;
    align-items: center;
    /* Recessed well — see .grammar-match-row above. Same treatment
       so both Match exercises share the same chip language. */
    background: var(--bg-outer);
    box-shadow: inset 0 1px 2px rgba(31, 26, 26, 0.08);
    border: 1px dashed var(--subtle-border);
    border-radius: 10px;
    padding: 10px 18px;
    font-family: 'Noto Sans JP', sans-serif;
    font-size: 1.1rem;
    color: var(--text);
    cursor: crosshair;
    touch-action: none;
    transition: border-color 0.15s, background 0.15s,
        opacity 0.18s, transform 0.32s ease-in;
}

/* Queue preview rows are dimmed and non-interactive — only the
   top row answers. */
.grammar-kmatch-row.preview {
    opacity: 0.45;
    cursor: default;
}

@media (hover: hover) {
    .grammar-kmatch-row.active:hover,
    .grammar-kmatch-col-right .grammar-kmatch-row:hover {
        border-color: var(--accent);
    }
}

/* Left column holds a single kanji per row — keep it narrow so the
   bubbles aren't balloon-shaped, and so the right column has room for
   multi-syllable readings like ちい.さい / おお.きい. */
.grammar-kmatch-col-left {
    flex: 0 0 110px;
}
.grammar-kmatch-col-left .grammar-kmatch-row {
    padding: 8px 16px 8px 14px;
    justify-content: center;
    font-size: 1.35rem;
}
.grammar-kmatch-col-right .grammar-kmatch-row {
    padding-left: 22px;
    justify-content: flex-end;
    text-align: right;
}

.grammar-kmatch-dot {
    position: absolute;
    top: 50%;
    width: 16px;
    height: 16px;
    /* Matches the .grammar-match-dot weight — keep small handles
       crisp at 1.5px solid; dashed reads as fuzz at this size. */
    border: 1.5px solid var(--subtle-border);
    border-radius: 50%;
    background: var(--bg);
    box-shadow: 0 0 0 2px var(--bg);
    transform: translateY(-50%);
    cursor: crosshair;
    transition: border-color 0.15s, background 0.15s, transform 0.1s,
        box-shadow 0.15s;
    touch-action: none;
}
.grammar-kmatch-col-left .grammar-kmatch-dot { right: -9px; }
.grammar-kmatch-col-right .grammar-kmatch-dot { left: -9px; }

.grammar-kmatch-row.preview .grammar-kmatch-dot {
    pointer-events: none;
}

@media (hover: hover) {
    .grammar-kmatch-dot:hover {
        border-color: var(--accent);
        background: var(--accent);
    }
}

.grammar-kmatch-dot.active {
    border-color: var(--accent);
    background: var(--accent);
    transform: translateY(-50%) scale(1.2);
}

/* Correct / wrong flash — used briefly on the top + matched right rows
   before the exit animation runs. Green for correct, red for wrong —
   matches universal convention (don't use the red brand accent for
   correctness, since users read red as "error" regardless of context). */
/* State flashes (correct/wrong/show-correct/hinted) override the
   recessed well — flat fill reads as a deliberate state change. */
.grammar-kmatch-row.correct,
.grammar-kmatch-row.wrong,
.grammar-kmatch-row.show-correct,
.grammar-kmatch-row.hinted {
    box-shadow: none;
}
.grammar-kmatch-row.correct {
    border: 1.5px solid var(--green);
    background: var(--green-bg);
}
.grammar-kmatch-row.correct .grammar-kmatch-dot {
    border-color: var(--green);
    background: var(--green);
}
.grammar-kmatch-row.wrong {
    border: 1.5px solid var(--red, #d23);
    background: var(--red-bg, color-mix(in srgb, var(--red, #d23) 12%, var(--bg)));
}
.grammar-kmatch-row.wrong .grammar-kmatch-dot {
    border-color: var(--red);
    background: var(--red);
}

/* Wrong-answer reveal — when the user picks an incorrect reading the
   right column's actual correct row flashes green for REVEAL_MS so the
   wrong guess still teaches the answer. */
.grammar-kmatch-row.show-correct {
    border: 1.5px solid var(--green);
    background: var(--green-bg);
}
.grammar-kmatch-row.show-correct .grammar-kmatch-dot {
    border-color: var(--green);
    background: var(--green);
}

/* ── Hint / peek ────────────────────────────────────────────────────
   Tap an active left kanji to reveal its reading in place. The reading
   span is absolute-positioned over the kanji and faded in via .hinted;
   the kanji glyph fades out. Peeking counts as a miss — the row keeps
   a .was-hinted marker (small accent dot) for the rest of the round. */
.grammar-kmatch-row-text {
    transition: opacity 0.18s ease, transform 0.18s ease;
}
.grammar-kmatch-hint-text {
    position: absolute;
    inset: 0;
    display: grid;
    place-items: center;
    padding: 4px 6px;
    font-family: 'Noto Sans JP', sans-serif;
    font-size: 0.95rem;
    line-height: 1.12;
    text-align: center;
    color: var(--accent);
    opacity: 0;
    transform: scale(0.9);
    transition: opacity 0.18s ease, transform 0.18s ease;
    pointer-events: none;
}
.grammar-kmatch-row.hinted {
    border: 1.5px solid var(--accent);
    background: var(--accent-bg);
}
.grammar-kmatch-row.hinted .grammar-kmatch-row-text {
    opacity: 0;
    transform: scale(0.9);
}
.grammar-kmatch-row.hinted .grammar-kmatch-hint-text {
    opacity: 1;
    transform: scale(1);
}
/* Persistent peeked-marker. Small accent dot in the top-RIGHT
   corner — was top-left until the ON/KUN badge took that corner.
   Visible even after the row exits and re-enters because the engine
   re-applies .was-hinted via _hintMissed.has(pairIdx). */
.grammar-kmatch-col-left .grammar-kmatch-row.was-hinted::after {
    content: "";
    position: absolute;
    top: 6px;
    right: 8px;
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: var(--accent);
    opacity: 0.7;
}

/* Reading-type badge — small uppercase tag in the top-left corner
   of the left-column row. Tells the user whether this turn asks for
   the ON, KUN, or irregular reading. Muted by default so the kanji
   glyph remains the focal element. */
.grammar-kmatch-row-badge {
    position: absolute;
    top: 6px;
    left: 9px;
    font-size: 0.55rem;
    font-weight: 700;
    letter-spacing: 0.08em;
    color: var(--text-faint);
    text-transform: uppercase;
    pointer-events: none;
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.grammar-kmatch-row-badge[data-type="irregular"] {
    color: var(--accent);
    font-size: 0.7rem;
}

/* Feedback line — picks up muted text when the engine writes the
   "Peeked — counts as a miss" hint message. */
.grammar-exercise-feedback.hint {
    color: var(--text-muted);
    font-style: italic;
}

/* Exit animation — the matched row slides upward off the board top.
   In Any-Kanji mode the matched row may be anywhere from slot 0 to 7,
   so the translate distance has to clear the full board height. The
   board's overflow:hidden clips whatever spills above. */
.grammar-kmatch-row.exiting {
    transform: translateY(-500px);
    opacity: 0;
    pointer-events: none;
}

/* Refresh transition — every turn snap-replaces both columns'
   children. Same animation on both sides so the columns land together
   visually instead of left-then-right. Respects reduced-motion. */
@media (prefers-reduced-motion: no-preference) {
    .grammar-kmatch-col-left .grammar-kmatch-row,
    .grammar-kmatch-col-right .grammar-kmatch-row {
        animation: grammar-kmatch-enter 0.26s ease both;
    }
}
@keyframes grammar-kmatch-enter {
    from { opacity: 0.35; }
    to   { opacity: 1; }
}

.grammar-kmatch-svg {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    pointer-events: none;
}

.grammar-kmatch-svg .live-line {
    fill: none;
    stroke: var(--accent);
    stroke-width: 2.5;
    stroke-linecap: round;
    stroke-dasharray: 6 5;
    opacity: 0.85;
}

@media (max-width: 600px) {
    /* Tighter col gap, tighter row padding, smaller fonts — same
       compaction template as the Match exercise so 8 kmatch rows
       fit on a typical phone (8 × ~38px + 7 × 4px gap ≈ 332px
       of board content). */
    .grammar-kmatch-board {
        gap: 40px;
        padding: 2px 0 4px;
    }
    .grammar-kmatch-col {
        gap: 4px;
    }
    .grammar-kmatch-row {
        font-size: 0.85rem;
        padding: 4px 10px;
        line-height: 1.25;
        min-height: 36px;
        border-radius: 8px;
    }
    .grammar-kmatch-col-left { flex: 0 0 68px; }
    .grammar-kmatch-col-left .grammar-kmatch-row {
        padding: 4px 10px;
        font-size: 1.1rem;
    }
    .grammar-kmatch-col-right .grammar-kmatch-row { padding-left: 16px; }
    /* Long English glosses ("Mr. / Ms. (honorific suffix attached to
       names)") spill out of the narrow mobile bubbles. Clamp the
       right-column text to two lines with an ellipsis so the row
       stays inside its capped height. Webkit's line-clamp is now
       supported across all modern browsers. */
    .grammar-kmatch-col-right .grammar-kmatch-row-text {
        display: -webkit-box;
        -webkit-box-orient: vertical;
        -webkit-line-clamp: 2;
        line-clamp: 2;
        overflow: hidden;
        text-overflow: ellipsis;
        word-break: break-word;
    }
}

/* ── Vocabulary Match — exercise overlay ──────────────────────────────
   Mounted into .card-area-stage by ExerciseHost.enter(), same pattern
   as Rain / Fall / Prefecture. position:absolute + inset:0 inherits
   the stage's bounds so the drill anchors to the three-column area
   instead of the full viewport. Cream background + dashed outline
   match the other exercise games. */
.vocab-match-game {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.4s ease;
    z-index: 2;
    background: var(--bg);
    border: var(--dash);
    border-radius: 14px;
    padding: 14px;
    box-sizing: border-box;
}
.vocab-match-game.active {
    opacity: 1;
    pointer-events: auto;
}

/* ── Kanji Memory / Kanji Reading — exercise overlays ─────────────────
   Same pattern as .vocab-match-game above: position:absolute + inset:0
   anchors the mounted partial inside .card-area-stage instead of
   spreading across the full viewport. Without these rules the engines'
   3-column grid uses the body width and the cards balloon. */
.kanji-memory-game,
.kanji-reading-game {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.4s ease;
    z-index: 2;
    background: var(--bg);
    border: var(--dash);
    border-radius: 14px;
    padding: 14px;
    box-sizing: border-box;
    overflow: auto;
}
.kanji-memory-game.active,
.kanji-reading-game.active {
    opacity: 1;
    pointer-events: auto;
}

/* Kanji Memory inside the overlay scales to fit BOTH the available
   width and height — no scrolling at any breakpoint. We can't use the
   grammar-mode "3 × 1fr columns + per-card aspect-ratio 3:4" because
   that picks card height from column width alone, so a wide desktop
   stage balloons rows past the available height (and a tall mobile
   stage isn't the binding axis). Instead: switch the panel to grid so
   the board sits in a definite 1fr row, then make the board itself an
   aspect-ratio: 9/8 box (3 cards × 3:4 each, 2 rows) bounded by both
   max-width and max-height. The browser picks the smaller dimension,
   and the resulting box is centered in whatever space is left. */
.kanji-memory-game .grammar-kanji-memory-active {
    display: grid;
    grid-template-rows: auto auto 1fr auto;
    flex: 1 1 0;
    min-height: 0;
    gap: 14px;
    /* Container query target — the @container rules below switch the
       binding dimension based on the panel's current aspect ratio. */
    container-type: size;
    container-name: kmemstage;
}
/* Tall/portrait stage (typical mobile): bind width, derive height. */
@container kmemstage (aspect-ratio < 9/8) {
    .kanji-memory-game .grammar-kmem-board {
        width: 100%;
        height: auto;
        aspect-ratio: 9 / 8;
        max-height: 100%;
        margin: auto 0;
        grid-template-columns: repeat(3, 1fr);
        grid-template-rows: repeat(2, 1fr);
    }
}
/* Wide/landscape stage (typical desktop): bind height, derive width. */
@container kmemstage (aspect-ratio >= 9/8) {
    .kanji-memory-game .grammar-kmem-board {
        width: auto;
        height: 100%;
        aspect-ratio: 9 / 8;
        max-width: 100%;
        margin: 0 auto;
        grid-template-columns: repeat(3, 1fr);
        grid-template-rows: repeat(2, 1fr);
    }
}
.kanji-memory-game .grammar-kmem-card {
    /* Cells are already 3:4 from the board's 9:8 / 3-col / 2-row split,
       so override the grammar-mode per-card aspect-ratio (would fight
       the cell shape) and just fill the cell. */
    aspect-ratio: auto;
    width: 100%;
    height: 100%;
}

/* Vocabulary Match — deck picker modal */
.vocab-deck-picker {
    position: fixed;
    inset: 0;
    z-index: 1600;
    display: flex;
    align-items: center;
    justify-content: center;
}
.vocab-deck-picker[hidden] { display: none; }
.vocab-deck-picker-backdrop {
    position: absolute;
    inset: 0;
    background: rgba(0, 0, 0, 0.42);
    backdrop-filter: blur(2px);
}
.vocab-deck-picker-panel {
    position: relative;
    width: min(480px, calc(100vw - 32px));
    max-height: calc(100vh - 64px);
    background: var(--bg);
    border: 1.5px solid var(--card-outline);
    border-radius: 14px;
    padding: 20px;
    display: flex;
    flex-direction: column;
    gap: 12px;
    box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
}
.vocab-deck-picker-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
}
.vocab-deck-picker-header h2 {
    margin: 0;
    font-size: 1.1rem;
    color: var(--heading);
    font-weight: 600;
}
.vocab-deck-picker-close {
    background: transparent;
    border: none;
    color: var(--text-muted);
    font-size: 1.6rem;
    line-height: 1;
    cursor: pointer;
    padding: 4px 8px;
}
.vocab-deck-picker-close:hover { color: var(--accent); }
.vocab-deck-picker-list {
    display: flex;
    flex-direction: column;
    gap: 6px;
    overflow-y: auto;
    max-height: 50vh;
}
.vocab-deck-picker-item {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 12px 14px;
    background: var(--bg);
    border: 1.5px solid var(--subtle-border);
    border-radius: 10px;
    cursor: pointer;
    text-align: left;
    font-family: inherit;
    color: var(--text);
    transition: border-color 0.15s, background 0.15s;
}
.vocab-deck-picker-item:hover {
    border-color: var(--accent);
    background: var(--accent-bg);
}
.vocab-deck-picker-item:disabled {
    opacity: 0.5;
    cursor: not-allowed;
}
.vocab-deck-picker-item-name {
    font-weight: 600;
    font-size: 0.98rem;
}
.vocab-deck-picker-item-count {
    color: var(--text-muted);
    font-size: 0.85rem;
}
.vocab-deck-picker-empty {
    color: var(--text-faint);
    font-style: italic;
    text-align: center;
    padding: 24px 8px;
}

.grammar-exercise-empty {
    margin: auto;
    color: var(--text-faint);
    font-size: 0.95rem;
    font-style: italic;
    text-align: center;
    padding: 24px;
}

/* ── Exercise header ─────────────────────────────────────── */
.grammar-exercise-header {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: 10px;
    text-align: center;
}

/* Top utility row: back button hugs the left, progress tally
   ("1 / 13") hugs the right, both on the same baseline. */
.grammar-exercise-header-top {
    display: flex;
    align-items: center;
    justify-content: space-between;
    width: 100%;
}

.grammar-exercise-progress {
    font-size: 0.75rem;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--text-faint);
    font-weight: 700;
}

.grammar-exercise-prompt {
    font-size: 1rem;
    line-height: 1.4;
    color: var(--text);
    font-weight: 500;
    padding: 4px 8px;
}

/* ── Build area ─────────────────────────────────────────── */
.grammar-build-area {
    min-height: 84px;
    border: 1.5px dashed var(--subtle-border);
    border-radius: 10px;
    padding: 14px;
    display: flex;
    flex-wrap: wrap;
    gap: 10px;
    align-items: center;
    justify-content: center;
    /* Recessed well — same treatment as tokens and match pills. */
    background: var(--bg-outer);
    box-shadow: inset 0 1px 2px rgba(31, 26, 26, 0.08);
    transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
}

.grammar-build-area.empty::before {
    content: '\200B'; /* zero-width space — preserves min-height */
}

.grammar-build-area.shake {
    animation: grammarShake 0.4s ease-in-out;
    border-color: var(--danger, #d23);
}

@keyframes grammarShake {
    0%, 100% { transform: translateX(0); }
    20% { transform: translateX(-6px); }
    40% { transform: translateX(6px); }
    60% { transform: translateX(-4px); }
    80% { transform: translateX(4px); }
}

.grammar-build-area.correct {
    border-color: var(--accent);
    border-style: solid;
    background: var(--accent-bg);
    box-shadow: none;
}

/* ── Grammar-only options-dropdown sections ──────────────
   Live inside the shared #pen-dropdown-panel. Hidden unless the
   user is actually in grammar mode so the dropdown isn't littered
   with controls that wouldn't do anything in Trace/Vocab/Kanji.
   `!important` guards against any sibling-rule display win from the
   parent .col-options-panel.open flex layout. */
body:not(.grammar-active) .grammar-only-section { display: none !important; }

/* The left-column options dropdown only ships kana-specific items
   (Audio on click, Highlight flagged, 3-column layout) — none apply
   to the Grammar Lessons list. Hide the trigger entirely in grammar
   mode until grammar grows its own column-level settings. The center
   column's pen-dropdown stays because grammar's Write modality still
   uses the writing canvas. */
body.grammar-active #col-options-dropdown { display: none !important; }

/* Center-column pen-dropdown carries the Display (Kanji/Hiragana/Both)
   toggle that every furigana-eligible engine respects, plus a few
   Sentence Reconstruction-specific knobs (Input, Stroke Color,
   Tolerance, Particles). The trigger is shown whenever an exercise
   that supports Display is running. */

/* Hide the trigger on lesson home and for engines that don't honor
   the Display toggle: Cryptex (no per-token labels to flip) and Kanji
   Memory (faces are always single-kanji / readings, fixed by design).
   body.grammar-set-<type> is set by _syncExerciseSetUI. */
body.grammar-active:not(.grammar-exercise-running) #pen-dropdown,
body.grammar-active.grammar-set-cryptex #pen-dropdown,
body.grammar-active.grammar-set-kanji_memory #pen-dropdown {
    display: none !important;
}

/* Inside the dropdown, the Write-mode sections (Input toggle + the
   pen settings) only make sense when Sentence Reconstruction is the
   active set. Display stays visible across all furigana exercises. */
body.grammar-active:not(.grammar-set-sentence) .grammar-sentence-only-section {
    display: none !important;
}

/* Kanji Reading: the Display (Kanji/Hiragana/Both) toggle doesn't apply
   — the left column is always a single kanji glyph by design. Hide it
   and show only the Answer Mode toggle. Vocabulary keeps Display
   visible because vocab words have meaningful kanji + kana forms. */
body.grammar-active.grammar-set-kanji_match .grammar-non-kmatch-section {
    display: none !important;
}
body.grammar-active:not(.grammar-set-kanji_match):not(.grammar-set-vocab_match) .grammar-kmatch-only-section {
    display: none !important;
}

/* Vocabulary drill: left column shows multi-character words (食べ物,
   インドネシア人, 高校), so the kanji-only sizing is too tight.
   Widen the column, shrink the font, and right-side text too. Applies
   to both the grammar-lesson Vocabulary set AND the standalone Vocab
   Match exercise that runs out of the vocab-mode Exercises dropdown. */
body.grammar-set-vocab_match .grammar-kmatch-col-left,
body.exercise-vocab-match .grammar-kmatch-col-left {
    flex: 0 0 170px;
}
body.grammar-set-vocab_match .grammar-kmatch-col-left .grammar-kmatch-row,
body.exercise-vocab-match .grammar-kmatch-col-left .grammar-kmatch-row {
    font-size: 1.05rem;
    padding: 8px 14px;
}
body.grammar-set-vocab_match .grammar-kmatch-col-right .grammar-kmatch-row,
body.exercise-vocab-match .grammar-kmatch-col-right .grammar-kmatch-row {
    font-size: 0.9rem;
}

/* Show stroke guides toggles the kana-mode resting-state guide animation;
   grammar Write mode doesn't use that pipeline at all, so the entire
   section (Show stroke guides + the always-hidden Haptic stub) is hidden
   in grammar mode along with the divider above it. */
body.grammar-active #stroke-guides-divider,
body.grammar-active #stroke-guides-section {
    display: none !important;
}

/* Per-character freehand score chip, positioned the same way the
   kana .trace-score chip is. Fades in on character completion and
   out a moment later — JS toggles opacity inline. */
.grammar-hw-score {
    position: absolute;
    top: 8px;
    right: 12px;
    font-size: 0.85rem;
    font-weight: 700;
    letter-spacing: 0.03em;
    pointer-events: none;
    opacity: 0;
    transition: opacity 0.3s;
    z-index: 10;
    text-shadow: 0 1px 3px rgba(0,0,0,0.15);
}

/* ── Tap / Write input-mode toggle ────────────────────── */
.grammar-input-toggle,
.grammar-display-toggle {
    display: flex;
    width: 100%;
    border: 1.5px solid var(--subtle-border);
    border-radius: 8px;
    overflow: hidden;
    background: var(--bg);
}

.grammar-input-toggle > button,
.grammar-display-toggle > button {
    flex: 1 1 0;
}

.grammar-input-btn,
.grammar-display-btn {
    border: 0;
    background: transparent;
    color: var(--text-muted);
    font-size: 0.82rem;
    font-weight: 600;
    padding: 6px 14px;
    cursor: pointer;
    transition: background 0.12s, color 0.12s;
}

.grammar-input-btn.active,
.grammar-display-btn.active {
    background: var(--accent);
    color: #fff;
}

@media (hover: hover) {
    .grammar-input-btn:not(.active):hover,
    .grammar-display-btn:not(.active):hover {
        background: var(--accent-bg);
        color: var(--accent);
    }
}

/* Furigana-style ruby rendering for "Both" display mode — kanji on
   top, kana reading on a smaller subscript line. */
.grammar-token ruby rt {
    font-size: 0.55em;
    color: var(--text-faint);
    font-weight: normal;
    line-height: 1;
}

/* Tap / Write column swap — when Write is active, hide the
   tap pool and show the handwriting canvas in its place. */
.grammar-exercise-active.write-mode .grammar-token-pool { display: none; }
.grammar-exercise-active.write-mode .grammar-hw-wrap { display: flex; }

/* ── Handwriting canvas (Write mode) ─────────────────── */
.grammar-hw-wrap {
    display: none;
    flex-direction: column;
    align-items: center;
    gap: 10px;
}

.grammar-hw-frame {
    width: 280px;
    height: 280px;
    background: var(--panel-bg, var(--bg));
    border: 1.5px solid var(--subtle-border);
    border-radius: 8px;
    position: relative;
    overflow: hidden;
}

.grammar-hw-cross {
    position: absolute;
    inset: 0;
    pointer-events: none;
}

.grammar-hw-cross-v,
.grammar-hw-cross-h {
    position: absolute;
    background-image: linear-gradient(to bottom, var(--text-faint) 50%, transparent 50%);
    background-size: 1px 8px;
    opacity: 0.35;
}

.grammar-hw-cross-v {
    top: 0;
    bottom: 0;
    left: 50%;
    width: 1px;
}

.grammar-hw-cross-h {
    top: 50%;
    left: 0;
    right: 0;
    height: 1px;
    background-image: linear-gradient(to right, var(--text-faint) 50%, transparent 50%);
    background-size: 8px 1px;
}

.grammar-hw-canvas {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    touch-action: none;
    cursor: crosshair;
}

.grammar-hw-frame.miss-flash {
    animation: grammarHwShake 0.4s ease-out;
    border-color: var(--danger, #d23);
}

@keyframes grammarHwShake {
    0% { transform: translateX(0); }
    20% { transform: translateX(-5px); }
    40% { transform: translateX(4px); }
    60% { transform: translateX(-3px); }
    80% { transform: translateX(2px); }
    100% { transform: translateX(0); }
}

/* ── Token pool ─────────────────────────────────────────── */
.grammar-token-pool {
    display: flex;
    flex-wrap: wrap;
    gap: 10px;
    align-items: center;
    justify-content: center;
    padding: 6px 4px;
}

/* ── Tokens (used in both pool and build area) ──────────── */
.grammar-token {
    font-family: 'Noto Sans JP', sans-serif;
    font-size: 1rem;
    font-weight: 500;
    color: var(--text);
    /* Recessed well — same treatment as .grammar-match-row /
       .grammar-kmatch-row so the chip language reads consistently. */
    background: var(--bg-outer);
    border: 1px dashed var(--subtle-border);
    border-radius: 8px;
    padding: 8px 14px;
    cursor: pointer;
    transition: background 0.12s, border-color 0.12s, transform 0.08s;
    user-select: none;
    box-shadow: inset 0 1px 2px rgba(31, 26, 26, 0.08);
}

@media (hover: hover) {
    .grammar-token:hover {
        background: var(--accent-bg);
        border-color: var(--accent);
    }
}

.grammar-token:active {
    transform: translateY(1px);
}

.grammar-token.used {
    visibility: hidden;
}

/* ── Feedback line ──────────────────────────────────────── */
.grammar-exercise-feedback {
    min-height: 1.4em;
    text-align: center;
    font-size: 0.95rem;
    font-weight: 500;
}

.grammar-exercise-feedback.correct { color: var(--accent); }
.grammar-exercise-feedback.wrong { color: var(--danger, #d23); }

/* ── Lesson home (exercise-set picker) ──────────────────── */
.grammar-lesson-home {
    display: flex;
    flex-direction: column;
    gap: 12px;
    padding: 12px 4px;
}

/* Quiet eyebrow — matches the column titles ("GRAMMAR LESSONS",
   "GRAMMAR EXERCISE", "CHARACTER DETAILS") so the picker reads as
   a labeled section rather than a competing heading. */
.grammar-lesson-home-title {
    font-size: 0.72rem;
    font-weight: 600;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: var(--text-faint);
    text-align: center;
}

.grammar-lesson-home-sub {
    font-size: 0.85rem;
    color: var(--text-faint);
    text-align: center;
    margin-bottom: 6px;
}

.grammar-set-cards {
    display: flex;
    flex-direction: column;
    gap: 10px;
}

.grammar-set-card {
    text-align: left;
    padding: 14px 16px;
    border-radius: 10px;
    /* Dashed subtle border matches the rest of the site's "row"
       language (toolbar buttons, exercise dropdowns, recall nudge).
       Solid borders made the picker read as a third-party widget. */
    border: 1px dashed var(--subtle-border);
    background: var(--bg);
    color: var(--text);
    cursor: pointer;
    transition: background 0.12s, border-color 0.12s, transform 0.06s;
    display: flex;
    flex-direction: column;
    gap: 4px;
}

.grammar-set-card-title {
    font-size: 0.9rem;
    font-weight: 500;
}

.grammar-set-card-desc {
    font-size: 0.85rem;
    color: var(--text-muted);
    line-height: 1.35;
}

.grammar-set-card-footer {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    margin-top: 2px;
}

.grammar-set-card-count {
    font-size: 0.72rem;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--text-faint);
    font-weight: 700;
}

/* Resume indicator — dashed-accent pill so the user can see at a
   glance which sets they're partway through, matching the rest of
   the action language. */
.grammar-set-card-progress {
    font-size: 0.7rem;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--accent);
    font-weight: 700;
    padding: 2px 8px;
    border: 1.5px dashed var(--accent);
    border-radius: 999px;
    background: var(--bg);
}

.grammar-set-card-right {
    display: flex;
    align-items: center;
    gap: 8px;
}

/* Reset — quiet, subtle until hovered. Clears the set's resume
   point. Sized to match the progress pill so they read as a unit. */
.grammar-set-card-reset {
    font-size: 0.7rem;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--text-muted);
    font-weight: 700;
    padding: 2px 8px;
    border: 1.5px solid var(--subtle-border);
    border-radius: 999px;
    background: transparent;
    cursor: pointer;
    transition: color 0.12s, border-color 0.12s, background 0.12s;
}

@media (hover: hover) {
    .grammar-set-card-reset:hover {
        color: var(--accent);
        border-color: var(--accent);
        background: var(--accent-bg);
    }
}

@media (hover: hover) {
    .grammar-set-card:hover {
        background: var(--accent-bg);
        border-color: var(--accent);
    }
}

.grammar-set-card:active {
    transform: translateY(1px);
}

/* ── Mobile sizing for the exercise-set picker ─────────────
   Mirrors the desktop layout — eyebrow title, subtitle, full
   card content (title + description + footer count) — just at
   slightly tighter paddings and gaps. The light-typography
   reset (eyebrow title, dashed cards, 0.85rem muted desc,
   0.72rem faint count) keeps each card around 75-85px tall,
   so four still fit comfortably in the picker column. */
@media (max-width: 900px) {
    .grammar-lesson-home {
        gap: 8px;
        padding: 6px 4px;
    }
    .grammar-set-cards {
        gap: 6px;
    }
    .grammar-set-card {
        padding: 10px 12px;
        gap: 2px;
    }
}

/* ── Back-to-sets button inside the active exercise ──── */
.grammar-back-to-sets-btn {
    background: transparent;
    border: 0;
    color: var(--text-muted);
    font-size: 0.85rem;
    font-weight: 600;
    cursor: pointer;
    padding: 2px 4px;
}

@media (hover: hover) {
    .grammar-back-to-sets-btn:hover {
        color: var(--accent);
    }
}

/* ── Action buttons ─────────────────────────────────────── */
.grammar-exercise-actions {
    display: flex;
    justify-content: center;
    gap: 12px;
    margin-top: auto;
    padding-top: 8px;
}

/* Action buttons (Check / Next / Restart / Back to Lesson) — match
   the dashed-accent language of the rest of the site (see .recall-nudge,
   .info-panel-top). Outline, not fill; uppercase eyebrow weight. */
.grammar-action-btn {
    font-size: 0.78rem;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    padding: 10px 22px;
    border-radius: 8px;
    border: 1.5px dashed var(--accent);
    background: var(--bg);
    color: var(--accent);
    cursor: pointer;
    transition: background 0.12s, opacity 0.12s, color 0.12s;
}

.grammar-action-btn:disabled {
    opacity: 0.45;
    cursor: default;
    border-color: var(--subtle-border);
    color: var(--text-faint);
}

@media (hover: hover) {
    .grammar-action-btn:not(:disabled):hover {
        background: var(--accent-bg);
    }
}

/* Quiet variant — Hint is a supporting action; outline in neutral
   subtle-border / muted text so it sits visually below Check / Next.
   Picks up the accent color only on hover so the affordance is clear. */
.grammar-action-btn-quiet {
    border-color: var(--subtle-border);
    color: var(--text-muted);
}
@media (hover: hover) {
    .grammar-action-btn-quiet:not(:disabled):hover {
        border-color: var(--accent);
        color: var(--accent);
        background: var(--accent-bg);
    }
}

/* ── Completion screen ──────────────────────────────────── */
.grammar-exercise-complete {
    margin: auto;
    text-align: center;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 14px;
    padding: 24px;
}

.grammar-complete-check {
    font-size: 3rem;
    color: var(--accent);
}

.grammar-complete-title {
    font-size: 1.4rem;
    font-weight: 700;
    color: var(--text);
}

.grammar-complete-stats {
    font-size: 0.95rem;
    color: var(--text-faint);
}

.grammar-complete-actions {
    display: flex;
    gap: 10px;
    flex-wrap: wrap;
    justify-content: center;
}

/* The Report Card / Printable Practice Sheet action row lives
   under the right column and is scoped to the kana practice flow.
   Hide it in grammar mode — neither action has meaning for a
   grammar lesson. Mirrors body.vocab-active .col-right-actions. */
body.grammar-active .col-right-actions {
    display: none;
}

/* ── Right column swap (Character Details ↔ Grammar Details) ── */
.grammar-details-panel { display: none; }

body.grammar-active #char-details,
body.grammar-active #vocab-details {
    display: none;
}

body.grammar-active .grammar-details-panel {
    display: block;
}

body.grammar-active .col-right .panel-box {
    flex: 1 1 0;
    min-height: 0;
    max-height: none;
}

body.grammar-active .col-right .panel-body {
    min-height: 0;
}

@media (max-width: 900px) {
    body.grammar-active .col-right .panel-box {
        flex: none;
        height: 60vh;
    }
}

.grammar-details-panel {
    padding: 4px 4px 16px;
}

.grammar-details-title {
    font-size: 1.15rem;
    font-weight: 700;
    color: var(--text);
    margin-bottom: 2px;
}

.grammar-details-subtitle {
    font-family: 'Noto Sans JP', sans-serif;
    font-size: 0.95rem;
    color: var(--text-faint);
    margin-bottom: 14px;
}

.grammar-details-section-heading {
    font-size: 0.72rem;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--text-faint);
    font-weight: 700;
    margin: 16px 0 8px;
}

.grammar-rule {
    border-left: 3px solid var(--accent);
    padding: 6px 10px;
    margin-bottom: 10px;
    background: var(--accent-bg);
    border-radius: 0 6px 6px 0;
}

.grammar-rule-title {
    font-family: 'Noto Sans JP', sans-serif;
    font-weight: 700;
    font-size: 0.95rem;
    color: var(--text);
    margin-bottom: 4px;
}

.grammar-rule-body {
    font-size: 0.88rem;
    line-height: 1.45;
    color: var(--text);
}

/* Kanji Introduced grid — square tiles below the vocabulary table.
   Each tile is a quick-jump into Kanji mode for that character. Mirrors
   the kanji-tile look from kanji.css but renders inline in the grammar
   details column. */
.grammar-kanji-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
    gap: 6px;
    padding: 4px 4px 16px;
}

.grammar-kanji-tile {
    aspect-ratio: 1 / 1;
    background: var(--bg);
    border: 1px solid var(--subtle-border);
    border-radius: 6px;
    font-family: 'Noto Sans JP', sans-serif;
    font-size: 1.5rem;
    font-weight: 600;
    color: var(--text);
    cursor: pointer;
    padding: 0;
    transition: background 0.12s, border-color 0.12s, transform 0.08s;
}
@media (hover: hover) {
    .grammar-kanji-tile:hover {
        background: var(--accent-bg);
        border-color: var(--accent);
        color: var(--accent);
    }
}
.grammar-kanji-tile:active { transform: translateY(1px); }

/* Vocabulary section header row — title + bulk "+" button. The
   button uses the existing .vocab-add-btn pill style so it visually
   matches the per-row + buttons in the table below. */
.grammar-vocab-heading-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    margin: 12px 4px 6px;
}
.grammar-vocab-heading-row .grammar-details-section-heading {
    margin: 0;
}

.grammar-vocab-table {
    width: 100%;
    border-collapse: collapse;
    font-size: 0.9rem;
}

/* Group header rows inside the vocab table — used by L3 to split
   entries into い-Adjectives / な-Adjectives / Other. Reads as a
   subdued sub-heading: uppercase, faint, no underline so it doesn't
   look like a clickable button. */
.grammar-vocab-group-header th {
    text-align: left;
    padding: 14px 8px 4px;
    font-size: 0.72rem;
    font-weight: 700;
    color: var(--text-faint);
    text-transform: uppercase;
    letter-spacing: 0.08em;
    border-bottom: none;
}
.grammar-vocab-group:first-child .grammar-vocab-group-header th {
    padding-top: 4px;
}


.grammar-vocab-table td {
    padding: 5px 8px;
    border-bottom: 1px solid var(--subtle-border);
    vertical-align: top;
}

.grammar-vocab-table tr:last-child td {
    border-bottom: none;
}

.grammar-vocab-jp {
    font-family: 'Noto Sans JP', sans-serif;
    font-weight: 600;
    color: var(--text);
    white-space: nowrap;
    width: 1%;
}

.grammar-vocab-gloss {
    color: var(--text-faint);
}

/* Add-to-Deck action column — fixed-width, right-aligned cell so
   rows whose word lives in the vocabulary library show a "+" and
   rows whose token is a particle/copula (no library entry) leave
   a clean empty slot instead of jumping the gloss column around. */
.grammar-vocab-action {
    width: 32px;
    padding: 2px 4px;
    text-align: right;
    vertical-align: middle;
}

.grammar-vocab-add-btn,
.grammar-vocab-bulk-add-btn {
    /* Override .vocab-add-btn defaults (absolute overlay + opacity:0
       at rest) — the vocab-list version positions itself on top of a
       relatively-positioned row and only fades in on hover/selection.
       In our table / heading context the button needs to sit inline
       and always be visible. */
    position: static;
    transform: none;
    opacity: 1;
    width: 24px;
    height: 24px;
    line-height: 1;
    padding: 0;
    font-size: 1rem;
}

/* Bulk button in the section heading — tinted accent at rest so it
   reads as a primary call-to-action distinct from the per-row pills
   that only color when in-deck. */
.grammar-vocab-bulk-add-btn {
    color: var(--accent);
    border-color: var(--accent);
}
@media (hover: hover) {
    .grammar-vocab-bulk-add-btn:hover {
        background: var(--accent);
        color: #fff;
    }
}

/* ============================================================
   Kanji Memory exercise — flip-card grid.
   3-up grid of cards. Each card has two faces (hint with vertical
   ON/KUN readings, kanji glyph) controlled by a CSS flip on the
   inner element. Shake on wrong, brief accent flash on correct.
   ============================================================ */
.grammar-kanji-memory-active {
    display: flex;
    flex-direction: column;
    gap: 14px;
    flex: 1 1 0;
    min-height: 0;
    padding-top: 2px;
}

.grammar-kanji-memory-active .grammar-exercise-prompt {
    font-size: 0.9rem;
    font-weight: 500;
    color: var(--text-muted);
    padding: 0 8px;
    margin: 0;
}

.grammar-kmem-question {
    font-family: 'Noto Sans JP', sans-serif;
    font-size: 1.05rem;
    font-weight: 500;
    color: var(--text);
    text-align: center;
    padding: 6px 12px;
    min-height: 1.6em;
}

.grammar-kmem-board {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 14px;
    padding: 4px 4px 6px;
    perspective: 1100px;
    transition: opacity 0.22s ease;
}

/* Between-round cover — see _afterPromptCleared. The board fades to
   zero opacity while the DOM is rebuilt at the new positions, then
   fades back in. Avoids the "round 1 hint layout flashes briefly
   during the flip-back" visual glitch. */
.grammar-kmem-board-fading {
    opacity: 0;
    pointer-events: none;
}

.grammar-kmem-card {
    position: relative;
    aspect-ratio: 3 / 4;
    background: transparent;
    border: none;
    padding: 0;
    cursor: pointer;
    /* Inner element does the rotation — see below. */
    perspective: none;
    /* Reset the global button reset so the focus ring is custom. */
    outline: none;
}

.grammar-kmem-card:focus-visible {
    /* Match the in-app focus convention — accent ring, no blue. */
    box-shadow: 0 0 0 2px var(--accent-bg), 0 0 0 4px var(--accent);
    border-radius: 12px;
}

.grammar-kmem-card-inner {
    position: relative;
    width: 100%;
    height: 100%;
    /* `transform: translateZ(0)` is a force-layer hack that holds the
       GPU compositor layer even when the card has been idle for a few
       seconds. We previously used `will-change: transform`, which is
       only a *hint* — older mobile GPUs honor it briefly and then
       demote the layer to save power. The user reported quick taps
       were smooth but waiting-then-tapping was laggy, which matched
       that demote-then-rePromote pattern. translateZ keeps the layer
       resident the whole round. Note: combined with the rotateY in
       the .flipped state, the translateZ resets to identity once the
       transition kicks in, so the flip animation is the same. */
    transform: translateZ(0);
    transform-style: preserve-3d;
    transition: transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.grammar-kmem-card.flipped .grammar-kmem-card-inner {
    /* When flipping, override the idle translateZ with the rotation
       transform (translateZ has no effect once rotateY is the active
       transform). */
    transform: rotateY(180deg);
}

.grammar-kmem-card-face {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    border: 1px dashed var(--subtle-border);
    border-radius: 12px;
    background: var(--bg-outer);
    box-shadow: inset 0 1px 2px rgba(31, 26, 26, 0.08);
    /* Both faces share the same back-side hidden treatment so the
       reverse side of a face is never visible mid-flip. */
    backface-visibility: hidden;
    -webkit-backface-visibility: hidden;
}

.grammar-kmem-card-kanji {
    transform: rotateY(180deg);
    font-family: 'Noto Sans JP', sans-serif;
    font-size: 3.2rem;
    color: var(--text);
}

.grammar-kmem-card-hint {
    padding: 12px 8px;
    /* The per-character font size is computed in JS at render time
       (see _setHintFontSize in kanji-memory.js) and set as
       --hint-char-size on each card so .grammar-kmem-reading-char can
       read it directly. The previous approach used a container query
       + cqh — clean syntactically but a measurable hot spot on older
       mobile GPUs (layout containment forces re-isolation on every
       size change). The JS approach runs once per card. */
}

/* Reading stack — multiple readings sit side-by-side as separate
   vertical columns. Reversed flex so the first reading anchors on
   the right (the conventional vertical-text reading order). */
.grammar-kmem-reading-stack {
    display: flex;
    flex-direction: row-reverse;
    justify-content: center;
    align-items: stretch;
    gap: 10px;
    width: 100%;
    height: 100%;
}

.grammar-kmem-reading-col {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    flex: 0 0 auto;
    /* Stretch isn't always picked up by short single-char columns when
       they sit alongside multi-char siblings — force-fill the stack so
       justify-content: center reliably anchors the chars at the column
       midpoint regardless of length. */
    height: 100%;
    line-height: 1.15;
}

/* Thin divider between adjacent readings. The stack is row-reverse,
   so the first DOM column is visually on the right; its left edge
   sits between it and the next column → border-left on :not(:last-child)
   places the rule cleanly between every pair. Without this, beginners
   read the two columns as one horizontally-stacked word. */
.grammar-kmem-reading-col:not(:last-child) {
    border-left: 1px solid var(--divider);
    padding-left: 10px;
}

.grammar-kmem-reading-char {
    font-family: 'Noto Sans JP', sans-serif;
    /* --hint-char-size is set per-card from JS at render time, sized
       from the card's actual height divided by the longest column's
       character count. Default falls back to 1rem if JS hasn't run
       (e.g. SSR / no-script paths). */
    font-size: var(--hint-char-size, 1rem);
    color: var(--text);
}

/* State flashes on the visible (currently-front-facing) card. We
   key off the face that's actually in view so the shake reads as
   the card shaking, not the back face. */
@keyframes grammar-kmem-shake {
    0%, 100% { transform: translateX(0); }
    20%      { transform: translateX(-6px); }
    40%      { transform: translateX(6px); }
    60%      { transform: translateX(-4px); }
    80%      { transform: translateX(4px); }
}

/* Shake the outer card, not the inner — the inner holds the flip
   transform (rotateY 180deg in .flipped), and a shake on the same
   element would override that rotation mid-animation and flash the
   hint side involuntarily. Translating the outer keeps the rotation
   pristine so the explicit flip-on-wrong is the only reveal. */
.grammar-kmem-card.wrong {
    animation: grammar-kmem-shake 0.4s ease;
}

/* Corner kanji glyphs — playing-card style. Painted into the hint
   face when each card is rendered, hidden at rest, revealed when the
   card moves into .answered after a correct tap. Two opposite corners
   give the spent-card look without crowding the central readings. */
.grammar-kmem-corner {
    position: absolute;
    font-family: 'Noto Sans JP', sans-serif;
    font-size: 0.95rem;
    color: var(--text);
    line-height: 1;
    opacity: 0;
    transition: opacity 0.25s ease;
    pointer-events: none;
}
.grammar-kmem-corner-tl { top: 8px;  left: 10px; }
.grammar-kmem-corner-br { bottom: 8px; right: 10px; }

/* Answered = the card has been correctly matched this round. It flips
   back to hint-side and stays there with a slightly darker shade and
   the corner glyphs visible — reads as a played card in a card game.
   Non-interactive: tapping does nothing (see _onCardTapped). */
.grammar-kmem-card.answered {
    cursor: default;
}
.grammar-kmem-card.answered .grammar-kmem-card-hint {
    /* Simple rgba overlay for the "spent" look — fast composite path
       on every browser. We dropped color-mix(in oklab, …) here because
       it was a hot spot on older mobile GPUs without wide-gamut
       pipelines. */
    background-color: rgba(31, 26, 26, 0.08);
    background-blend-mode: multiply;
}
.grammar-kmem-card.answered .grammar-kmem-corner {
    opacity: 0.78;
}

/* Exhausted = every reading for this kanji has been answered. The
   card stays kanji-side up and is non-interactive — visually faded
   so the user knows it's no longer in play. */
.grammar-kmem-card.exhausted {
    cursor: default;
    opacity: 0.42;
}
.grammar-kmem-card.exhausted .grammar-kmem-card-face {
    border-style: solid;
}

@media (hover: hover) {
    .grammar-kmem-card:not(.exhausted):hover .grammar-kmem-card-face {
        border-color: var(--accent);
    }
}

/* Flip button — primary call-to-action while in hint phase. */
.grammar-kmem-flip-btn {
    align-self: center;
    min-width: 180px;
}
