Commit 564551a0 authored by shrabbit's avatar shrabbit
Browse files

feat: 新增 sExt 字段解析页面

- 新增 /sext-parse 页面,含 6 个章节(问题/规律/源码/映射/C#/成品)

- CharacterCard 翻转卡片组件,正面渲染成品、反面显示原始 sExt

- SextFieldTable 字段映射表 + FieldPreview 实时预览面板(支持图片/音频/HTML/字符串)

- sext.ts 数据:17 个角色字段 + 16 个世界字段 + 5 段代码片段 + 魈解析成品

- Tab 切换 CHARACTER(732) / WORLD(742),悬停联动预览
parent d2d5399d
Loading
Loading
Loading
Loading
+542 −0
Original line number Diff line number Diff line
<template>
  <div class="char-card" :class="{ 'is-flipped': showBack }">
    <!-- ============ 正面 ============ -->
    <article class="char-card__face char-card__face--front">
      <div class="char-card__bg">
        <img
          v-if="character.cover1"
          :src="character.cover1"
          :alt="character.title + ' 大图'"
          class="char-card__bg-img"
          loading="lazy"
          decoding="async"
        />
        <div class="char-card__bg-overlay"></div>
      </div>

      <header class="char-card__head">
        <div class="char-card__avatar-wrap">
          <img
            v-if="character.icon"
            :src="character.icon"
            :alt="character.title"
            class="char-card__avatar"
            loading="lazy"
            decoding="async"
          />
          <span v-else class="char-card__avatar-fallback">{{ character.title.charAt(0) }}</span>
          <img
            v-if="character.attr"
            :src="character.attr"
            alt="元素"
            class="char-card__elem-icon"
            loading="lazy"
            decoding="async"
          />
        </div>

        <div class="char-card__title-wrap">
          <img
            v-if="character.name"
            :src="character.name"
            :alt="character.title + ' 名字艺术字'"
            class="char-card__title-art"
            loading="lazy"
            decoding="async"
          />
          <h3 v-else class="char-card__title">{{ character.title }}</h3>
          <div class="char-card__lang-list">
            <span
              v-for="(l, i) in character.lang"
              :key="i"
              class="char-card__lang"
              :class="{ 'is-active': activeLang === i }"
              @click="activeLang = i"
            >{{ l }}</span>
          </div>
        </div>
      </header>

      <section class="char-card__body">
        <div class="char-card__intro" v-html="strippedIntro"></div>

        <div class="char-card__cv">
          <span class="char-card__cv-label">CV</span>
          <span class="char-card__cv-name">{{ activeCv?.name || '' }}</span>
        </div>

        <div class="char-card__voice-list">
          <button
            v-for="(url, i) in activeCv?.audio || []"
            :key="i"
            class="char-card__voice-btn"
            :class="{ 'is-playing': playingIdx === i }"
            type="button"
            @click="togglePlay(i, url)"
          >
            <svg v-if="playingIdx !== i" width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden="true">
              <path d="M5 3l14 9-14 9V3z" fill="currentColor"/>
            </svg>
            <svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden="true">
              <rect x="6" y="4" width="4" height="16" fill="currentColor"/>
              <rect x="14" y="4" width="4" height="16" fill="currentColor"/>
            </svg>
            <span>Voice {{ i + 1 }}</span>
          </button>
        </div>
      </section>

      <footer class="char-card__foot">
        <span class="char-card__foot-tag">Parsed · from sExt</span>
        <button class="char-card__flip" type="button" @click="flip">
          <span>查看原始 sExt</span>
          <svg width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <path d="M3 12a9 9 0 1 0 9-9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
            <path d="M3 4v5h5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
          </svg>
        </button>
      </footer>
    </article>

    <!-- ============ 反面 ============ -->
    <article class="char-card__face char-card__face--back">
      <header class="char-card__back-head">
        <span class="char-card__back-tag">RAW · sExt</span>
        <h3 class="char-card__back-title">{{ character.title }}</h3>
      </header>

      <div class="char-card__raw-scroll">
        <pre class="char-card__raw-pre"><code>{{ rawText }}</code></pre>
      </div>

      <footer class="char-card__foot">
        <span class="char-card__foot-tag">JSON string · 17 keys</span>
        <button class="char-card__flip" type="button" @click="flip">
          <svg width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <path d="M21 12a9 9 0 1 1-9-9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
            <path d="M21 4v5h-5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
          </svg>
          <span>返回卡片</span>
        </button>
      </footer>
    </article>
  </div>
</template>

<script setup lang="ts">
import type { ParsedCharacter } from '~/data/sext'

interface Props {
  character: ParsedCharacter
  rawText: string
}
const props = defineProps<Props>()

const showBack = ref(false)
const activeLang = ref(0)
const playingIdx = ref<number | null>(null)
const audioEl = ref<HTMLAudioElement | null>(null)

const activeCv = computed(() => props.character.cv[activeLang.value])

const strippedIntro = computed(() => {
  // 移除 <p> 包裹仅保留内联内容保留 <br>
  return props.character.intro
    .replace(/^<p>/, '<span class="char-card__intro-text">')
    .replace(/<\/p>$/, '</span>')
})

const flip = () => {
  showBack.value = !showBack.value
  // 翻转时停止音频
  if (playingIdx.value !== null) {
    audioEl.value?.pause()
    playingIdx.value = null
  }
}

const togglePlay = async (idx: number, url: string) => {
  // 同一条目:暂停
  if (playingIdx.value === idx) {
    audioEl.value?.pause()
    playingIdx.value = null
    return
  }
  if (!audioEl.value) {
    audioEl.value = new Audio()
    audioEl.value.addEventListener('ended', () => {
      playingIdx.value = null
    })
  }
  audioEl.value.src = url
  try {
    await audioEl.value.play()
    playingIdx.value = idx
  } catch (e) {
    console.warn('播放失败', e)
  }
}

onBeforeUnmount(() => {
  audioEl.value?.pause()
  audioEl.value = null
})
</script>

<style scoped>
.char-card {
  position: relative;
  width: 100%;
  aspect-ratio: 4 / 5;
  max-width: 380px;
  perspective: 1600px;
  font-family: var(--font-sans);
}

.char-card__face {
  position: absolute;
  inset: 0;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
  background: var(--color-bg-elev);
  border: 1px solid var(--color-line);
  border-radius: var(--radius-lg);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  transition: transform 0.7s var(--ease-out-expo), opacity 0.4s var(--ease-out-quint);
  box-shadow: var(--shadow-card);
}

.char-card__face--front {
  transform: rotateY(0deg);
}

.char-card__face--back {
  transform: rotateY(180deg);
}

.char-card.is-flipped .char-card__face--front {
  transform: rotateY(-180deg);
}

.char-card.is-flipped .char-card__face--back {
  transform: rotateY(0deg);
}

/* ============ 背景 ============ */
.char-card__bg {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
}

.char-card__bg-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  filter: saturate(0.85) brightness(0.5);
  transform: scale(1.05);
  transition: transform 1.2s var(--ease-out-quint);
}

