본문으로 건너뛰기
cccskills
무료GitHub에 공개

panel-base-advanced

Build dashboard panels in vanilla TypeScript with resizing, saved layout state, loading indicators, and retry controls. Examples also show how to wrap existing data-fetching functions with caching and temporary request pauses after repeated failures.

한국어 요약은 준비 중입니다. 영어 요약을 표시하고 있습니다.

원문원문 설명 보기

Unified guide for authoring dashboard panel components in vanilla TypeScript — covers the authoritative Panel base class (grid-span persistence, dual resize handles, radar loading animation, exponential-backoff error countdown, data/new badge helpers, full destroy() cleanup), localStorage state via loadMap/saveMap, exponential-backoff retry, and CircuitBreaker integration. Shows how to wrap any existing fetchX() service call with CircuitBreaker.execute() as a one-liner and clarifies when each resilience layer is appropriate.

설치 방법 보기

이럴 때 유용합니다

  • Building dashboard panels in vanilla TypeScript
  • Saving panel sizes across page reloads
  • Adding retry controls to failed data requests
  • Adding caching and cooldowns to existing fetch functions

포함된 파일(2)

  • SKILL.md23.7 KB
  • .skill_id61 B

SKILL.md(원문)

설치하기 전에 에이전트에게 전달되는 지시 내용을 확인할 수 있습니다.

Resilient Panel — Unified Pattern (v2)

Create dashboard panel components using vanilla TypeScript (no framework, no JSX). Each panel is a class extending a Panel base class and optionally wraps its data source with a CircuitBreaker.


Architecture Overview

Panel (base class)
├── element: HTMLElement (.panel)
│   ├── header: HTMLElement (.panel-header)
│   │   ├── headerLeft (.panel-header-left)
│   │   │   ├── title (.panel-title)
│   │   │   └── newBadge (.panel-new-badge) [optional, via setNewBadge()]
│   │   ├── statusBadge (.panel-data-badge) [optional, via setDataBadge()]
│   │   └── countEl (.panel-count) [optional]
│   ├── content: HTMLElement (.panel-content)
│   ├── resizeHandleRow (.panel-resize-handle-row)   [bottom edge]
│   └── resizeHandleCol (.panel-resize-handle-col)   [right edge]

Resilience layers (choose what you need)

LayerFileWhen to use
isFetching guardPanel base classAlways — prevents concurrent duplicate requests
Exponential-backoff retryPanel.fetchWithRetry()When you own the fetch call and want per-request retry before surfacing an error
Error countdown in showError()Panel base classAutomatic retry countdown rendered inside the error state UI
CircuitBreakersrc/utils/circuit-breaker.tsWhen a service may fail repeatedly; stops hammering the API and serves stale cache instead
localStorage persistencePanel.saveMap() / loadMap()When you want grid-span (not pixel size) state to survive page reloads

Wrapping an existing service call with CircuitBreaker is a one-liner:

// Before
const data = await fetchStockQuotes();
// After — adds caching + cooldown with zero refactor
const data = await breaker.execute(() => fetchStockQuotes(), []);

File 1 — src/components/Panel.ts

Authoritative implementation. Use this verbatim; do not revert to the older saveState()/loadState() width/height approach.

export interface PanelOptions {
  id: string;
  title: string;
  showCount?: boolean;
  className?: string;
}

export class Panel {
  protected element: HTMLElement;
  protected content: HTMLElement;
  protected header: HTMLElement;
  protected headerLeft: HTMLElement;
  protected countEl: HTMLElement | null = null;
  protected statusBadge: HTMLElement | null = null;
  protected newBadge: HTMLElement | null = null;
  protected panelId: string;

  private _fetching = false;
  private _countdownInterval: ReturnType<typeof setInterval> | null = null;
  private _resizeListeners: Array<{ target: EventTarget; type: string; fn: EventListenerOrEventListenerObject }> = [];

  // Retry state (used by fetchWithRetry)
  protected retryAttempts = 0;
  protected retryDelay = 1000; // ms; doubles each attempt
  private maxRetries = 3;

