Initial commit: Add project files
ci / backend (push) Failing after 12s
ci / frontend (push) Failing after 19s
ci / static (push) Failing after 45s

This commit is contained in:
2026-08-02 05:15:36 +09:00
commit dcd1322d41
636 changed files with 122352 additions and 0 deletions
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { UiGridColumn } from '../../../shared/ui/adapter/contracts'
import DataGridShell from '../../../shared/ui/DataGridShell.vue'
import type { DataQualityRun } from '../schema'
// Template fixture only. Production data must come from DAT-03 and pass Zod validation.
const rows: DataQualityRun[] = []
const columns = computed<UiGridColumn[]>(() => [
{ field: 'source', header: 'Source' },
{ field: 'session', header: 'Session' },
{ field: 'status', header: 'DQ' },
{ field: 'rowCount', header: 'Rows' },
{ field: 'failedRows', header: 'Failed' },
{ field: 'sourceWatermark', header: 'Watermark' },
{ field: 'datasetId', header: 'Dataset' },
{ field: 'completedAt', header: 'Completed' }
])
</script>
<template>
<main>
<header>
<h1>데이터 품질 운영</h1>
<p>RawPITDQDataset lineage를 확인합니다. QUARANTINED 데이터는 추천에 사용할 없습니다.</p>
</header>
<DataGridShell
:rows="rows"
:columns="columns"
empty-message="DAT-03 계약이 구현되면 서버 검증 결과가 표시됩니다."
/>
</main>
</template>
@@ -0,0 +1,27 @@
import { z } from 'zod'
export const dataQualityStatusSchema = z.enum(['PASS', 'WARN', 'QUARANTINED'])
export const dataQualityRunSchema = z.object({
runId: z.string().uuid(),
source: z.string().min(1),
session: z.string().min(1),
status: dataQualityStatusSchema,
rowCount: z.number().int().nonnegative(),
failedRows: z.number().int().nonnegative(),
sourceWatermark: z.string().min(1),
datasetId: z.string().min(1),
contentHash: z.string().min(1),
completedAt: z.string().datetime({ offset: true })
}).superRefine((value, ctx) => {
if (value.failedRows > value.rowCount) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '실패 행 수는 전체 행 수를 초과할 수 없습니다.',
path: ['failedRows']
})
}
})
export const dataQualityRunsSchema = z.array(dataQualityRunSchema)
export type DataQualityRun = z.infer<typeof dataQualityRunSchema>
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { dataQualityRunSchema } from '../schema'
const valid = {
runId: '00000000-0000-0000-0000-000000000001',
source: 'KRX',
session: '2026-08-01',
status: 'PASS',
rowCount: 100,
failedRows: 0,
sourceWatermark: 'KRX:2026-08-01',
datasetId: 'dataset-1',
contentHash: 'hash-1',
completedAt: '2026-08-01T09:00:00Z'
}
describe('data quality contract', () => {
it('accepts a valid run', () => {
expect(dataQualityRunSchema.safeParse(valid).success).toBe(true)
})
it('rejects failed rows greater than total rows', () => {
expect(dataQualityRunSchema.safeParse({ ...valid, failedRows: 101 }).success).toBe(false)
})
})
@@ -0,0 +1,7 @@
import { api } from '../../shared/api/client'
import { modelOperationsPlanSchema, type ModelOperationsPlan } from './schema'
export async function getModelOperationsPlan(): Promise<ModelOperationsPlan> {
const response = await api.get('/internal/v1/model-operations/plan')
return modelOperationsPlanSchema.parse(response.data)
}
@@ -0,0 +1,31 @@
<script setup lang="ts">
defineProps<{
algorithmStatus: string
orderCapability: string
modelMutationBoundary: string
}>()
</script>
<template>
<section aria-labelledby="automation-boundary-title">
<h2 id="automation-boundary-title">자동화 경계</h2>
<dl>
<div>
<dt>알고리즘 상태</dt>
<dd>{{ algorithmStatus }}</dd>
</div>
<div>
<dt>주문 Capability</dt>
<dd>{{ orderCapability }}</dd>
</div>
<div>
<dt>모델 변경</dt>
<dd>{{ modelMutationBoundary }}</dd>
</div>
</dl>
<p>
스케줄러는 평가 증거와 개선 제안만 생성합니다. 모델 승격·롤백·임계값 변경은 독립 검증과
maker-checker 승인을 거쳐야 합니다.
</p>
</section>
</template>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { ModelOperationItem } from '../schema'
defineProps<{ operations: ModelOperationItem[] }>()
</script>
<template>
<section aria-labelledby="operation-plan-title">
<h2 id="operation-plan-title">지속 평가·개선 작업 계획</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Job</th>
<th>작업</th>
<th>주기</th>
<th>자동화 모드</th>
<th>Queue</th>
<th>Gate</th>
<th>Owner</th>
<th>산출물</th>
</tr>
</thead>
<tbody>
<tr v-for="operation in operations" :key="operation.operationCode">
<td>{{ operation.operationCode }}</td>
<td>{{ operation.name }}</td>
<td>{{ operation.cadence }}</td>
<td>{{ operation.automationMode }}</td>
<td>{{ operation.queue }}</td>
<td>{{ operation.gate }}</td>
<td>{{ operation.primaryOwner }} / {{ operation.secondaryOwner }}</td>
<td>{{ operation.output }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import QueryStateBoundary from '../../../shared/ui/QueryStateBoundary.vue'
import AutomationBoundaryPanel from '../components/AutomationBoundaryPanel.vue'
import ModelOperationTable from '../components/ModelOperationTable.vue'
import { useModelOperationsPlanQuery } from '../queries'
const planQuery = useModelOperationsPlanQuery()
</script>
<template>
<article>
<header>
<h1>모델 운영·지속 고도화</h1>
<p>···분기 평가, drift, champion/challenger, 개선 제안과 승격 증거를 관리합니다.</p>
</header>
<QueryStateBoundary
:loading="planQuery.isLoading.value"
:error="planQuery.error.value as Error | null"
:empty="!planQuery.data.value"
>
<template v-if="planQuery.data.value">
<AutomationBoundaryPanel
:algorithm-status="planQuery.data.value.algorithmStatus"
:order-capability="planQuery.data.value.orderCapability"
:model-mutation-boundary="planQuery.data.value.modelMutationBoundary"
/>
<ModelOperationTable :operations="planQuery.data.value.operations" />
</template>
</QueryStateBoundary>
</article>
</template>
@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/vue-query'
import { getModelOperationsPlan } from './api'
export const modelOperationsKeys = {
all: ['model-operations'] as const,
plan: () => [...modelOperationsKeys.all, 'plan'] as const
}
export function useModelOperationsPlanQuery() {
return useQuery({
queryKey: modelOperationsKeys.plan(),
queryFn: getModelOperationsPlan,
staleTime: 5 * 60 * 1000,
retry: 1
})
}
@@ -0,0 +1,24 @@
import { z } from 'zod'
export const operationItemSchema = z.object({
operationCode: z.string().min(1),
name: z.string().min(1),
cadence: z.enum(['DAILY', 'WEEKLY', 'MONTHLY', 'QUARTERLY', 'EVENT_DRIVEN']),
automationMode: z.enum(['EVALUATION_ONLY', 'PROPOSAL_ONLY', 'DRILL_ONLY']),
queue: z.string().min(1),
primaryOwner: z.string().min(1),
secondaryOwner: z.string().min(1),
requiredEvidence: z.string().min(1),
output: z.string().min(1),
gate: z.string().min(1)
})
export const modelOperationsPlanSchema = z.object({
algorithmStatus: z.literal('RESEARCH_CANDIDATE_NOT_PRODUCTION'),
orderCapability: z.literal('AUTOMATIC_ORDER_AND_KIS_SUBMISSION_OFF'),
modelMutationBoundary: z.literal('EVALUATION_AND_PROPOSAL_ONLY_HUMAN_APPROVAL_REQUIRED'),
operations: z.array(operationItemSchema)
})
export type ModelOperationsPlan = z.infer<typeof modelOperationsPlanSchema>
export type ModelOperationItem = z.infer<typeof operationItemSchema>
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { modelOperationsPlanSchema } from '../schema'
describe('model operations plan schema', () => {
it('rejects an automatic promotion mode', () => {
const result = modelOperationsPlanSchema.safeParse({
algorithmStatus: 'RESEARCH_CANDIDATE_NOT_PRODUCTION',
orderCapability: 'AUTOMATIC_ORDER_AND_KIS_SUBMISSION_OFF',
modelMutationBoundary: 'EVALUATION_AND_PROPOSAL_ONLY_HUMAN_APPROVAL_REQUIRED',
operations: [{
operationCode: 'J22',
name: 'PromotionEvidenceReviewBuild',
cadence: 'MONTHLY',
automationMode: 'AUTO_PROMOTE',
queue: 'q-control',
primaryOwner: 'Risk',
secondaryOwner: 'Compliance',
requiredEvidence: 'all evidence',
output: 'review packet',
gate: 'G4'
}]
})
expect(result.success).toBe(false)
})
})
@@ -0,0 +1,24 @@
import { api } from '../../shared/api/client'
import {
researchSellPolicyRequestSchema,
researchSellPolicyResponseSchema,
type ResearchSellPolicyRequest,
type ResearchSellPolicyResponse
} from './schema'
export interface ResearchSellPolicyCommand {
request: ResearchSellPolicyRequest
idempotencyKey: string
}
export async function evaluateResearchSellPolicy(
command: ResearchSellPolicyCommand
): Promise<ResearchSellPolicyResponse> {
const request = researchSellPolicyRequestSchema.parse(command.request)
const { data } = await api.post(
'/internal/v1/research/sell-policy/evaluate',
request,
{ headers: { 'Idempotency-Key': command.idempotencyKey } }
)
return researchSellPolicyResponseSchema.parse(data)
}
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ResearchSellPolicyResponse } from '../schema'
type TraceEntry = ResearchSellPolicyResponse['policyTrace'][number]
const props = defineProps<{ entries: TraceEntry[]; schemaVersion: 2 }>()
const dispositionLabel: Record<0 | 1 | 2, string> = { 0: 'NOT_APPLICABLE', 1: 'BLOCKED', 2: 'APPLIED' }
const ordered = computed(() => [...props.entries].sort((a, b) => b.priority - a.priority))
</script>
<template>
<section aria-labelledby="policy-trace-title">
<h2 id="policy-trace-title">정책 우선순위 추적</h2>
<p>Trace schema v{{ props.schemaVersion }} · 상위 정책부터 평가된 불변 증거입니다.</p>
<ol>
<li v-for="entry in ordered" :key="`${entry.priority}-${entry.policyId}`">
<strong>{{ entry.policyId }}</strong>
<span> {{ dispositionLabel[entry.disposition] }} · {{ entry.reasonCode }}</span>
<span v-if="entry.requestedSellRatioOfLot > 0">
· 요청 {{ entry.requestedSellRatioOfLot }} / 적용 {{ entry.appliedSellRatioOfLot }}
</span>
<span v-if="entry.strategicCoreClampApplied"> · Strategic Core clamp</span>
</li>
</ol>
</section>
</template>
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import QueryStateBoundary from '../../../shared/ui/QueryStateBoundary.vue'
import PolicyTracePanel from '../components/PolicyTracePanel.vue'
import { useEvaluateResearchSellPolicy } from '../queries'
import type { ResearchSellPolicyCommand } from '../api'
const mutation = useEvaluateResearchSellPolicy()
const hardImpairmentApproved = ref(false)
const capitalFloorBreached = ref(false)
const gapBelowFloorAtr = ref(1.6)
const consecutiveCloseBreaches = ref(0)
const lastCommand = ref<ResearchSellPolicyCommand | null>(null)
const isBusy = computed(() => mutation.isPending.value)
function createCommand(): ResearchSellPolicyCommand {
const asOf = new Date().toISOString()
return {
idempotencyKey: crypto.randomUUID(),
request: {
positionLotId: '00000000-0000-0000-0000-000000000001',
cycleId: '00000000-0000-0000-0000-000000000002',
evidenceId: 'sample-evidence',
datasetId: 'sample-dataset',
modelVersion: 'research-v12.2',
configVersion: 'proposal-v12.2',
codeSha: 'sample-code-sha',
asOf,
publishedAtCutoff: asOf,
currentSecurityPortfolioWeight: 0.6,
currentLotPortfolioWeight: 0.2,
strategicCoreFloorWeight: 0.3,
hardImpairmentApproved: hardImpairmentApproved.value,
capitalFloorBreached: capitalFloorBreached.value,
survivalSellRatioOfLot: 0.5,
gapBelowFloorAtr: gapBelowFloorAtr.value,
consecutiveCloseBreaches: consecutiveCloseBreaches.value,
cooldownSatisfied: true,
concentrationSellRatioOfLot: 0,
opportunityEdgeLowerBound: 0,
opportunitySellRatioOfLot: 0
}
}
}
function run() {
const command = createCommand()
lastCommand.value = command
mutation.mutate(command)
}
function retry() {
if (lastCommand.value) mutation.mutate(lastCommand.value)
}
</script>
<template>
<main>
<header>
<h1>매도 정책 연구 콘솔</h1>
<p>
순수 정책 계약과 우선순위를 확인하는 연구 전용 화면입니다.
고객 제안·공개·주문 기능과 연결되지 않습니다.
</p>
<strong>RESEARCH_CANDIDATE_NOT_PRODUCTION · 자동주문 OFF</strong>
</header>
<form @submit.prevent="run">
<fieldset :disabled="isBusy">
<legend>연구 입력 벡터</legend>
<label>
<input v-model="hardImpairmentApproved" type="checkbox" />
Hard impairment 승인
</label>
<label>
<input v-model="capitalFloorBreached" type="checkbox" />
자본바닥 위반
</label>
<label>
보호선 이탈 ATR
<input v-model.number="gapBelowFloorAtr" type="number" min="0" step="0.1" />
</label>
<label>
연속 종가 이탈
<input v-model.number="consecutiveCloseBreaches" type="number" min="0" step="1" />
</label>
<button type="submit">정책 평가</button>
</fieldset>
</form>
<QueryStateBoundary
:loading="isBusy"
:error="mutation.error.value as Error | null"
:empty="!mutation.data.value"
@retry="retry"
>
<dl v-if="mutation.data.value">
<dt>행동</dt><dd>{{ mutation.data.value.action }}</dd>
<dt>정책</dt><dd>{{ mutation.data.value.policyId }}</dd>
<dt>사유</dt><dd>{{ mutation.data.value.reasonCode }}</dd>
<dt>Lot 매도비율</dt><dd>{{ mutation.data.value.sellRatioOfLot }}</dd>
<dt>매도 종목 비중</dt><dd>{{ mutation.data.value.targetSecurityPortfolioWeightAfter }}</dd>
<dt>재진입 가능</dt><dd>{{ mutation.data.value.reentryEligible }}</dd>
<dt>결정 계약</dt><dd>{{ mutation.data.value.decisionContractVersion }}</dd>
<dt>정책 추적</dt><dd>{{ mutation.data.value.policyTrace.length }}단계</dd>
</dl>
<PolicyTracePanel
v-if="mutation.data.value"
:entries="mutation.data.value.policyTrace"
:schema-version="mutation.data.value.policyTraceSchemaVersion"
/>
</QueryStateBoundary>
</main>
</template>
@@ -0,0 +1,6 @@
import { useMutation } from '@tanstack/vue-query'
import { evaluateResearchSellPolicy } from './api'
export function useEvaluateResearchSellPolicy() {
return useMutation({ mutationFn: evaluateResearchSellPolicy })
}
@@ -0,0 +1,68 @@
import { z } from 'zod'
const ratio = z.number().min(0).max(1)
const policyTraceEntrySchema = z.object({
policyId: z.string().min(1),
priority: z.number().int(),
disposition: z.union([z.literal(0), z.literal(1), z.literal(2)]),
reasonCode: z.string().min(1),
requestedSellRatioOfLot: ratio,
appliedSellRatioOfLot: ratio,
strategicCoreClampApplied: z.boolean()
})
export const researchSellPolicyRequestSchema = z.object({
positionLotId: z.string().uuid(),
cycleId: z.string().uuid(),
evidenceId: z.string().min(1).max(128),
datasetId: z.string().min(1).max(128),
modelVersion: z.string().min(1).max(128),
configVersion: z.string().min(1).max(128),
codeSha: z.string().min(1).max(128),
asOf: z.string().datetime({ offset: true }),
publishedAtCutoff: z.string().datetime({ offset: true }),
currentSecurityPortfolioWeight: ratio,
currentLotPortfolioWeight: ratio,
strategicCoreFloorWeight: ratio,
hardImpairmentApproved: z.boolean(),
capitalFloorBreached: z.boolean(),
survivalSellRatioOfLot: ratio,
gapBelowFloorAtr: z.number().min(0),
consecutiveCloseBreaches: z.number().int().min(0),
cooldownSatisfied: z.boolean(),
concentrationSellRatioOfLot: ratio,
opportunityEdgeLowerBound: z.number(),
opportunitySellRatioOfLot: ratio
}).superRefine((value, ctx) => {
if (value.currentLotPortfolioWeight > value.currentSecurityPortfolioWeight) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Lot 비중은 종목 전체 비중을 초과할 수 없습니다.',
path: ['currentLotPortfolioWeight']
})
}
if (new Date(value.publishedAtCutoff) > new Date(value.asOf)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '공개 가능 시각은 평가 시각 이후일 수 없습니다.',
path: ['publishedAtCutoff']
})
}
})
export const researchSellPolicyResponseSchema = z.object({
action: z.enum(['Hold', 'PartialSell', 'FullSell']),
sellRatioOfLot: ratio,
targetSecurityPortfolioWeightAfter: ratio,
policyId: z.string().min(1),
reasonCode: z.string().min(1),
decisionContractVersion: z.literal('sell-decision.v2'),
policyTraceSchemaVersion: z.literal(2),
reentryEligible: z.boolean(),
policyTrace: z.array(policyTraceEntrySchema),
evidenceStatus: z.literal('RESEARCH_CANDIDATE_NOT_PRODUCTION')
})
export type ResearchSellPolicyRequest = z.infer<typeof researchSellPolicyRequestSchema>
export type ResearchSellPolicyResponse = z.infer<typeof researchSellPolicyResponseSchema>
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import {
researchSellPolicyRequestSchema,
researchSellPolicyResponseSchema
} from '../schema'
const baseRequest = {
positionLotId: '00000000-0000-0000-0000-000000000001',
cycleId: '00000000-0000-0000-0000-000000000002',
evidenceId: 'evidence-1',
datasetId: 'dataset-1',
modelVersion: 'model-1',
configVersion: 'config-1',
codeSha: 'sha-1',
asOf: '2026-08-01T07:00:00Z',
publishedAtCutoff: '2026-08-01T06:00:00Z',
currentSecurityPortfolioWeight: 0.6,
currentLotPortfolioWeight: 0.2,
strategicCoreFloorWeight: 0.3,
hardImpairmentApproved: false,
capitalFloorBreached: false,
survivalSellRatioOfLot: 0,
gapBelowFloorAtr: 0,
consecutiveCloseBreaches: 0,
cooldownSatisfied: true,
concentrationSellRatioOfLot: 0,
opportunityEdgeLowerBound: 0,
opportunitySellRatioOfLot: 0
}
describe('research sell policy contracts', () => {
it('accepts a valid point-in-time request', () => {
expect(researchSellPolicyRequestSchema.safeParse(baseRequest).success).toBe(true)
})
it('rejects a lot weight above security weight', () => {
const result = researchSellPolicyRequestSchema.safeParse({
...baseRequest,
currentLotPortfolioWeight: 0.7
})
expect(result.success).toBe(false)
})
it('rejects a future published-at cutoff', () => {
const result = researchSellPolicyRequestSchema.safeParse({
...baseRequest,
publishedAtCutoff: '2026-08-01T08:00:00Z'
})
expect(result.success).toBe(false)
})
it('rejects an unknown production status', () => {
const result = researchSellPolicyResponseSchema.safeParse({
action: 'Hold',
sellRatioOfLot: 0,
targetSecurityPortfolioWeightAfter: 0.6,
policyId: 'ALG-HOLD-001',
reasonCode: 'NO_SELL_CONDITION',
reentryEligible: false,
policyTrace: [],
evidenceStatus: 'PRODUCTION'
})
expect(result.success).toBe(false)
})
})
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { SearchListCrudPage } from '@/shared/ui/screen-types'
import { screenTemplateCatalogue } from '@/shared/ui/screen-types/catalogue'
import { KsButton, KsDataGrid, KsSelect, KsStatusTag, KsTextField } from '@/shared/ui/components'
import type { UiGridColumn, UiSelectOption } from '@/shared/ui/adapter/contracts'
import { useUiAdapter } from '@/shared/ui/adapter/useUiAdapter'
const adapter = useUiAdapter()
const query = ref('')
const status = ref<unknown>('ALL')
const options: UiSelectOption[] = [{ label: '전체', value: 'ALL' }, { label: '검토 필요', value: 'REVIEW' }, { label: '보류', value: 'HOLD' }]
const rows = computed(() => screenTemplateCatalogue.filter(x => !query.value || `${x.id} ${x.name} ${x.component}`.toLowerCase().includes(query.value.toLowerCase())).map(x => ({ id: x.id, name: x.name, component: x.component, evidence: x.mandatoryEvidence.length, state: 'READY' })))
const columns: UiGridColumn[] = [{ field: 'id', header: '화면 ID', width: 100 }, { field: 'name', header: '화면 타입' }, { field: 'component', header: '표준 컴포넌트', minWidth: 220 }, { field: 'evidence', header: '필수 증거', width: 110 }, { field: 'state', header: '상태', width: 110 }]
</script>
<template>
<SearchListCrudPage title="표준 UI 패턴" subtitle="Feature는 공급자 라이브러리를 직접 사용하지 않고, v2 어댑터·레이아웃·화면 계약을 사용한다." state="READY" :evidence="{ asOf: '2026-08-02', version: 'UI-CONTRACT-2.0' }">
<template #actions><KsButton label="새 화면 패킷" severity="secondary" /></template>
<template #summary><div class="ks-card summary"><strong>10</strong><span>화면 타입</span></div><div class="ks-card summary"><strong>{{ adapter.descriptor.capabilities.size }}</strong><span>어댑터 포트</span></div><div class="ks-card summary"><KsStatusTag :value="adapter.descriptor.id" severity="info" /><span>{{ adapter.descriptor.vendor }}</span></div><div class="ks-card summary"><KsStatusTag value="자동주문 OFF" severity="warning" /><span>고정 경계</span></div></template>
<template #filters><div class="filters"><KsTextField v-model="query" label="검색" placeholder="화면 ID, 타입 또는 컴포넌트" /><KsSelect v-model="status" label="상태" :options="options" /></div></template>
<KsDataGrid :rows="rows" :columns="columns" height="25rem" />
<template #detail><div class="ks-card detail"><h2>교체 계약</h2><p>기본 공급자 교체는 <code>VITE_UI_ADAPTER</code> provider registry에서 수행한다. Feature 코드는 변경하지 않는다.</p><p>PrimeVue+AG Grid와 Native reference adapter가 동일 contract test를 통과해야 한다.</p><p>생산 교체는 접근성, 키보드, 상태행렬, 시각회귀, 대량목록 성능을 별도 Gate로 검증한다.</p></div></template>
</SearchListCrudPage>
</template>
<style scoped>.filters{display:grid;grid-template-columns:2fr 1fr;gap:var(--ks-space-3)}.summary,.detail{padding:var(--ks-space-4)}.summary{display:grid;gap:var(--ks-space-1)}.summary strong{font-size:1.5rem}.detail h2{margin-top:0;font-size:var(--ks-font-section)}@media(max-width:700px){.filters{grid-template-columns:1fr}}</style>