.char-card:hover .char-card__bg-img {
  transform: scale(1.1);
}

.char-card__bg-overlay {
  position: absolute;
  inset: 0;
  background:
    linear-gradient(180deg, rgba(10, 14, 12, 0.4) 0%, rgba(10, 14, 12, 0.95) 70%),
    radial-gradient(ellipse at top right, rgba(212, 164, 90, 0.15), transparent 60%);
}

/* ============ 头部 ============ */
.char-card__head {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: flex-end;
  gap: var(--space-4);
  padding: var(--space-5);
  flex-shrink: 0;
}

.char-card__avatar-wrap {
  position: relative;
  width: 88px;
  height: 88px;
  flex-shrink: 0;
}

.char-card__avatar {
  width: 100%;
  height: 100%;
  border-radius: 50%;
  object-fit: cover;
  border: 2px solid var(--color-accent);
  box-shadow: 0 0 24px rgba(212, 164, 90, 0.4);
}

.char-card__avatar-fallback {
  display: block;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  background: var(--color-bg-elev-2);
  border: 2px solid var(--color-accent);
  display: grid;
  place-items: center;
  font-family: var(--font-serif);
  font-size: 2rem;
  color: var(--color-accent);
}

.char-card__elem-icon {
  position: absolute;
  bottom: -4px;
  right: -4px;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  background: var(--color-bg-elev);
  border: 1px solid var(--color-line);
  object-fit: contain;
  padding: 2px;
  box-sizing: border-box;
}