  constructor(options: PanelOptions) {
    this.panelId = options.id;

    this.element = document.createElement('div');
    this.element.className = `panel${options.className ? ' ' + options.className : ''}`;
    this.element.dataset.panel = options.id;

    // Header
    this.header = document.createElement('div');
    this.header.className = 'panel-header';

    this.headerLeft = document.createElement('div');
    this.headerLeft.className = 'panel-header-left';

    const title = document.createElement('span');
    title.className = 'panel-title';
    title.textContent = options.title;
    this.headerLeft.appendChild(title);
    this.header.appendChild(this.headerLeft);

    // Count badge (optional)
    if (options.showCount) {
      this.countEl = document.createElement('span');
      this.countEl.className = 'panel-count';
      this.countEl.textContent = '0';
      this.header.appendChild(this.countEl);
    }

    // Content area
    this.content = document.createElement('div');
    this.content.className = 'panel-content';
    this.content.id = `${options.id}Content`;

    // Dual resize handles
    const resizeHandleRow = document.createElement('div');
    resizeHandleRow.className = 'panel-resize-handle-row';

    const resizeHandleCol = document.createElement('div');
    resizeHandleCol.className = 'panel-resize-handle-col';

    this.element.appendChild(this.header);
    this.element.appendChild(this.content);
    this.element.appendChild(resizeHandleRow);
    this.element.appendChild(resizeHandleCol);

    this._initResizeHandles(resizeHandleRow, resizeHandleCol);
    this.showLoading();
  }

  // Public API

  public getElement(): HTMLElement { return this.element; }

  /** Radar-ring loading animation with optional message. */
  public showLoading(message = 'Loading...'): void {
    this._clearCountdown();
    this.content.innerHTML = `
      <div class="panel-loading">
        <div class="panel-loading-radar">
          <div class="radar-ring radar-ring-1"></div>
          <div class="radar-ring radar-ring-2"></div>
          <div class="radar-ring radar-ring-3"></div>
        </div>
        <div class="panel-loading-text">${message}</div>
      </div>`;
  }

  /**
   * Show an error state.
   * If autoRetryMs is provided, renders an exponential-backoff countdown
   * inside the error UI and calls onRetry automatically when it reaches 0.
   */
  public showError(
    message = 'Failed to load',
    onRetry?: () => void,
    autoRetryMs?: number
  ): void {
    this._clearCountdown();
    const countdownId = `panel-error-countdown-${this.panelId}`;
    this.content.innerHTML = `
      <div class="panel-error-state">
        <div class="panel-error-msg">${message}</div>
        ${autoRetryMs != null
          ? `<div class="panel-error-countdown" id="${countdownId}">Retrying in ${Math.ceil(autoRetryMs / 1000)}s...</div>`
          : ''}
        ${onRetry ? '<button class="panel-retry-btn" data-panel-retry>Retry</button>' : ''}
      </div>`;

    if (onRetry) {
      this.content.querySelector('[data-panel-retry]')
        ?.addEventListener('click', () => { this._clearCountdown(); onRetry(); });
    }

    if (autoRetryMs != null && onRetry) {
      let remaining = autoRetryMs;
      const getEl = () => this.content.querySelector<HTMLElement>(`#${countdownId}`);
      this._countdownInterval = setInterval(() => {
        remaining -= 1000;
        if (remaining <= 0) {
          this._clearCountdown();
          onRetry();
        } else {
          const node = getEl();
          if (node) node.textContent = `Retrying in ${Math.ceil(remaining / 1000)}s...`;
        }
      }, 1000);
    }
  }

  public setContent(html: string): void {
    this._clearCountdown();
    this.content.innerHTML = html;
  }

  public setCount(count: number): void {
    if (this.countEl) this.countEl.textContent = count.toString();
  }

  /**
   * Show (or update) the data-status badge in the panel header.
   * Pass a variant CSS modifier such as 'live', 'stale', or 'error'.
   */
  public setDataBadge(text: string, variant = 'live'): void {
    if (!this.statusBadge) {
      this.statusBadge = document.createElement('span');
      this.statusBadge.className = 'panel-data-badge';
      this.header.appendChild(this.statusBadge);
    }
    this.statusBadge.textContent = text;
    this.statusBadge.dataset.variant = variant;
  }

