generated from coulomb/repo-seed
Vendor whynot-design Layer 1 (tokens, CSS) and Layer 2 (<wn-*> components) via scripts/sync-whynot-design.sh with a pinned ref. Migrate the observatory shell to canonical web components, keep observatory-specific layout in styles.css, and add vendor integrity tests plus correct JS MIME types on the dev server.
207 lines
7.8 KiB
JavaScript
207 lines
7.8 KiB
JavaScript
/* =============================================================
|
|
* @whynot/design — chrome.js
|
|
* ------------------------------------------------------------
|
|
* <wn-top-nav>, <wn-sidebar> / <wn-sidebar-group> /
|
|
* <wn-sidebar-item>, <wn-page-header>, <wn-pipeline>,
|
|
* <wn-prototype-card>
|
|
* ============================================================= */
|
|
|
|
import { LitElement, html, nothing } from "lit";
|
|
import { WnBase } from "./atoms.js";
|
|
|
|
/* ---------- <wn-top-nav> ---------- */
|
|
export class WnTopNav extends WnBase {
|
|
static properties = {
|
|
logoSrc: { type: String, attribute: "logo-src" },
|
|
brand: { type: String },
|
|
slug: { type: String },
|
|
};
|
|
constructor() { super(); this.brand = "whynot"; this.slug = "control"; }
|
|
render() {
|
|
return html`
|
|
<nav class="wn-topnav" part="nav">
|
|
<div class="wn-topnav__brand">
|
|
${this.logoSrc ? html`<img src=${this.logoSrc} alt="">` : nothing}
|
|
<span>${this.brand}</span>
|
|
${this.slug ? html`<span class="wn-topnav__brand-slug">/ ${this.slug}</span>` : nothing}
|
|
</div>
|
|
<div class="wn-topnav__links"><slot name="links"></slot></div>
|
|
<div class="wn-topnav__right"><slot name="right"></slot></div>
|
|
</nav>
|
|
`;
|
|
}
|
|
}
|
|
|
|
/* ---------- <wn-sidebar> ---------- */
|
|
export class WnSidebar extends WnBase {
|
|
static properties = { activation: { type: String } };
|
|
render() {
|
|
return html`
|
|
<aside class="wn-sidebar" part="sidebar">
|
|
<slot></slot>
|
|
${this.activation
|
|
? html`<div class="wn-sidebar__footer">
|
|
<div class="wn-sidebar__activation">
|
|
<span class="wn-sidebar__activation-dot"></span>
|
|
<span>${this.activation}</span>
|
|
</div>
|
|
</div>`
|
|
: nothing}
|
|
</aside>
|
|
`;
|
|
}
|
|
}
|
|
|
|
export class WnSidebarGroup extends WnBase {
|
|
static properties = { label: { type: String } };
|
|
render() {
|
|
return html`
|
|
<div class="wn-sidebar__group" part="group">
|
|
${this.label ? html`<wn-eyebrow class="wn-sidebar__group-label">${this.label}</wn-eyebrow>` : nothing}
|
|
<slot></slot>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
export class WnSidebarItem extends WnBase {
|
|
static properties = {
|
|
href: { type: String },
|
|
icon: { type: String },
|
|
active: { type: Boolean, reflect: true },
|
|
count: { type: String },
|
|
variant: { type: String, reflect: true },
|
|
};
|
|
render() {
|
|
const cls = ["wn-sidebar__item",
|
|
this.active ? "wn-sidebar__item--active" : "",
|
|
this.variant === "doc" ? "wn-sidebar__item--doc" : "",
|
|
].filter(Boolean).join(" ");
|
|
const inner = html`
|
|
${this.icon ? html`<wn-icon name=${this.icon} size=${this.variant === "doc" ? "sm" : "md"}></wn-icon>` : nothing}
|
|
<slot></slot>
|
|
${this.count ? html`<span class="wn-sidebar__count">${this.count}</span>` : nothing}
|
|
`;
|
|
return this.href
|
|
? html`<a class=${cls} href=${this.href} part="item">${inner}</a>`
|
|
: html`<div class=${cls} part="item">${inner}</div>`;
|
|
}
|
|
}
|
|
|
|
/* ---------- <wn-page-header> ---------- */
|
|
export class WnPageHeader extends WnBase {
|
|
static properties = {
|
|
eyebrow: { type: String },
|
|
title: { type: String },
|
|
lede: { type: String },
|
|
hasActions: { state: true },
|
|
};
|
|
constructor() { super(); this.hasActions = false; }
|
|
_onSlot() { this.hasActions = !!this.querySelector('[slot="actions"]'); }
|
|
render() {
|
|
return html`
|
|
<header class="wn-page-header" part="root">
|
|
${this.eyebrow ? html`<wn-eyebrow>${this.eyebrow}</wn-eyebrow>` : nothing}
|
|
<slot name="eyebrow"></slot>
|
|
<div class="wn-page-header__row">
|
|
<h1 class="wn-page-header__title">${this.title}<slot name="title"></slot></h1>
|
|
<div class="wn-page-header__actions" ?hidden=${!this.hasActions}>
|
|
<slot name="actions" @slotchange=${this._onSlot}></slot>
|
|
</div>
|
|
</div>
|
|
${this.lede ? html`<p class="wn-page-header__lede">${this.lede}</p>` : nothing}
|
|
<slot name="lede"></slot>
|
|
</header>
|
|
`;
|
|
}
|
|
}
|
|
|
|
/* ---------- <wn-pipeline> ---------- */
|
|
export class WnPipeline extends WnBase {
|
|
static properties = {
|
|
stages: { type: Array },
|
|
activeIdx: { type: Number, attribute: "active-idx" },
|
|
};
|
|
constructor() {
|
|
super();
|
|
this.stages = [
|
|
{ num: "Stage 0", name: "Raw idea", meta: "inbox/" },
|
|
{ num: "Stage 1", name: "Triage", meta: "" },
|
|
{ num: "Stage 2", name: "Prototype card", meta: "prototypes/" },
|
|
{ num: "Stage 3", name: "Experiment", meta: "" },
|
|
{ num: "Stage 4", name: "Signal review", meta: "" },
|
|
];
|
|
this.activeIdx = 0;
|
|
}
|
|
render() {
|
|
return html`
|
|
<div class="wn-pipeline" part="root">
|
|
${this.stages.map((s, i) => {
|
|
const state = i < this.activeIdx ? "done" : i === this.activeIdx ? "active" : "pending";
|
|
const cls = `wn-pipeline__stage wn-pipeline__stage--${state}`;
|
|
return html`
|
|
<div class=${cls}>
|
|
<span class="wn-pipeline__num">${s.num}</span>
|
|
<span class="wn-pipeline__name">${s.name}</span>
|
|
${s.meta ? html`<span class="wn-pipeline__meta">${s.meta}</span>` : nothing}
|
|
${i > 0 ? html`<span class="wn-pipeline__arrow">→</span>` : nothing}
|
|
</div>
|
|
`;
|
|
})}
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
/* ---------- <wn-prototype-card> ---------- */
|
|
export class WnPrototypeCard extends WnBase {
|
|
static properties = {
|
|
cardId: { type: String, attribute: "card-id", reflect: true },
|
|
signal: { type: String, reflect: true },
|
|
stageLabel: { type: String, attribute: "stage-label" },
|
|
href: { type: String },
|
|
};
|
|
constructor() { super(); this.signal = "S1"; }
|
|
_onClick() {
|
|
if (this.href) window.location.href = this.href;
|
|
this.dispatchEvent(new CustomEvent("wn-open", { detail: { id: this.cardId }, bubbles: true, composed: true }));
|
|
}
|
|
render() {
|
|
const clickable = !!this.href;
|
|
const cls = "wn-card wn-prototype-card" + (clickable ? " wn-card--clickable" : "");
|
|
return html`
|
|
<article class=${cls}
|
|
role=${clickable ? "button" : nothing}
|
|
tabindex=${clickable ? "0" : nothing}
|
|
@click=${clickable ? this._onClick : nothing}
|
|
part="card">
|
|
<header class="wn-card__head">
|
|
<wn-eyebrow>${this.cardId ? this.cardId + " · " : ""}Prototype</wn-eyebrow>
|
|
<wn-stage-dot level=${this.signal}>${this.stageLabel || this.signal}</wn-stage-dot>
|
|
</header>
|
|
<h3 class="wn-card__title"><slot name="pitch"></slot></h3>
|
|
<div class="wn-prototype-card__qrow">
|
|
<span class="wn-prototype-card__qkey">Learning q.</span>
|
|
<span class="wn-prototype-card__qval"><slot name="learning"></slot></span>
|
|
<span class="wn-prototype-card__qkey">Smallest test</span>
|
|
<span class="wn-prototype-card__qval"><slot name="test"></slot></span>
|
|
</div>
|
|
<footer class="wn-card__foot">
|
|
<span><slot name="target"></slot></span>
|
|
<span>${this.signal} signal</span>
|
|
</footer>
|
|
</article>
|
|
`;
|
|
}
|
|
}
|
|
|
|
export function defineChrome() {
|
|
if (!customElements.get("wn-top-nav")) customElements.define("wn-top-nav", WnTopNav);
|
|
if (!customElements.get("wn-sidebar")) customElements.define("wn-sidebar", WnSidebar);
|
|
if (!customElements.get("wn-sidebar-group")) customElements.define("wn-sidebar-group", WnSidebarGroup);
|
|
if (!customElements.get("wn-sidebar-item")) customElements.define("wn-sidebar-item", WnSidebarItem);
|
|
if (!customElements.get("wn-page-header")) customElements.define("wn-page-header", WnPageHeader);
|
|
if (!customElements.get("wn-pipeline")) customElements.define("wn-pipeline", WnPipeline);
|
|
if (!customElements.get("wn-prototype-card")) customElements.define("wn-prototype-card", WnPrototypeCard);
|
|
}
|