.char-card__title-wrap {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  padding-bottom: var(--space-2);
}

.char-card__title-art {
  max-height: 36px;
  width: auto;
  filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.6));
}

.char-card__title {
  font-family: var(--font-serif);
  font-size: 1.75rem;
  font-weight: 700;
  color: var(--color-text-primary);
  margin: 0;
  line-height: 1;
}

.char-card__lang-list {
  display: flex;
  gap: var(--space-2);
}

.char-card__lang {
  font-family: var(--font-mono);
  font-size: 0.625rem;
  letter-spacing: 0.1em;
  color: var(--color-text-muted);
  padding: 2px 8px;
  border: 1px solid var(--color-line);
  border-radius: 2px;
  cursor: pointer;
  transition: all var(--duration-fast) var(--ease-out-quint);
}

.char-card__lang.is-active,
.char-card__lang:hover {
  color: var(--color-accent);
  border-color: var(--color-accent);
  background: rgba(212, 164, 90, 0.08);
}

/* ============ 主体 ============ */
.char-card__body {
  position: relative;
  z-index: 1;
  padding: 0 var(--space-5);
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  overflow: hidden;
}

.char-card__intro {
  font-size: 0.8125rem;
  line-height: 1.7;
  color: var(--color-text-secondary);
  overflow: hidden;
  display: -webkit-box;
  -webkit-line-clamp: 4;
  line-clamp: 4;
  -webkit-box-orient: vertical;
}

.char-card__intro :deep(br) {
  display: block;
  content: '';
  margin-top: 4px;
}

.char-card__cv {
  display: flex;
  align-items: baseline;
  gap: var(--space-3);
  padding-top: var(--space-2);
  border-top: 1px solid var(--color-line-faint);
}

.char-card__cv-label {
  font-family: var(--font-mono);
  font-size: 0.625rem;
  letter-spacing: 0.25em;
  color: var(--color-text-muted);
}

.char-card__cv-name {
  font-family: var(--font-serif);
  font-size: 0.875rem;
  color: var(--color-accent-soft);
}

.char-card__voice-list {
  display: flex;
  gap: var(--space-2);
  flex-wrap: wrap;
}

.char-card__voice-btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 4px 10px;
  background: transparent;
  border: 1px solid var(--color-line);
  border-radius: 2px;
  color: var(--color-text-secondary);
  font-family: var(--font-mono);
  font-size: 0.625rem;
  letter-spacing: 0.05em;
  cursor: pointer;
  transition: all var(--duration-fast) var(--ease-out-quint);
}

.char-card__voice-btn:hover {
  border-color: var(--color-accent);
  color: var(--color-accent);
}

.char-card__voice-btn.is-playing {
  background: rgba(212, 164, 90, 0.12);
  border-color: var(--color-accent);
  color: var(--color-accent);
  animation: voice-pulse 1.2s ease-in-out infinite;
}

@keyframes voice-pulse {
  0%, 100% { box-shadow: 0 0 0 0 rgba(212, 164, 90, 0); }
  50% { box-shadow: 0 0 0 4px rgba(212, 164, 90, 0.1); }
}

/* ============ 底部 ============ */
.char-card__foot {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--space-3) var(--space-5);
  border-top: 1px solid var(--color-line);
  background: rgba(10, 14, 12, 0.4);
  flex-shrink: 0;
}

.char-card__foot-tag {
  font-family: var(--font-mono);
  font-size: 0.625rem;
  letter-spacing: 0.15em;
  color: var(--color-text-muted);
  text-transform: uppercase;
}