  public clearDataBadge(): void {
    this.statusBadge?.remove();
    this.statusBadge = null;
  }

  /** Show the "NEW" badge next to the panel title (idempotent). */
  public setNewBadge(text = 'NEW'): void {
    if (!this.newBadge) {
      this.newBadge = document.createElement('span');
      this.newBadge.className = 'panel-new-badge';
      this.headerLeft.appendChild(this.newBadge);
    }
    this.newBadge.textContent = text;
  }

  public clearNewBadge(): void {
    this.newBadge?.remove();
    this.newBadge = null;
  }

  public show(): void { this.element.classList.remove('hidden'); }
  public hide(): void { this.element.classList.add('hidden'); }

  /** Full teardown: removes element, clears countdown timer, removes all resize listeners. */
  public destroy(): void {
    this._clearCountdown();
    for (const { target, type, fn } of this._resizeListeners) {
      target.removeEventListener(type, fn);
    }
    this._resizeListeners = [];
    this.element.remove();
  }

  // Protected helpers

  protected setFetching(v: boolean): void { this._fetching = v; }
  protected get isFetching(): boolean { return this._fetching; }

  /**
   * Fetch url with exponential-backoff retry (up to maxRetries attempts).
   * Reset this.retryAttempts and this.retryDelay before calling if you
   * want a fresh retry sequence (e.g. from a user-initiated Retry button).
   */
  protected async fetchWithRetry(url: string): Promise<unknown> {
    while (this.retryAttempts < this.maxRetries) {
      try {
        const response = await fetch(url);
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        return await response.json();
      } catch (error) {
        this.retryAttempts++;
        if (this.retryAttempts >= this.maxRetries) {
          throw new Error(
            `Failed after ${this.maxRetries} attempts: ${(error as Error).message}`
          );
        }
        await new Promise(resolve => setTimeout(resolve, this.retryDelay));
        this.retryDelay *= 2;
      }
    }
  }

  // Grid-span persistence
  //
  // Panels live inside a CSS grid. Their size is expressed as span classes
  // (e.g. "col-span-2", "row-span-3"), NOT inline pixel widths/heights.
  // Use saveMap / loadMap to persist this.

  /**
   * Persist an arbitrary key->value map to localStorage under this panel's key.
   * Typical use: save current grid-span class numbers.
   *
   *   this.saveMap({ colSpan: '2', rowSpan: '1', collapsed: 'false' });
   */
  public saveMap(data: Record<string, string>): void {
    localStorage.setItem(`panelState_${this.panelId}`, JSON.stringify(data));
  }

  /**
   * Load the map saved by saveMap(), or null if nothing was stored.
   *
   *   const state = this.loadMap();
   *   if (state) {
   *     this.element.classList.add(`col-span-${state.colSpan}`);
   *     this.element.classList.add(`row-span-${state.rowSpan}`);
   *     if (state.collapsed === 'true') this.element.classList.add('collapsed');
   *   }
   */
  public loadMap(): Record<string, string> | null {
    const raw = localStorage.getItem(`panelState_${this.panelId}`);
    if (!raw) return null;
    try {
      return JSON.parse(raw) as Record<string, string>;
    } catch {
      return null;
    }
  }

  // Private internals

  private _clearCountdown(): void {
    if (this._countdownInterval !== null) {
      clearInterval(this._countdownInterval);
      this._countdownInterval = null;
    }
  }

