refactor(workload): plugin architecture wave + apps UI + volume scopes
Completes the workload-first refactor's plugin layer:
- internal/workload/plugin/ — Source/Trigger plugin contract,
registry, types (Workload, DeploymentIntent, InboundEvent,
PublicFace). Self-registering init() pattern + blank-import
in cmd/server/main.go.
- Source plugins: image (blue-green with multi-face proxy routing),
compose, static. Trigger plugins: registry, git, manual.
- internal/deployer/dispatch.go — DispatchPlugin/Teardown/Reconcile
seam routing the legacy deployer through plugins.
- internal/api/workload_*.go — REST surface: workloads, env,
volumes, chain (parent/children), promote-from. hooks.go
serves /api/hooks/kinds/{kind}/schema for the wizard.
- internal/store: workload_env (encrypt-at-rest secrets) and
workload_volumes tables, keyed on workload_id.
- cmd/server/static_backend.go — phantom-row adapter delegating
the static source plugin to the legacy staticsite.Manager
(deleted at hard cutover once the static inline port lands).
- web/src/routes/apps/ — /apps list + /apps/new wizard +
/apps/[id] detail with kind-aware compose / image / static
forms (Advanced JSON toggle), env panel, volumes panel,
webhook panel, chain panel, manual deploy.
Volume scope generalization (v2 resolver):
- internal/volume.ResolveWorkloadPath (workload-keyed, sits
next to legacy ResolvePath). Honors all VolumeScope values:
absolute, ephemeral, instance, stage, project, project_named,
named. internal/workload/plugin/source/image/image.go
computeMounts wires settings + imageTag through. Coverage in
internal/volume/resolver_test.go (portable Linux/Windows via
t.TempDir).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,14 +6,19 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { fetchContainerLogs, fetchStaticSiteLogs } from '$lib/api';
|
||||
import {
|
||||
fetchContainerLogs,
|
||||
fetchStaticSiteLogs,
|
||||
fetchWorkloadContainerLogs
|
||||
} from '$lib/api';
|
||||
import { getAuthToken } from '$lib/auth';
|
||||
import { t } from '$lib/i18n';
|
||||
import { IconLoader, IconX } from '$lib/components/icons';
|
||||
|
||||
export type LogSource =
|
||||
| { kind: 'instance'; projectId: string; stageId: string; instanceId: string }
|
||||
| { kind: 'site'; siteId: string };
|
||||
| { kind: 'site'; siteId: string }
|
||||
| { kind: 'workload'; workloadId: string; containerRowId: string };
|
||||
|
||||
interface Props {
|
||||
source: LogSource;
|
||||
@@ -58,6 +63,9 @@
|
||||
if (source.kind === 'instance') {
|
||||
return `/api/projects/${source.projectId}/stages/${source.stageId}/instances/${source.instanceId}/logs?follow=true&tail=0${tokenParam}`;
|
||||
}
|
||||
if (source.kind === 'workload') {
|
||||
return `/api/workloads/${source.workloadId}/containers/${source.containerRowId}/logs?follow=true&tail=0${tokenParam}`;
|
||||
}
|
||||
return `/api/sites/${source.siteId}/logs?follow=true&tail=0${tokenParam}`;
|
||||
}
|
||||
|
||||
@@ -65,6 +73,9 @@
|
||||
if (source.kind === 'instance') {
|
||||
return fetchContainerLogs(source.projectId, source.stageId, source.instanceId, tail);
|
||||
}
|
||||
if (source.kind === 'workload') {
|
||||
return fetchWorkloadContainerLogs(source.workloadId, source.containerRowId, tail);
|
||||
}
|
||||
return fetchStaticSiteLogs(source.siteId, tail);
|
||||
}
|
||||
|
||||
|
||||
+36
-3
@@ -536,11 +536,11 @@ export interface SystemStatsSample {
|
||||
|
||||
// ── Workload / Container / App ────────────────────────────────────
|
||||
|
||||
export type WorkloadKind = 'project' | 'stack' | 'site';
|
||||
export type WorkloadKind = 'project' | 'stack' | 'site' | 'plugin' | (string & {});
|
||||
|
||||
/**
|
||||
* Workload is the unifying primitive over Project / Stack / StaticSite.
|
||||
* Read-only at this layer — mutations go through the kind-specific endpoints.
|
||||
* Workload is the unifying primitive over Project / Stack / StaticSite,
|
||||
* plus plugin-native rows whose source/trigger kinds are populated.
|
||||
*/
|
||||
export interface Workload {
|
||||
id: string;
|
||||
@@ -548,12 +548,45 @@ export interface Workload {
|
||||
ref_id: string;
|
||||
name: string;
|
||||
app_id: string;
|
||||
source_kind: string;
|
||||
source_config: string;
|
||||
trigger_kind: string;
|
||||
trigger_config: string;
|
||||
public_faces: string;
|
||||
parent_workload_id: string;
|
||||
notification_url: string;
|
||||
webhook_require_signature: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PublicFace {
|
||||
subdomain: string;
|
||||
domain: string;
|
||||
target_service: string;
|
||||
target_port: number;
|
||||
access_list_id: number;
|
||||
enable_ssl: boolean;
|
||||
}
|
||||
|
||||
export interface PluginWorkloadInput {
|
||||
name: string;
|
||||
group_id?: string;
|
||||
parent_workload_id?: string;
|
||||
source_kind: string;
|
||||
source_config: unknown;
|
||||
trigger_kind: string;
|
||||
trigger_config: unknown;
|
||||
public_faces?: PublicFace[];
|
||||
notification_url?: string;
|
||||
webhook_require_signature?: boolean;
|
||||
}
|
||||
|
||||
export interface HookKinds {
|
||||
sources: string[];
|
||||
triggers: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical container states. The trailing `(string & {})` is the
|
||||
* "literal-friendly string" trick — it lets the union accept arbitrary
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { Workload } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import { IconPlus, IconRefresh } from '$lib/components/icons';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
|
||||
let workloads = $state<Workload[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let filter = $state<'all' | string>('all');
|
||||
|
||||
// Plugin-native rows are the ones with both source_kind and trigger_kind
|
||||
// populated. Legacy project/stack/site rows still appear in
|
||||
// /api/workloads — those are surfaced under their own sections.
|
||||
const pluginRows = $derived(
|
||||
workloads.filter((w) => w.source_kind !== '' && w.trigger_kind !== '')
|
||||
);
|
||||
const filtered = $derived(
|
||||
filter === 'all' ? pluginRows : pluginRows.filter((w) => w.source_kind === filter)
|
||||
);
|
||||
const sourceKinds = $derived(
|
||||
Array.from(new Set(pluginRows.map((w) => w.source_kind))).sort()
|
||||
);
|
||||
const countBy = $derived((kind: string) =>
|
||||
pluginRows.filter((w) => w.source_kind === kind).length
|
||||
);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
workloads = await api.listWorkloads();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load apps';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function sourceBadge(kind: string): string {
|
||||
switch (kind) {
|
||||
case 'image':
|
||||
return 'badge-image';
|
||||
case 'compose':
|
||||
return 'badge-compose';
|
||||
case 'static':
|
||||
return 'badge-static';
|
||||
default:
|
||||
return 'badge-other';
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Apps · Tinyforge</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="forge">
|
||||
{#snippet appsToolbar()}
|
||||
<button class="forge-btn-icon" onclick={load} aria-label="Refresh">
|
||||
<IconRefresh size={16} />
|
||||
</button>
|
||||
<a href="/apps/new" class="forge-btn">
|
||||
<IconPlus size={14} />
|
||||
<span>New App</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
{#snippet appsStats()}
|
||||
<div>
|
||||
<dt>TOTAL</dt>
|
||||
<dd>{loading ? '—' : String(pluginRows.length).padStart(2, '0')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>IMAGE</dt>
|
||||
<dd>{loading ? '—' : String(countBy('image')).padStart(2, '0')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>COMPOSE</dt>
|
||||
<dd>{loading ? '—' : String(countBy('compose')).padStart(2, '0')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>STATIC</dt>
|
||||
<dd class="accent">{loading ? '—' : String(countBy('static')).padStart(2, '0')}</dd>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet appsLede()}
|
||||
Plugin-native deployables — <em>image</em>, <em>compose</em>, or <em>static</em>, with
|
||||
pluggable redeploy triggers. Legacy projects, stacks, and sites continue to live under their
|
||||
own sections during the cutover.
|
||||
{/snippet}
|
||||
|
||||
<ForgeHero
|
||||
eyebrowSuffix="APPS"
|
||||
title="Apps"
|
||||
size="lg"
|
||||
toolbar={appsToolbar}
|
||||
lede_html={appsLede}
|
||||
stats={appsStats}
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
|
||||
{/if}
|
||||
|
||||
{#if !loading && pluginRows.length > 0}
|
||||
<div class="filter-row" role="tablist" aria-label="Filter by source plugin">
|
||||
<button
|
||||
class="chip"
|
||||
class:active={filter === 'all'}
|
||||
role="tab"
|
||||
aria-selected={filter === 'all'}
|
||||
onclick={() => (filter = 'all')}
|
||||
>
|
||||
<span class="chip-label">ALL</span>
|
||||
<span class="chip-count">{String(pluginRows.length).padStart(2, '0')}</span>
|
||||
</button>
|
||||
{#each sourceKinds as kind}
|
||||
{@const count = countBy(kind)}
|
||||
<button
|
||||
class="chip src-{kind}"
|
||||
class:active={filter === kind}
|
||||
role="tab"
|
||||
aria-selected={filter === kind}
|
||||
onclick={() => (filter = kind)}
|
||||
>
|
||||
<span class="chip-dot" aria-hidden="true"></span>
|
||||
<span class="chip-label">{kind.toUpperCase()}</span>
|
||||
<span class="chip-count">{String(count).padStart(2, '0')}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="skeleton-rows" aria-busy="true">
|
||||
{#each Array(4) as _, i}
|
||||
<div class="skeleton-row" style:--i={i}></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if filtered.length === 0}
|
||||
<div class="empty">
|
||||
<div class="empty-mark"><span></span><span></span><span></span></div>
|
||||
<h2>No apps yet</h2>
|
||||
<p>
|
||||
Apps unify image, compose, and static deployables behind a single plugin-driven
|
||||
surface. Forge your first one to see it light up here.
|
||||
</p>
|
||||
<a href="/apps/new" class="btn-primary">
|
||||
<IconPlus size={14} /><span>Forge the first app</span>
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table class="forge-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Source</th>
|
||||
<th>Trigger</th>
|
||||
<th>Created</th>
|
||||
<th class="t-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filtered as w, i (w.id)}
|
||||
<tr>
|
||||
<td>
|
||||
<a class="row-link" href={`/apps/${w.id}`}>
|
||||
<span class="row-ref">{String(i + 1).padStart(2, '0')}</span>
|
||||
<span class="row-name">{w.name}</span>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge {sourceBadge(w.source_kind)}">
|
||||
<span class="badge-dot" aria-hidden="true"></span>{w.source_kind}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-trigger">{w.trigger_kind}</span>
|
||||
</td>
|
||||
<td class="muted mono">{w.created_at}</td>
|
||||
<td class="actions-cell">
|
||||
<a class="row-action" href={`/apps/${w.id}`}>
|
||||
Open <span class="arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.forge {
|
||||
--accent: var(--forge-accent);
|
||||
--accent-soft: var(--forge-accent-soft);
|
||||
--glow: var(--forge-glow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── Alert ─────────────────────────────────────── */
|
||||
.alert {
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
align-items: center;
|
||||
padding: 0.7rem 0.9rem;
|
||||
background: var(--color-danger-light);
|
||||
color: var(--color-danger-dark);
|
||||
border: 1px solid var(--color-danger);
|
||||
border-left-width: 4px;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.alert-tag {
|
||||
font-family: var(--forge-mono);
|
||||
font-weight: 700;
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.16em;
|
||||
padding: 0.15rem 0.4rem;
|
||||
background: var(--color-danger);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
:global([data-theme='dark']) .alert {
|
||||
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* ── Filter chips ──────────────────────────────── */
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
background: var(--surface-card);
|
||||
border-radius: var(--radius-full);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: border-color 150ms ease, background 150ms ease, color 150ms ease,
|
||||
transform 150ms ease;
|
||||
}
|
||||
.chip:hover {
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.chip:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.chip.active {
|
||||
background: var(--text-primary);
|
||||
color: var(--surface-card);
|
||||
border-color: var(--text-primary);
|
||||
}
|
||||
.chip.active .chip-count,
|
||||
.chip.active .chip-dot {
|
||||
color: var(--surface-card);
|
||||
}
|
||||
.chip-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.chip.src-image .chip-dot {
|
||||
color: var(--badge-image-color);
|
||||
}
|
||||
.chip.src-compose .chip-dot {
|
||||
color: var(--badge-compose-color);
|
||||
}
|
||||
.chip.src-static .chip-dot {
|
||||
color: var(--badge-static-color);
|
||||
}
|
||||
.chip-count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.62rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── Skeleton ──────────────────────────────────── */
|
||||
.skeleton-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.skeleton-row {
|
||||
height: 52px;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(
|
||||
110deg,
|
||||
var(--surface-card) 20%,
|
||||
var(--surface-card-hover) 50%,
|
||||
var(--surface-card) 80%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.6s linear infinite;
|
||||
animation-delay: calc(var(--i) * 120ms);
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Empty ─────────────────────────────────────── */
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
border: 1px dashed var(--border-primary);
|
||||
border-radius: var(--radius-2xl);
|
||||
background: var(--surface-card);
|
||||
}
|
||||
.empty-mark {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.empty-mark span {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--border-input);
|
||||
}
|
||||
.empty-mark span:nth-child(2) {
|
||||
background: var(--accent);
|
||||
animation: ember 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes ember {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-brand-500) 18%, transparent);
|
||||
}
|
||||
}
|
||||
.empty h2 {
|
||||
font-family: var(--font-family-sans);
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 0.5rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.empty p {
|
||||
color: var(--text-secondary);
|
||||
margin: 0 auto 1.5rem;
|
||||
font-size: 0.95rem;
|
||||
max-width: 48ch;
|
||||
}
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1rem;
|
||||
background: var(--text-primary);
|
||||
color: var(--surface-card);
|
||||
border: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease, box-shadow 150ms ease;
|
||||
box-shadow: 0 0 0 0 var(--glow);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 0 4px var(--glow);
|
||||
}
|
||||
|
||||
/* ── Table ─────────────────────────────────────── */
|
||||
.table-wrap {
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--surface-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
.t-right {
|
||||
text-align: right;
|
||||
}
|
||||
.row-link {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
.row-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.row-link:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.row-ref {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.row-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.row-action {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.row-action:hover {
|
||||
color: var(--color-brand-700);
|
||||
}
|
||||
.row-action:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.row-action .arrow {
|
||||
display: inline-block;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
.row-action:hover .arrow {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
/* ── Source badges (themed via tokens) ─────────── */
|
||||
/* Light-theme defaults; dark overrides below pick up automatically. */
|
||||
.forge {
|
||||
--badge-image-color: var(--color-info);
|
||||
--badge-image-text: var(--color-info-dark);
|
||||
--badge-compose-color: var(--color-brand-500);
|
||||
--badge-compose-text: var(--color-brand-700);
|
||||
--badge-static-color: var(--color-success);
|
||||
--badge-static-text: var(--color-success-dark);
|
||||
}
|
||||
:global([data-theme='dark']) .forge {
|
||||
--badge-image-text: #93c5fd;
|
||||
--badge-compose-text: #c4b5fd;
|
||||
--badge-static-text: #6ee7b7;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.18rem 0.55rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
.badge-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
.badge-image {
|
||||
background: color-mix(in srgb, var(--badge-image-color) 10%, transparent);
|
||||
color: var(--badge-image-text);
|
||||
border-color: color-mix(in srgb, var(--badge-image-color) 35%, transparent);
|
||||
}
|
||||
.badge-compose {
|
||||
background: color-mix(in srgb, var(--badge-compose-color) 10%, transparent);
|
||||
color: var(--badge-compose-text);
|
||||
border-color: color-mix(in srgb, var(--badge-compose-color) 35%, transparent);
|
||||
}
|
||||
.badge-static {
|
||||
background: color-mix(in srgb, var(--badge-static-color) 10%, transparent);
|
||||
color: var(--badge-static-text);
|
||||
border-color: color-mix(in srgb, var(--badge-static-color) 35%, transparent);
|
||||
}
|
||||
.badge-other {
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.badge-trigger {
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.actions-cell {
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user