.char-card__flip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  background: transparent;
  border: none;
  color: var(--color-accent);
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  letter-spacing: 0.05em;
  cursor: pointer;
  padding: 4px 6px;
  transition: opacity var(--duration-fast) var(--ease-out-quint);
}

.char-card__flip:hover { opacity: 0.7; }

.char-card__flip svg {
  transition: transform var(--duration-base) var(--ease-out-quint);
}

.char-card__flip:hover svg {
  transform: rotate(-90deg);
}

/* ============ 反面 ============ */
.char-card__back-head {
  padding: var(--space-5) var(--space-5) var(--space-3);
  border-bottom: 1px solid var(--color-line);
  background: linear-gradient(180deg, rgba(212, 164, 90, 0.06), transparent);
  flex-shrink: 0;
}

.char-card__back-tag {
  display: block;
  font-family: var(--font-mono);
  font-size: 0.625rem;
  letter-spacing: 0.25em;
  color: var(--color-accent);
  margin-bottom: 4px;
}

.char-card__back-title {
  font-family: var(--font-serif);
  font-size: 1.5rem;
  font-weight: 700;
  color: var(--color-text-primary);
  margin: 0;
}

.char-card__raw-scroll {
  flex: 1;
  overflow: auto;
  padding: var(--space-4) var(--space-5);
  scrollbar-width: thin;
  scrollbar-color: var(--color-line-strong) transparent;
}

.char-card__raw-scroll::-webkit-scrollbar { width: 6px; height: 6px; }
.char-card__raw-scroll::-webkit-scrollbar-track { background: transparent; }
.char-card__raw-scroll::-webkit-scrollbar-thumb {
  background: var(--color-line-strong);
  border-radius: 3px;
}

.char-card__raw-pre {
  margin: 0;
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  line-height: 1.6;
  color: var(--color-text-secondary);
  white-space: pre-wrap;
  word-break: break-all;
}
</style>
+370 −0
Original line number Diff line number Diff line
<template>
  <div class="field-preview">
    <header class="field-preview__head">
      <span class="field-preview__tag">{{ field ? 'INSPECTOR' : 'EMPTY' }}</span>
      <span class="field-preview__key">{{ field?.key || '' }}</span>
      <span v-if="field" class="field-preview__type" :data-type="field.type">{{ field.type }}</span>
    </header>

    <div class="field-preview__body">
      <div v-if="!field" class="field-preview__placeholder">
        <svg width="40" height="40" viewBox="0 0 24 24" fill="none" aria-hidden="true">
          <path d="M12 4v8M12 16v.01" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
          <circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.6"/>
        </svg>
        <p>悬停左侧表格中的任意行</p>
        <p class="field-preview__hint">查看该 sExt 字段的实际内容</p>
      </div>

      <div v-else-if="field.type === 'image' && field.url" class="field-preview__image">
        <img
          :src="field.url"
          :alt="field.sample"
          loading="lazy"
          decoding="async"
          @load="onImgLoad"
          @error="onImgError"
        />
        <span class="field-preview__caption">{{ field.sample }}</span>
      </div>

      <div v-else-if="field.type === 'audio' && field.url" class="field-preview__audio">
        <div class="field-preview__audio-icon">
          <svg width="32" height="32" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <path d="M9 18V5l12-2v13" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
            <circle cx="6" cy="18" r="3" stroke="currentColor" stroke-width="1.6"/>
            <circle cx="18" cy="16" r="3" stroke="currentColor" stroke-width="1.6"/>
          </svg>
        </div>
        <span class="field-preview__audio-name">{{ field.sample }}</span>
        <button class="field-preview__audio-play" type="button" @click="togglePlay">
          <svg v-if="!isPlaying" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <path d="M5 3l14 9-14 9V3z" fill="currentColor"/>
          </svg>
          <svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <rect x="6" y="4" width="4" height="16" fill="currentColor"/>
            <rect x="14" y="4" width="4" height="16" fill="currentColor"/>
          </svg>
          {{ isPlaying ? '暂停' : '试听' }}
        </button>
        <div v-if="isPlaying" class="field-preview__bars" aria-hidden="true">
          <span v-for="i in 5" :key="i" :style="{ animationDelay: i * 0.1 + 's' }"></span>
        </div>
      </div>

      <div v-else-if="field.type === 'html'" class="field-preview__html">
        <span class="field-preview__html-label">HTML 内容</span>
        <div class="field-preview__html-content" v-html="field.sample"></div>
      </div>

      <div v-else-if="field.type === 'string'" class="field-preview__string">
        <span class="field-preview__string-label">字符串值</span>
        <code class="field-preview__string-value">{{ field.sample }}</code>
      </div>

      <div v-else class="field-preview__empty">
        <span class="field-preview__empty-label">空值</span>
        <code class="field-preview__empty-value">{{ field.sample }}</code>
      </div>
    </div>

    <footer v-if="field" class="field-preview__foot">
      <span class="field-preview__usage">{{ field.usage }}</span>
      <span class="field-preview__index">idx: {{ field.index }}</span>
    </footer>
  </div>