  /**
   * Wire up the two resize handles.
   * Row handle drags vertically and updates the row-span class.
   * Col handle drags horizontally and updates the col-span class.
   * All listeners are tracked in _resizeListeners for clean destroy().
   */
  private _initResizeHandles(
    rowHandle: HTMLElement,
    colHandle: HTMLElement
  ): void {
    const track = (
      target: EventTarget,
      type: string,
      fn: EventListenerOrEventListenerObject
    ) => {
      target.addEventListener(type, fn);
      this._resizeListeners.push({ target, type, fn });
    };

    // Row resize (bottom handle)
    let rowDragging = false;
    let rowStartY = 0;
    let rowStartSpan = 1;

    const onRowMouseDown = (e: Event) => {
      const me = e as MouseEvent;
      rowDragging = true;
      rowStartY = me.clientY;
      const m = (this.element.className.match(/row-span-(\d+)/) ?? [])[1];
      rowStartSpan = m ? parseInt(m, 10) : 1;
      me.preventDefault();
    };
    const onRowMouseMove = (e: Event) => {
      if (!rowDragging) return;
      const me = e as MouseEvent;
      const delta = Math.round((me.clientY - rowStartY) / 60);
      const newSpan = Math.max(1, Math.min(6, rowStartSpan + delta));
      this.element.className = this.element.className
        .replace(/row-span-\d+/, `row-span-${newSpan}`)
        .trim();
      if (!/row-span-\d+/.test(this.element.className)) {
        this.element.classList.add(`row-span-${newSpan}`);
      }
    };
    const onRowMouseUp = () => { rowDragging = false; };

    track(rowHandle, 'mousedown', onRowMouseDown);
    track(document,  'mousemove', onRowMouseMove);
    track(document,  'mouseup',   onRowMouseUp);

    // Col resize (right handle)
    let colDragging = false;
    let colStartX = 0;
    let colStartSpan = 1;

    const onColMouseDown = (e: Event) => {
      const me = e as MouseEvent;
      colDragging = true;
      colStartX = me.clientX;
      const m = (this.element.className.match(/col-span-(\d+)/) ?? [])[1];
      colStartSpan = m ? parseInt(m, 10) : 1;
      me.preventDefault();
    };
    const onColMouseMove = (e: Event) => {
      if (!colDragging) return;
      const me = e as MouseEvent;
      const delta = Math.round((me.clientX - colStartX) / 120);
      const newSpan = Math.max(1, Math.min(12, colStartSpan + delta));
      this.element.className = this.element.className
        .replace(/col-span-\d+/, `col-span-${newSpan}`)
        .trim();
      if (!/col-span-\d+/.test(this.element.className)) {
        this.element.classList.add(`col-span-${newSpan}`);
      }
    };
    const onColMouseUp = () => { colDragging = false; };

    track(colHandle, 'mousedown', onColMouseDown);
    track(document,  'mousemove', onColMouseMove);
    track(document,  'mouseup',   onColMouseUp);
  }
}

File 2 — src/utils/circuit-breaker.ts

interface CircuitState {
  failures: number;
  cooldownUntil: number;
}

interface CacheEntry<T> {
  data: T;
  timestamp: number;
}

export interface CircuitBreakerOptions {
  name: string;
  maxFailures?: number;   // default 2
  cooldownMs?: number;    // default 5 min
  cacheTtlMs?: number;    // default 10 min
}

export class CircuitBreaker<T> {
  private state: CircuitState = { failures: 0, cooldownUntil: 0 };
  private cache: CacheEntry<T> | null = null;
  private readonly name: string;
  private readonly maxFailures: number;
  private readonly cooldownMs: number;
  private readonly cacheTtlMs: number;

  constructor(options: CircuitBreakerOptions) {
    this.name        = options.name;
    this.maxFailures = options.maxFailures ?? 2;
    this.cooldownMs  = options.cooldownMs  ?? 5 * 60 * 1000;
    this.cacheTtlMs  = options.cacheTtlMs  ?? 10 * 60 * 1000;
  }

  isOnCooldown(): boolean {
    if (Date.now() < this.state.cooldownUntil) return true;
    if (this.state.cooldownUntil > 0) {
      this.state = { failures: 0, cooldownUntil: 0 };
    }
    return false;
  }

  getCached(): T | null {
    if (this.cache && Date.now() - this.cache.timestamp < this.cacheTtlMs) {
      return this.cache.data;
    }
    return null;
  }

  recordSuccess(data: T): void {
    this.state = { failures: 0, cooldownUntil: 0 };
    this.cache = { data, timestamp: Date.now() };
  }

  recordFailure(): void {
    this.state.failures++;
    if (this.state.failures >= this.maxFailures) {
      this.state.cooldownUntil = Date.now() + this.cooldownMs;
      console.warn(`[${this.name}] Circuit open — cooldown ${this.cooldownMs / 1000}s`);
    }
  }

  /**
   * Execute fn. On success, caches result. On failure, records failure and
   * returns defaultValue. While on cooldown or cache is fresh, skips fn
   * entirely and returns cached/default value.
   */
  async execute<R extends T>(fn: () => Promise<R>, defaultValue: R): Promise<R> {
    if (this.isOnCooldown()) {
      const cached = this.getCached();
      return (cached as R) ?? defaultValue;
    }
    const cached = this.getCached();
    if (cached !== null) return cached as R;
    try {
      const result = await fn();
      this.recordSuccess(result);
      return result;
    } catch (e) {
      console.error(`[${this.name}] Request failed:`, e);
      this.recordFailure();
      return defaultValue;
    }
  }
}

export function createCircuitBreaker<T>(
  options: CircuitBreakerOptions
): CircuitBreaker<T> {
  return new CircuitBreaker<T>(options);
}

File 3 — src/utils/sparkline.ts

/**
 * Returns an inline SVG sparkline string, or '' if data is too short.
 * Embed the return value directly in HTML template strings.
 */