</template>

<script setup lang="ts">
import type { SextField } from '~/data/sext'

interface Props {
  field?: SextField | null
}
const props = defineProps<Props>()

const audioEl = ref<HTMLAudioElement | null>(null)
const isPlaying = ref(false)

const togglePlay = async () => {
  if (!props.field?.url) return
  if (isPlaying.value) {
    audioEl.value?.pause()
    isPlaying.value = false
    return
  }
  if (!audioEl.value) {
    audioEl.value = new Audio()
    audioEl.value.addEventListener('ended', () => {
      isPlaying.value = false
    })
  }
  audioEl.value.src = props.field.url
  try {
    await audioEl.value.play()
    isPlaying.value = true
  } catch (e) {
    console.warn('播放失败', e)
  }
}

const onImgLoad = () => {}
const onImgError = () => {}

watch(() => props.field?.key, () => {
  if (isPlaying.value) {
    audioEl.value?.pause()
    isPlaying.value = false
  }
})

onBeforeUnmount(() => {
  audioEl.value?.pause()
  audioEl.value = null
})
</script>

<style scoped>
.field-preview {
  background: var(--color-bg-elev);
  border: 1px solid var(--color-line);
  border-radius: var(--radius-lg);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  height: 100%;
  min-height: 360px;
}

.field-preview__head {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-4);
  border-bottom: 1px solid var(--color-line);
  background: rgba(212, 164, 90, 0.04);
  flex-shrink: 0;
}

.field-preview__tag {
  font-family: var(--font-mono);
  font-size: 0.625rem;
  letter-spacing: 0.25em;
  color: var(--color-accent);
}

.field-preview__key {
  font-family: var(--font-mono);
  font-size: 0.875rem;
  color: var(--color-text-primary);
  font-weight: 500;
  flex: 1;
}

.field-preview__type {
  font-family: var(--font-mono);
  font-size: 0.625rem;
  padding: 2px 8px;
  border: 1px solid var(--color-line);
  border-radius: 2px;
  text-transform: lowercase;
}

.field-preview__type[data-type='image']  { color: var(--color-anemo); border-color: rgba(116, 194, 168, 0.3); }
.field-preview__type[data-type='audio']  { color: var(--color-hydro); border-color: rgba(92, 167, 212, 0.3); }
.field-preview__type[data-type='html']   { color: var(--color-pyro); border-color: rgba(217, 117, 89, 0.3); }
.field-preview__type[data-type='string'] { color: var(--color-geo); border-color: rgba(228, 166, 114, 0.3); }
.field-preview__type[data-type='empty']  { color: var(--color-text-muted); }

.field-preview__body {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-6);
  min-height: 240px;
  background: var(--color-bg-deep);
  position: relative;
}

/* Placeholder */
.field-preview__placeholder {
  text-align: center;
  color: var(--color-text-muted);
}
.field-preview__placeholder svg { color: var(--color-line-strong); }
.field-preview__placeholder p {
  margin: var(--space-3) 0 0;
  font-size: 0.875rem;
  font-family: var(--font-mono);
}
.field-preview__placeholder .field-preview__hint {
  margin: 4px 0 0;
  font-size: 0.6875rem;
  color: var(--color-text-muted);
  opacity: 0.6;
}