export function miniSparkline(
  data: number[] | undefined,
  change: number | null,
  w = 50,
  h = 16
): string {
  if (!data || data.length < 2) return '';
  const min   = Math.min(...data);
  const max   = Math.max(...data);
  const range = max - min || 1;
  const color = change != null && change >= 0 ? 'var(--green)' : 'var(--red)';
  const points = data.map((v, i) => {
    const x = (i / (data.length - 1)) * w;
    const y = h - ((v - min) / range) * (h - 2) - 1;
    return `${x.toFixed(1)},${y.toFixed(1)}`;
  }).join(' ');
  return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">` +
    `<polyline points="${points}" fill="none" stroke="${color}" ` +
    `stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
}

Key differences from the old Panel pattern

FeatureOld patternNew (authoritative) pattern
PersistencesaveState()/loadState() with inline width/height stringssaveMap()/loadMap() with arbitrary key/value map; store grid-span class numbers
ResizeSingle .panel-resize-handleDual handles: .panel-resize-handle-row (vertical) + .panel-resize-handle-col (horizontal)
Loading UISpinner divRadar-ring animation (3 nested .radar-ring divs)
Error UIStatic message + optional retry buttonMessage + optional countdown timer (autoRetryMs) + retry button; countdown clears on manual retry
BadgesNonesetDataBadge(text, variant) / clearDataBadge() and setNewBadge(text) / clearNewBadge()
destroy()element.remove() onlyClears countdown interval + removes all resize document listeners + element.remove()

Creating a Concrete Panel

Option A — Wrapping an existing project service with CircuitBreaker (preferred)

Use this when the project already has a fetchStockQuotes() (or similar) function. The circuit breaker is a one-liner wrapper — no refactoring of the service needed.

import { Panel } from './Panel';
import { createCircuitBreaker } from '../utils/circuit-breaker';
import { fetchStockQuotes } from '../services/stocks'; // existing project service

interface StockQuote {
  symbol: string;
  name: string;
  price: number | null;
  change: number | null;
  sparkline?: number[];
}

// Module-level breaker — shared across re-renders, persists cache between fetches
const breaker = createCircuitBreaker<StockQuote[]>({
  name: 'StockMarket',
  cacheTtlMs: 60_000,
  cooldownMs: 5 * 60_000,
});

export class StockPanel extends Panel {
  private refreshTimer: ReturnType<typeof setInterval> | null = null;

  constructor() {
    super({ id: 'stocks', title: 'Stock Market', showCount: true });

    // Restore grid-span state from localStorage
    const state = this.loadMap();
    if (state) {
      if (state.colSpan) this.element.classList.add(`col-span-${state.colSpan}`);
      if (state.rowSpan) this.element.classList.add(`row-span-${state.rowSpan}`);
      if (state.collapsed === 'true') this.element.classList.add('collapsed');
    }

    this.fetchData();
    this.refreshTimer = setInterval(() => this.fetchData(), 60_000);

    const btn = document.createElement('button');
    btn.className = 'panel-refresh-btn';
    btn.textContent = 'Refresh';
    btn.addEventListener('click', () => this.fetchData());
    this.header.appendChild(btn);

    this.setNewBadge('NEW');
  }

  private async fetchData(): Promise<void> {
    if (this.isFetching) return;
    this.setFetching(true);
    this.showLoading();
    try {
      const quotes = await breaker.execute(() => fetchStockQuotes(), []);
      this.setContent(this.renderQuotes(quotes));
      this.setCount(quotes.length);
      this.setDataBadge('LIVE', 'live');
      const colM = this.element.className.match(/col-span-(\d+)/);
      const rowM = this.element.className.match(/row-span-(\d+)/);
      this.saveMap({
        colSpan: colM ? colM[1] : '1',
        rowSpan: rowM ? rowM[1] : '1',
      });
    } catch (err) {
      this.setDataBadge('ERROR', 'error');
      this.showError('Failed to load stock data', () => this.fetchData(), 30_000);
    } finally {
      this.setFetching(false);
    }
  }

  private renderQuotes(quotes: StockQuote[]): string {
    if (!quotes.length) return '<p class="panel-empty">No data</p>';
    return quotes.map(q => `
      <div class="stock-row">
        <span class="stock-symbol">${q.symbol}</span>
        <span class="stock-name">${q.name}</span>
        <span class="stock-price">${q.price ?? '—'}</span>
        <span class="stock-change ${(q.change ?? 0) >= 0 ? 'positive' : 'negative'}">
          ${q.change != null ? (q.change >= 0 ? '+' : '') + q.change.toFixed(2) + '%' : '—'}
        </span>
      </div>`).join('');
  }

  public override destroy(): void {
    if (this.refreshTimer) clearInterval(this.refreshTimer);
    super.destroy();
  }
}

Option B — Direct fetch with fetchWithRetry (no external service)

Use this when there is no existing service function and you want full control over the HTTP call with per-request retry baked in.

export class NewsPanel extends Panel {
  constructor() {
    super({ id: 'news', title: 'Latest News' });
    const state = this.loadMap();
    if (state?.collapsed === 'true') this.element.classList.add('collapsed');
    this.load();
  }

  private async load(): Promise<void> {
    if (this.isFetching) return;
    this.setFetching(true);
    this.retryAttempts = 0;
    this.retryDelay    = 1000;
    try {
      const data = await this.fetchWithRetry('/api/news') as NewsItem[];
      this.setContent(this.renderNews(data));
      this.setDataBadge('LIVE', 'live');
    } catch (err) {
      this.setDataBadge('ERROR', 'error');
      this.showError('Could not load news', () => this.load(), 15_000);
    } finally {
      this.setFetching(false);
    }
  }

  private renderNews(items: NewsItem[]): string {
    return items.map(i => `<div class="news-item">${i.headline}</div>`).join('');
  }
}

CSS reference for new features

/* Radar loading animation */
.panel-loading-radar {
  position: relative;
  width: 40px;
  height: 40px;
  margin: 0 auto 8px;
}
.radar-ring {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 2px solid var(--accent, #4af);
  opacity: 0;
  animation: radar-pulse 1.8s ease-out infinite;
}
.radar-ring-2 { animation-delay: 0.6s; }
.radar-ring-3 { animation-delay: 1.2s; }
@keyframes radar-pulse {
  0%   { transform: scale(0.3); opacity: 0.8; }
  100% { transform: scale(1.4); opacity: 0; }
}

/* Data badge variants */
.panel-data-badge[data-variant="live"]  { background: var(--green, #2a2); color: #fff; }
.panel-data-badge[data-variant="stale"] { background: var(--yellow, #aa2); color: #fff; }
.panel-data-badge[data-variant="error"] { background: var(--red, #a22);   color: #fff; }

/* Dual resize handles */
.panel-resize-handle-row {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  height: 6px;
  cursor: row-resize;
}
.panel-resize-handle-col {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  width: 6px;
  cursor: col-resize;
}

/* Error countdown */
.panel-error-countdown {
  font-size: 0.75rem;
  opacity: 0.7;
  margin-top: 4px;
}

리뷰

아직 리뷰가 없습니다. 사용해 본 소감을 남겨 주세요.

댓글

로그인 없이 쓸 수 있습니다. 사용 후기, 질문, 작동하지 않은 환경 등을 편하게 남겨 주세요.

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

보내면 바로 공개됩니다. 이메일 주소나 전화번호 같은 개인 정보는 쓰지 마세요. URL은 2개까지입니다.

0 / 1,000자

이 양식은 reCAPTCHA로 보호되며 Google 개인정보처리방침 및 서비스 약관이 적용됩니다.

같은 저장소의 스킬

Prepare audio stems, the separate parts of a track, for mixing by matching their durations with silence, loops, or crossfades. Verify each generated and processed file before moving to the next production step.

한국어 요약은 준비 중입니다. 영어 요약을 표시하고 있습니다.

  • Match audio parts with different durations
  • Extend drums and melodic parts using suitable methods
  • Check audio specifications before mixing
HKUDS/OpenSpace7.8천2026년 8월 13일 업데이트

Produce audio in separate stems, meaning individual instrument tracks, and verify each output as you go. Align track lengths with silence, loops, or crossfades, then apply effects, mix a master, and package the files.

한국어 요약은 준비 중입니다. 영어 요약을 표시하고 있습니다.

  • Align instrument tracks to a reference audio file
  • Choose how to extend tracks that are too short
  • Verify audio properties after generation and effects
HKUDS/OpenSpace7.8천2026년 8월 13일 업데이트

Build serverless endpoints that relay external API requests for a dashboard while keeping API keys on the server. Includes Vercel examples for input checks, caching, cross-origin access, and consistent JSON responses.

한국어 요약은 준비 중입니다. 영어 요약을 표시하고 있습니다.

  • Connect a dashboard to external APIs without exposing API keys
  • Create endpoints for stock quotes and news
  • Standardize API responses and error handling
HKUDS/OpenSpace7.8천2026년 8월 13일 업데이트

Produce a master track and separate audio parts through a structured Python workflow. Apply filters and reverb, export WAV files with consistent settings, package them in a ZIP archive, and check that the deliverables meet the requested specifications.

한국어 요약은 준비 중입니다. 영어 요약을 표시하고 있습니다.

  • Export a master track with separate audio parts
  • Apply filters and reverb to audio
  • Check duration and format across deliverables
HKUDS/OpenSpace7.8천2026년 8월 13일 업데이트

Keep document creation moving when web searches and page retrieval repeatedly fail. The skill preserves the original task, uses the model’s existing knowledge, and clearly identifies information that could not be checked against external sources.

한국어 요약은 준비 중입니다. 영어 요약을 표시하고 있습니다.

  • Continue drafting after repeated web retrieval failures
  • Preserve task requirements during network errors
  • Mark information that could not be externally verified
HKUDS/OpenSpace7.8천2026년 8월 13일 업데이트

When Python execution repeatedly fails in execute_code_sandbox, use a file-based alternative: save the script, run it through the shell, and capture results or errors. The guidance covers paths, imports, timeouts, structured output, and optional cleanup.

한국어 요약은 준비 중입니다. 영어 요약을 표시하고 있습니다.

  • Retry Python work through the shell after repeated sandbox failures
  • Run longer tasks with an adjusted timeout
  • Execute Python work that needs multiple files or system utilities
HKUDS/OpenSpace7.8천2026년 8월 13일 업데이트

HKUDS의 스킬 모두 보기

이 스킬의 문제 신고