/* Image */
.field-preview__image {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-3);
  max-width: 100%;
}
.field-preview__image img {
  max-width: 100%;
  max-height: 220px;
  object-fit: contain;
  border: 1px solid var(--color-line);
  border-radius: var(--radius-md);
  background: var(--color-bg-elev);
}
.field-preview__caption {
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  color: var(--color-text-muted);
  word-break: break-all;
}

/* Audio */
.field-preview__audio {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-4);
}
.field-preview__audio-icon {
  color: var(--color-hydro);
  opacity: 0.8;
}
.field-preview__audio-name {
  font-family: var(--font-mono);
  font-size: 0.75rem;
  color: var(--color-text-secondary);
  text-align: center;
  max-width: 240px;
  word-break: break-all;
}
.field-preview__audio-play {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 6px 14px;
  background: rgba(92, 167, 212, 0.08);
  border: 1px solid var(--color-hydro);
  color: var(--color-hydro);
  font-family: var(--font-mono);
  font-size: 0.75rem;
  cursor: pointer;
  border-radius: 2px;
  transition: all var(--duration-fast) var(--ease-out-quint);
}
.field-preview__audio-play:hover {
  background: rgba(92, 167, 212, 0.15);
}
.field-preview__bars {
  display: flex;
  gap: 3px;
  align-items: end;
  height: 16px;
}
.field-preview__bars span {
  width: 3px;
  height: 100%;
  background: var(--color-hydro);
  border-radius: 1px;
  animation: bar-bounce 0.8s ease-in-out infinite alternate;
}
@keyframes bar-bounce {
  0%   { transform: scaleY(0.3); }
  100% { transform: scaleY(1); }
}

/* HTML */
.field-preview__html {
  width: 100%;
  max-width: 320px;
}
.field-preview__html-label {
  display: block;
  font-family: var(--font-mono);
  font-size: 0.625rem;
  letter-spacing: 0.2em;
  color: var(--color-text-muted);
  margin-bottom: var(--space-3);
  text-transform: uppercase;
}
.field-preview__html-content {
  font-size: 0.875rem;
  line-height: 1.7;
  color: var(--color-text-primary);
  font-family: var(--font-serif);
}

/* String */
.field-preview__string {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-3);
}
.field-preview__string-label {
  font-family: var(--font-mono);
  font-size: 0.625rem;
  letter-spacing: 0.2em;
  color: var(--color-text-muted);
  text-transform: uppercase;
}
.field-preview__string-value {
  font-family: var(--font-mono);
  font-size: 1.5rem;
  color: var(--color-geo);
  background: rgba(228, 166, 114, 0.08);
  border: 1px solid rgba(228, 166, 114, 0.3);
  border-radius: var(--radius-md);
  padding: var(--space-3) var(--space-5);
}

/* Empty */
.field-preview__empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-3);
}
.field-preview__empty-label {
  font-family: var(--font-mono);
  font-size: 0.625rem;
  letter-spacing: 0.2em;
  color: var(--color-text-muted);
  text-transform: uppercase;
}
.field-preview__empty-value {
  font-family: var(--font-mono);
  font-size: 1rem;
  color: var(--color-text-muted);
  opacity: 0.7;
}

.field-preview__foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--space-3) var(--space-4);
  border-top: 1px solid var(--color-line);
  background: rgba(10, 14, 12, 0.4);
  flex-shrink: 0;
}
.field-preview__usage {
  font-size: 0.75rem;
  color: var(--color-text-secondary);
}
.field-preview__index {
  font-family: var(--font-mono);
  font-size: 0.625rem;
  letter-spacing: 0.15em;
  color: var(--color-text-muted);
}
</style>
+298 −0

File added.

Preview size limit exceeded, changes collapsed.

web/app/data/sext.ts

0 → 100644
+272 −0

File added.

Preview size limit exceeded, changes collapsed.

+1022 −0

File added.

Preview size limit exceeded, changes collapsed.