This feature is experimental and may not be working correctly.
diff --git a/css/tailwind.source.css b/css/tailwind.source.css
index 88849605..eb586563 100644
--- a/css/tailwind.source.css
+++ b/css/tailwind.source.css
@@ -637,19 +637,87 @@
}
.badge {
- @apply inline-flex items-center gap-1 rounded-full bg-panel px-3 py-1 text-xs font-semibold uppercase tracking-wide text-muted-strong;
- }
-
- .badge[data-variant="critical"] {
- @apply bg-critical bg-opacity-20 text-critical-strong;
+ @apply inline-flex items-center gap-1 rounded-full border border-status-neutral-border bg-status-neutral-surface px-3 py-1 text-2xs font-semibold uppercase tracking-extra-wide text-status-neutral-on;
}
.badge[data-variant="info"] {
- @apply bg-info bg-opacity-20 text-info-strong;
+ @apply border-status-info-border bg-status-info-surface text-status-info-on;
+ }
+
+ .badge[data-variant="success"] {
+ @apply border-status-success-border bg-status-success-surface text-status-success-on;
+ }
+
+ .badge[data-variant="warning"] {
+ @apply border-status-warning-border bg-status-warning-surface text-status-warning-on;
+ }
+
+ .badge[data-variant="critical"] {
+ @apply border-status-danger-border bg-status-danger-surface text-status-danger-on;
+ }
+
+ .badge[data-variant="private"] {
+ @apply border-status-private-border bg-status-private-surface text-status-private-on;
}
.badge[data-variant="neutral"] {
- @apply bg-border bg-opacity-40 text-muted-strong;
+ @apply border-status-neutral-border bg-status-neutral-surface text-status-neutral-on;
+ }
+
+ .pill {
+ @apply inline-flex items-center gap-2 rounded-full border border-status-neutral-border bg-status-neutral-surface px-3 py-1 text-xs font-medium text-status-neutral-on;
+ }
+
+ .pill[data-size="compact"] {
+ @apply px-2 py-0.5 text-2xs;
+ }
+
+ .pill[data-variant="info"] {
+ @apply border-status-info-border bg-status-info-surface text-status-info-on;
+ }
+
+ .pill[data-variant="success"] {
+ @apply border-status-success-border bg-status-success-surface text-status-success-on;
+ }
+
+ .pill[data-variant="warning"] {
+ @apply border-status-warning-border bg-status-warning-surface text-status-warning-on;
+ }
+
+ .pill[data-variant="critical"] {
+ @apply border-status-danger-border bg-status-danger-surface text-status-danger-on;
+ }
+
+ .pill[data-variant="private"] {
+ @apply border-status-private-border bg-status-private-surface text-status-private-on;
+ }
+
+ .pill[data-variant="neutral"] {
+ @apply border-status-neutral-border bg-status-neutral-surface text-status-neutral-on;
+ }
+
+ .overlay-scrim {
+ background-color: var(--surface-overlay);
+ }
+
+ .overlay-scrim[data-strength="muted"] {
+ background-color: var(--surface-overlay-muted);
+ }
+
+ .overlay-scrim[data-strength="strong"] {
+ background-color: var(--surface-overlay-strong);
+ }
+
+ .overlay-panel {
+ background-color: var(--surface-overlay-panel);
+ }
+
+ .overlay-panel[data-variant="soft"] {
+ background-color: var(--surface-overlay-panel-soft);
+ }
+
+ .overlay-panel[data-variant="glass"] {
+ background-color: var(--surface-overlay-panel-glass);
}
.menu {
diff --git a/css/tokens.css b/css/tokens.css
index dfd55e06..35e53105 100644
--- a/css/tokens.css
+++ b/css/tokens.css
@@ -101,6 +101,88 @@
--color-text-frosted: rgb(226 232 240 / 0.9);
--color-text-frosted-soft: rgb(226 232 240 / 0.82);
+ /* Semantic palette */
+ --surface-base: var(--color-surface);
+ --surface-alt: var(--color-surface-alt);
+ --surface-raised: var(--color-card);
+ --surface-raised-private: var(--color-card-private);
+ --surface-raised-critical: var(--color-card-critical);
+ --surface-panel: var(--color-panel);
+ --surface-panel-hover: var(--color-panel-hover);
+ --surface-sunken: rgb(11 17 32 / 0.92);
+ --surface-muted: rgb(30 41 59 / 0.82);
+ --surface-veil: var(--color-overlay-veil);
+ --surface-inverse: var(--color-page);
+
+ --surface-overlay: var(--color-overlay);
+ --surface-overlay-muted: var(--color-overlay-muted);
+ --surface-overlay-strong: var(--color-overlay-strong);
+ --surface-overlay-darker: var(--color-overlay-darker);
+ --surface-overlay-black: var(--color-overlay-black);
+ --surface-overlay-banner: var(--color-overlay-banner);
+ --surface-overlay-panel: var(--color-overlay-panel);
+ --surface-overlay-panel-soft: var(--color-overlay-panel-soft);
+ --surface-overlay-panel-tint: var(--color-overlay-panel-tint);
+ --surface-overlay-panel-glass: var(--color-overlay-panel-glass);
+ --surface-overlay-panel-glass-soft: var(--color-overlay-panel-glass-soft);
+ --surface-modal-backdrop: var(--color-modal-backdrop);
+ --surface-modal: var(--color-modal-surface);
+
+ --border-default: var(--color-border);
+ --border-subtle: var(--color-border-subtle);
+ --border-strong: var(--color-border-translucent-stronger);
+ --border-overlay: var(--color-border-translucent);
+ --border-overlay-strong: var(--color-border-translucent-strongest);
+ --border-overlay-glow: var(--color-border-translucent-glow);
+ --border-neutral-soft: var(--color-border-neutral-soft);
+ --border-info-soft: var(--color-border-info-soft);
+ --border-sidebar-strong: var(--color-border-sidebar-strong);
+
+ --text-primary: var(--color-text);
+ --text-inverse: var(--color-text-strong);
+ --text-muted: var(--color-text-muted);
+ --text-muted-strong: var(--color-muted-strong);
+ --text-subtle: var(--color-subtle-text);
+ --text-frosted-primary: var(--color-text-frosted);
+ --text-frosted-strong: var(--color-text-frosted-strong);
+ --text-frosted-soft: var(--color-text-frosted-soft);
+
+ --status-info: var(--color-info);
+ --status-info-strong: var(--color-info-strong);
+ --status-info-surface: var(--color-info-glass);
+ --status-info-border: rgb(96 165 250 / 0.4);
+ --status-info-on: rgb(219 234 254);
+
+ --status-success: rgb(34 197 94);
+ --status-success-strong: rgb(22 163 74);
+ --status-success-surface: rgb(22 163 74 / 0.28);
+ --status-success-border: rgb(34 197 94 / 0.4);
+ --status-success-on: rgb(220 252 231);
+
+ --status-warning: var(--color-warning);
+ --status-warning-strong: var(--color-warning-strong);
+ --status-warning-surface: var(--color-warning-surface);
+ --status-warning-border: rgb(217 119 6 / 0.45);
+ --status-warning-on: rgb(254 249 195);
+
+ --status-danger: var(--color-critical);
+ --status-danger-strong: var(--color-critical-strong);
+ --status-danger-surface: rgb(127 29 29 / 0.32);
+ --status-danger-border: rgb(239 68 68 / 0.45);
+ --status-danger-on: rgb(254 202 202);
+
+ --status-private: rgb(168 85 247);
+ --status-private-strong: rgb(147 51 234);
+ --status-private-surface: rgb(91 33 182 / 0.32);
+ --status-private-border: rgb(168 85 247 / 0.45);
+ --status-private-on: rgb(233 213 255);
+
+ --status-neutral: var(--color-muted);
+ --status-neutral-strong: var(--color-muted-strong);
+ --status-neutral-surface: rgb(148 163 184 / 0.25);
+ --status-neutral-border: rgb(148 163 184 / 0.35);
+ --status-neutral-on: rgb(226 232 240);
+
/* Layout & stacking */
--scrollbar-width: 0.5rem;
--scrollbar-track: var(--color-white-soft);
@@ -530,6 +612,24 @@
--shadow-sidebar-panel: 0 2.25rem 4.5rem -1.85rem rgb(15 23 42 / 0.28);
--shadow-sidebar-focus-ring: 0 0 0 0.125rem rgb(59 130 246 / 0.35),
0 0 0 0.25rem rgb(59 130 246 / 0.28);
+
+ --surface-sunken: rgb(229 231 235 / 0.85);
+ --surface-muted: rgb(226 232 240 / 0.85);
+ --text-primary: var(--color-text);
+ --text-inverse: var(--color-white);
+ --text-muted: var(--color-text-muted);
+ --text-muted-strong: var(--color-muted-strong);
+ --text-subtle: var(--color-subtle-text);
+ --status-info-border: rgb(59 130 246 / 0.35);
+ --status-success-surface: rgb(34 197 94 / 0.18);
+ --status-success-border: rgb(16 185 129 / 0.32);
+ --status-warning-border: rgb(217 119 6 / 0.35);
+ --status-danger-surface: rgb(220 38 38 / 0.18);
+ --status-danger-border: rgb(220 38 38 / 0.32);
+ --status-private-surface: rgb(147 51 234 / 0.22);
+ --status-private-border: rgb(147 51 234 / 0.32);
+ --status-neutral-surface: rgb(100 116 139 / 0.18);
+ --status-neutral-border: rgb(100 116 139 / 0.3);
}
:root[data-theme="dark"] {
diff --git a/docs/design-system.md b/docs/design-system.md
index 28e46606..f0a9bf41 100644
--- a/docs/design-system.md
+++ b/docs/design-system.md
@@ -85,6 +85,22 @@ The blog embed now consumes dedicated typography and spacing tokens alongside Ta
Templates should reference these tokens (directly or via Tailwind arbitrary values) instead of hard-coded measurements so the embed stays in sync with future blog refreshes.
+## Semantic Palette
+
+All interactive surfaces now draw from a shared semantic color map exposed through `css/tokens.css` and Tailwind utility aliases. The palette is grouped into text, surface, overlay, and status slots so controllers can swap state styles without reaching for raw Tailwind hues:
+
+- **Text:** `text-primary`, `text-inverse`, `text-muted`, `text-muted-strong`, and `text-subtle` map to `--text-*` tokens. Use them for copy, helper text, and frosted overlays instead of `text-gray-*`.
+- **Surfaces:** `bg-surface`, `bg-surface-alt`, `bg-surface-raised`, `bg-surface-muted`, and `bg-overlay-*` wrap cards, modals, and scrims. Pair them with `border-surface`, `border-overlay`, or `border-overlay-strong` when rendering shells.
+- **Statuses:** `status-*` tokens expose matching text, background, and border slots. Tailwind utilities include `text-status-success`, `bg-status-warning-surface`, `border-status-danger-border`, and chip helpers (`.pill`, `.badge`) that accept `data-variant="info" | "success" | "warning" | "critical" | "private" | "neutral"`.
+- **Overlays:** `.overlay-scrim` and `.overlay-panel` helpers consume the overlay tier tokens for modals, sheets, and popovers. Adjust the strength via `data-strength`/`data-variant` rather than applying translucent grays.
+
+### Migration guidance
+
+- Swap raw Tailwind palette utilities (for example, `text-gray-400`, `bg-amber-500/10`, `border-red-700/70`) for the matching semantic utilities above. The new lint (`npm run lint:tailwind-colors`) fails when legacy color classes appear outside deprecated directories.
+- Replace ad-hoc chips (`inline-flex ... border-red-700/70 bg-red-900/40`) with `.pill` or `.badge` plus a `data-variant`. These helpers automatically pull the correct status text/border/fill tokens.
+- Use `text-info`/`hover:text-info-strong` for links that previously relied on `text-blue-400` so anchors adapt in both light and dark modes.
+- When rendering tinted descriptions or placeholders, prefer `text-muted`, `text-muted-strong`, or `text-subtle` over semi-transparent whites. Token overrides keep the same classes usable across the default and light themes.
+
### Blog carousel markup
The short-notes carousel ships as a semantic wrapper around the `blog-carousel` primitive defined in `css/tailwind.source.css`. The runtime no longer injects inline styles or Splide classes—layout is driven entirely by data attributes that map onto tokenised CSS rules.
diff --git a/js/r2-quick.js b/js/r2-quick.js
index 0265f7b7..ff6b3cbf 100644
--- a/js/r2-quick.js
+++ b/js/r2-quick.js
@@ -10,16 +10,17 @@ import { makeR2Client, multipartUpload } from "./storage/r2-s3.js";
const STORAGE_KEY = "bitvid:quickR2Settings";
const STATUS_CLASSNAMES = [
- "text-gray-400",
- "text-green-400",
- "text-red-400",
- "text-yellow-400",
+ "text-status-neutral",
+ "text-status-info",
+ "text-status-success",
+ "text-status-danger",
+ "text-status-warning",
];
const STATUS_TONES = {
- info: "text-gray-400",
- success: "text-green-400",
- error: "text-red-400",
- warning: "text-yellow-400",
+ info: "text-status-info",
+ success: "text-status-success",
+ error: "text-status-danger",
+ warning: "text-status-warning",
};
function sanitizeDomain(value) {
@@ -71,7 +72,7 @@ function setStatus(el, message, tone = "info") {
return;
}
STATUS_CLASSNAMES.forEach((cls) => el.classList.remove(cls));
- const className = STATUS_TONES[tone] || STATUS_TONES.info;
+ const className = STATUS_TONES[tone] || "text-status-neutral";
if (className) {
el.classList.add(className);
}
diff --git a/js/ui/components/RevertModal.js b/js/ui/components/RevertModal.js
index 3c2c3387..3223b999 100644
--- a/js/ui/components/RevertModal.js
+++ b/js/ui/components/RevertModal.js
@@ -385,7 +385,7 @@ export class RevertModal {
if (!this.revisions.length) {
this.list.innerHTML =
- '
No revisions found.
';
+ '
No revisions found.
';
return;
}
@@ -417,36 +417,36 @@ export class RevertModal {
"focus:outline-none",
"focus:ring-2",
"focus:ring-offset-2",
- "focus:ring-offset-gray-900",
+ "focus:ring-offset-surface",
];
if (isSelected) {
classes.push(
- "border-blue-500",
- "bg-blue-500/20",
- "text-blue-100",
- "focus:ring-blue-500"
+ "border-status-info-border",
+ "bg-status-info-surface",
+ "text-status-info-on",
+ "focus:ring-status-info"
);
} else if (isCurrent) {
classes.push(
- "border-green-500/60",
- "bg-green-500/10",
- "text-green-100",
- "focus:ring-green-500/80"
+ "border-status-success-border",
+ "bg-status-success-surface",
+ "text-status-success-on",
+ "focus:ring-status-success"
);
} else if (isDeleted) {
classes.push(
- "border-red-800/70",
- "bg-red-900/30",
- "text-red-200/90",
- "hover:bg-red-900/40"
+ "border-status-danger-border",
+ "bg-status-danger-surface",
+ "text-status-danger-on",
+ "hover:border-status-danger-border"
);
} else {
classes.push(
- "border-gray-800",
- "bg-gray-800/60",
- "hover:bg-gray-700/70",
- "text-gray-200"
+ "border-overlay",
+ "bg-overlay-panel-soft",
+ "hover:bg-overlay-panel",
+ "text-primary"
);
}
@@ -478,8 +478,8 @@ export class RevertModal {
}
const meta = metaParts.join(" • ");
const metaClass = entry.deleted
- ? "text-red-200/80"
- : "text-gray-400";
+ ? "text-status-danger-on"
+ : "text-muted";
const escape = (value) =>
this.escapeHTML ? this.escapeHTML(String(value ?? "")) : String(value ?? "");
@@ -488,10 +488,10 @@ export class RevertModal {
${escape(entry.title || "Untitled")}
-
${escape(relative)} • ${escape(absolute)}
+
${escape(relative)} • ${escape(absolute)}
${meta ? `
${escape(meta)}
` : ""}
-
+
${escape(versionLabel)}
@@ -588,7 +588,7 @@ export class RevertModal {
: displayMagnet;
const label = escape(labelRaw);
const caption = isPrivate
- ? '
Magnet stays visible only to you — private notes keep the raw string local.'
+ ? '
Magnet stays visible only to you — private notes keep the raw string local.'
: "";
magnetHtml = `
${label}${caption}
`;
}
@@ -599,33 +599,33 @@ export class RevertModal {
const conflictingAudienceFlags = nsfwFlag && forKidsFlag;
if (version.deleted) {
chips.push(
- '
Marked deleted'
+ '
Marked deleted'
);
}
if (isPrivate) {
chips.push(
- '
Private'
+ '
Private'
);
}
if (conflictingAudienceFlags) {
chips.push(
- '
NSFW + For kids conflict'
+ '
NSFW + For kids conflict'
);
} else {
if (nsfwFlag) {
chips.push(
- '
Marked NSFW'
+ '
Marked NSFW'
);
}
if (forKidsFlag) {
chips.push(
- '
For kids'
+ '
For kids'
);
}
}
if (version.version !== undefined) {
chips.push(
- `
Schema v${escape(
+ `Schema v${escape(
String(version.version)
)}`
);
@@ -637,10 +637,10 @@ export class RevertModal {
unspecifiedLabel = "Not specified",
} = {}) => {
if (value === true) {
- return `${escape(yesLabel)}`;
+ return `${escape(yesLabel)}`;
}
if (value === false) {
- return `${escape(noLabel)}`;
+ return `${escape(noLabel)}`;
}
return this.renderPlaceholder(unspecifiedLabel);
};
@@ -657,12 +657,12 @@ export class RevertModal {
});
const audienceConflictNotice = conflictingAudienceFlags
- ? 'Conflicting flags detected — this revision is marked both NSFW and for kids. Review before reverting.
'
+ ? 'Conflicting flags detected — this revision is marked both NSFW and for kids. Review before reverting.
'
: "";
const descriptionHtml = description
- ? `${escape(description)}
`
- : 'No description provided.
';
+ ? `${escape(description)}
`
+ : 'No description provided.
';
const rootId =
typeof version.videoRootId === "string" ? version.videoRootId : "";
@@ -698,17 +698,17 @@ export class RevertModal {
: "";
const parts = [];
if (absoluteLabel) {
- parts.push(`${escape(absoluteLabel)}`);
+ parts.push(`${escape(absoluteLabel)}`);
}
if (relativeLabel) {
- parts.push(`(${escape(relativeLabel)})`);
+ parts.push(`(${escape(relativeLabel)})`);
}
if (parts.length) {
return parts.join(" ");
}
}
if (raw) {
- return `${escape(raw)}`;
+ return `${escape(raw)}`;
}
return this.renderPlaceholder();
};
@@ -717,12 +717,12 @@ export class RevertModal {
if (Number.isFinite(seconds)) {
const display = this.formatDurationSeconds(seconds);
const suffix = seconds === 1 ? "second" : "seconds";
- return `${escape(display)} (${escape(
+ return `${escape(display)} (${escape(
`${seconds} ${suffix}`
)})`;
}
if (raw) {
- return `${escape(raw)}`;
+ return `${escape(raw)}`;
}
return this.renderPlaceholder();
};
@@ -740,21 +740,21 @@ export class RevertModal {
suffix = "video";
}
}
- const badge = `kind ${escape(
+ const badge = `kind ${escape(
label
)}`;
kindHtml = suffix
- ? `${badge} (${escape(suffix)})`
+ ? `${badge} (${escape(suffix)})`
: badge;
}
const summaryHtml = nip71Metadata.summary
- ? `${escape(
+ ? `
${escape(
nip71Metadata.summary
)}
`
: this.renderPlaceholder("Not provided");
const contentWarningHtml = nip71Metadata.contentWarning
- ? `${escape(
+ ? `${escape(
nip71Metadata.contentWarning
)}`
: this.renderPlaceholder("Not provided");
@@ -767,14 +767,14 @@ export class RevertModal {
nip71Metadata.durationRaw
);
const altHtml = nip71Metadata.alt
- ? `${escape(nip71Metadata.alt)}
`
+ ? `${escape(nip71Metadata.alt)}
`
: this.renderPlaceholder("Not provided");
const definition = (label, valueHtml, { span = 1 } = {}) => {
const colSpan = span > 1 ? "sm:col-span-2" : "";
return `
-
${escape(label)}
+ ${escape(label)}
${valueHtml}
`;
@@ -797,7 +797,7 @@ export class RevertModal {
this.details.innerHTML = `
-
+
-
${escape(
+ ${escape(
version.title || "Untitled"
)}
-
${timestampHtml}
+
${timestampHtml}
${
chips.length
? `
${chips.join("")}
`
: ""
}
-
+
-
- Hosted URL
+ - Hosted URL
- ${urlHtml}
-
- Magnet
+ - Magnet
- ${magnetHtml}
@@ -831,65 +831,65 @@ export class RevertModal {
- Description
+ Description
${descriptionHtml}
- Audience flags
-
+ Audience flags
+
${audienceRows}
${audienceConflictNotice}
- NIP-71 event metadata
-
+ NIP-71 event metadata
+
${eventMetadataRows}
- Note pointers
-
+ Note pointers
+
-
- Mode
+ - Mode
- ${escape(version.mode || "live")}
-
- d tag
+ - d tag
-
${
dTagValue
- ? `
${escape(
+ ? `${escape(
dTagValue
)}`
- : 'Not provided'
+ : 'Not provided'
}
-
- videoRootId
+ - videoRootId
-
${
rootDisplay
- ? `
${rootDisplay}`
- : 'Not provided'
+ : 'Not provided'
}
-
- Event ID
+ - Event ID
-
${
eventDisplay
- ? `
${eventDisplay}`
- : 'Unknown'
+ : 'Unknown'
}
@@ -897,30 +897,30 @@ export class RevertModal {
- NIP-71 media metadata
+ NIP-71 media metadata
-
Media variants (imeta)
+ Media variants (imeta)
${this.renderImetaVariants(nip71Metadata.imeta)}
-
Caption tracks
+ Caption tracks
${this.renderTextTracks(nip71Metadata.textTracks)}
-
Chapters
+ Chapters
${this.renderSegments(nip71Metadata.segments)}
-
Hashtags
+ Hashtags
${this.renderHashtags(nip71Metadata.hashtags)}
-
Participants
+ Participants
${this.renderParticipants(nip71Metadata.participants)}
-
References
+ References
${this.renderReferences(nip71Metadata.references)}
@@ -1123,12 +1123,12 @@ export class RevertModal {
renderPlaceholder(text = "Not provided") {
const escape = this.getEscapeFn();
- return `${escape(text)}`;
+ return `${escape(text)}`;
}
renderListEmpty(text) {
const escape = this.getEscapeFn();
- return `${escape(text)}
`;
+ return `${escape(text)}
`;
}
buildLinkMarkup(value, { breakAll = true } = {}) {
@@ -1150,10 +1150,10 @@ export class RevertModal {
const breakClass = breakAll ? " break-all" : " break-words";
if (/^https?:/i.test(raw)) {
- return `${safeDisplay}`;
+ return `${safeDisplay}`;
}
- return `${safeDisplay}`;
+ return `${safeDisplay}`;
}
formatDurationSeconds(seconds) {
@@ -1365,19 +1365,19 @@ export class RevertModal {
const badges = [];
if (variant.autoGenerated) {
badges.push(
- 'Auto-generated'
+ 'Auto-generated'
);
}
const variantNumber = `Variant ${index + 1}`;
const mimeHtml = variant.m
- ? `${escape(
+ ? `${escape(
variant.m
)}`
: this.renderPlaceholder();
const dimHtml = variant.dim
- ? `${escape(
+ ? `${escape(
variant.dim
)}`
: this.renderPlaceholder();
@@ -1385,14 +1385,14 @@ export class RevertModal {
? this.buildLinkMarkup(variant.url)
: this.renderPlaceholder("No URL");
const hashHtml = variant.x
- ? `${escape(
+ ? `${escape(
variant.x
)}`
: this.renderPlaceholder("Not provided");
const renderNestedList = (label, values, emptyLabel) => {
if (!Array.isArray(values) || values.length === 0) {
- return `${escape(
+ return `
${escape(
label
)}
${this.renderListEmpty(emptyLabel)}
`;
}
@@ -1403,17 +1403,17 @@ export class RevertModal {
.map((markup) => `
${markup}`);
if (!items.length) {
- return `
${escape(
+ return `
${escape(
label
)}
${this.renderListEmpty(emptyLabel)}
`;
}
return `
-
${escape(
+
${escape(
label
)}
-
+
`;
};
@@ -1425,30 +1425,30 @@ export class RevertModal {
].join("");
return `
-
-
-
${escape(variantNumber)}
+
+
+
${escape(variantNumber)}
${badges.length ? `
${badges.join("")}
` : ""}
-
+
-
- MIME type
+ - MIME type
- ${mimeHtml}
-
- Dimensions
+ - Dimensions
- ${dimHtml}
-
- URL
+ - URL
- ${urlHtml}
-
- Content hash
+ - Content hash
- ${hashHtml}
-
+
${nestedSections}
@@ -1471,33 +1471,33 @@ export class RevertModal {
? this.buildLinkMarkup(track.url)
: this.renderPlaceholder("No URL");
const typeHtml = track.type
- ? `
${escape(
+ ? `${escape(
track.type
)}`
: this.renderPlaceholder();
const languageHtml = track.language
- ? `${escape(
+ ? `${escape(
track.language
)}`
: this.renderPlaceholder();
return `
- -
-
-
${escape(headerLabel)}
+
-
+
+ ${escape(headerLabel)}
${track.language ? languageHtml : ""}
-
+
-
- URL
+ - URL
- ${urlHtml}
-
- Type
+ - Type
- ${typeHtml}
-
- Language
+ - Language
- ${track.language ? languageHtml : this.renderPlaceholder()}
@@ -1518,12 +1518,12 @@ export class RevertModal {
if (Number.isFinite(seconds)) {
const display = this.formatDurationSeconds(seconds);
const suffix = seconds === 1 ? "second" : "seconds";
- return `${escape(display)} (${escape(
+ return `${escape(display)} (${escape(
`${seconds} ${suffix}`
)})`;
}
if (raw) {
- return `${escape(raw)}`;
+ return `${escape(raw)}`;
}
return this.renderPlaceholder();
};
@@ -1534,30 +1534,30 @@ export class RevertModal {
const startHtml = formatTime(segment.startSeconds, segment.startRaw);
const endHtml = formatTime(segment.endSeconds, segment.endRaw);
const titleHtml = segment.title
- ? `${escape(segment.title)}
`
+ ? `${escape(segment.title)}
`
: this.renderPlaceholder("No title");
const thumbnailHtml = segment.thumbnail
? this.buildLinkMarkup(segment.thumbnail)
: this.renderPlaceholder("No thumbnail");
return `
- -
-
${escape(headerLabel)}
-
+
-
+
${escape(headerLabel)}
+
-
- Start
+ - Start
- ${startHtml}
-
- End
+ - End
- ${endHtml}
-
- Title
+ - Title
- ${titleHtml}
-
- Thumbnail
+ - Thumbnail
- ${thumbnailHtml}
@@ -1576,7 +1576,7 @@ export class RevertModal {
const chips = hashtags.map((tag) => {
const label = tag.startsWith("#") ? tag : `#${tag}`;
- return `${escape(
+ return `${escape(
label
)}`;
});
@@ -1600,7 +1600,7 @@ export class RevertModal {
: participant.pubkey
: "";
const pubkeyHtml = participant.pubkey
- ? `${escape(displayPubkey)}`
: this.renderPlaceholder("No pubkey");
@@ -1609,15 +1609,15 @@ export class RevertModal {
: this.renderPlaceholder("No relay specified");
return `
- -
-
${escape(label)}
-
+
-
+
${escape(label)}
+
-
- Pubkey
+ - Pubkey
- ${pubkeyHtml}
-
- Relay
+ - Relay
- ${relayHtml}
@@ -1641,7 +1641,7 @@ export class RevertModal {
return this.renderListEmpty("No external references.");
}
- return ``;
+ return ``;
}
extractDTagValue(tags) {
diff --git a/js/ui/components/VideoCard.js b/js/ui/components/VideoCard.js
index 74552d3b..8f272d79 100644
--- a/js/ui/components/VideoCard.js
+++ b/js/ui/components/VideoCard.js
@@ -322,7 +322,7 @@ export class VideoCard {
if (this.showUnsupportedTorrentBadge) {
const warning = this.createElement("p", {
- classNames: ["mt-3", "text-xs", "text-amber-300"],
+ classNames: ["mt-3", "text-xs", "text-status-warning-on"],
attrs: { title: this.assets.unsupportedBtihMessage || "" },
textContent:
"WebTorrent fallback unavailable (magnet missing btih info hash)"
diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js
index c9ad2735..d904535f 100644
--- a/js/ui/profileModalController.js
+++ b/js/ui/profileModalController.js
@@ -1275,7 +1275,7 @@ export class ProfileModalController {
if (!entriesToRender.length) {
listEl.setAttribute("data-profile-switcher-empty", "true");
const helper = document.createElement("p");
- helper.className = "text-sm text-gray-400";
+ helper.className = "text-sm text-muted";
helper.textContent = "No other profiles saved yet.";
helper.setAttribute("role", "note");
listEl.appendChild(helper);
@@ -1309,7 +1309,7 @@ export class ProfileModalController {
const avatarSpan = document.createElement("span");
avatarSpan.className =
- "flex h-12 w-12 flex-shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-slate-500/50 bg-slate-900";
+ "flex h-12 w-12 flex-shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-overlay-strong bg-overlay-panel-soft";
const avatarImg = document.createElement("img");
avatarImg.className = "h-full w-full object-cover";
avatarImg.src = meta.picture || FALLBACK_PROFILE_AVATAR;
@@ -1327,23 +1327,23 @@ export class ProfileModalController {
const label = document.createElement("span");
label.className =
- "text-3xs font-semibold uppercase tracking-extra-wide text-blue-200";
+ "text-3xs font-semibold uppercase tracking-extra-wide text-status-info";
label.textContent =
entry.authType === "nsec" ? "Direct key" : "Saved profile";
const action = document.createElement("span");
- action.className = "text-xs font-medium text-gray-400";
+ action.className = "text-xs font-medium text-muted";
action.setAttribute("aria-hidden", "true");
action.textContent = isSelected ? "Selected" : "Switch";
topLine.append(label, action);
const nameSpan = document.createElement("span");
- nameSpan.className = "truncate text-sm font-semibold text-white";
+ nameSpan.className = "truncate text-sm font-semibold text-primary";
nameSpan.textContent = cardDisplayName;
const npubSpan = document.createElement("span");
- npubSpan.className = "break-all font-mono text-xs text-gray-400";
+ npubSpan.className = "break-all font-mono text-xs text-muted";
npubSpan.textContent = meta.npub
? truncate(meta.npub, 48)
: "npub unavailable";
@@ -1507,7 +1507,7 @@ export class ProfileModalController {
if (!relays.length) {
const emptyState = document.createElement("li");
emptyState.className =
- "card border border-dashed border-gray-700 p-4 text-center text-sm text-gray-400";
+ "card border border-dashed border-surface-strong p-4 text-center text-sm text-muted";
emptyState.textContent = "No relays configured.";
this.relayList.appendChild(emptyState);
return;
@@ -1522,11 +1522,11 @@ export class ProfileModalController {
info.className = "flex-1 min-w-0";
const urlEl = document.createElement("p");
- urlEl.className = "text-sm font-medium text-gray-100 break-all";
+ urlEl.className = "text-sm font-medium text-primary break-all";
urlEl.textContent = entry.url;
const statusEl = document.createElement("p");
- statusEl.className = "mt-1 text-xs text-gray-400";
+ statusEl.className = "mt-1 text-xs text-muted";
let modeLabel = "Read & write";
if (entry.mode === "read") {
modeLabel = "Read only";
@@ -1867,7 +1867,7 @@ export class ProfileModalController {
info.className = "min-w-0 flex-1";
const title = document.createElement("p");
- title.className = "break-all text-sm font-medium text-gray-100";
+ title.className = "break-all text-sm font-medium text-primary";
title.textContent = label;
info.appendChild(title);
@@ -2167,13 +2167,19 @@ export class ProfileModalController {
const element = this.walletStatusText;
const variants = {
- success: "text-green-400",
- error: "text-red-400",
- info: "text-gray-400",
+ success: "text-status-success",
+ error: "text-status-danger",
+ info: "text-status-info",
+ neutral: "text-status-neutral",
};
- element.classList.remove("text-gray-400", "text-green-400", "text-red-400");
- const variantClass = variants[variant] || variants.info;
+ element.classList.remove(
+ "text-status-info",
+ "text-status-success",
+ "text-status-danger",
+ "text-status-neutral",
+ );
+ const variantClass = variants[variant] || variants.neutral;
element.classList.add(variantClass);
element.textContent = message || "";
}
@@ -2644,7 +2650,7 @@ export class ProfileModalController {
"card flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between";
const label = document.createElement("p");
- label.className = "break-all text-sm font-medium text-gray-100";
+ label.className = "break-all text-sm font-medium text-primary";
label.textContent = npub;
item.appendChild(label);
diff --git a/js/ui/views/VideoListView.js b/js/ui/views/VideoListView.js
index 83395f5b..4451a0f0 100644
--- a/js/ui/views/VideoListView.js
+++ b/js/ui/views/VideoListView.js
@@ -297,7 +297,7 @@ export class VideoListView {
}
this.lastRenderedVideoSignature = EMPTY_VIDEO_LIST_SIGNATURE;
this.container.innerHTML = `
-
+
No public videos available yet. Be the first to upload one!
`;
return displayVideos;
diff --git a/js/viewManager.js b/js/viewManager.js
index 3709740a..f9ea486d 100644
--- a/js/viewManager.js
+++ b/js/viewManager.js
@@ -118,7 +118,7 @@ export const viewInitRegistry = {
const container = document.getElementById("subscriptionsVideoList");
if (container) {
container.innerHTML =
- "Please log in to see your subscriptions.
";
+ "Please log in to see your subscriptions.
";
}
return;
}
diff --git a/package.json b/package.json
index 562eeff7..f6dbf564 100644
--- a/package.json
+++ b/package.json
@@ -9,11 +9,12 @@
"build:beacon": "npm run build:beacon:bundle && npm run lint:inline-styles",
"ci:beacon": "npm run build:beacon",
"format": "prettier --cache --write css/tailwind.source.css css/tokens.css \"docs/**/*.html\" tailwind.config.cjs README.md",
- "lint": "npm run lint:css && npm run lint:hex && npm run lint:inline-styles && npm run lint:tokens",
+ "lint": "npm run lint:css && npm run lint:hex && npm run lint:inline-styles && npm run lint:tokens && npm run lint:tailwind-colors",
"lint:css": "stylelint css/tailwind.source.css css/tokens.css",
"lint:hex": "node scripts/check-hex.js",
"lint:inline-styles": "node scripts/check-inline-styles.mjs",
"lint:tokens": "node scripts/check-design-tokens.mjs",
+ "lint:tailwind-colors": "node scripts/check-tailwind-colors.mjs",
"check:css": "npm run build:css && git diff --exit-code -- css/tailwind.generated.css",
"test:visual": "npm run build:css && npx playwright test",
"test:visual:update": "npm run build:css && cross-env UPDATE_VISUAL_BASELINES=1 npx playwright test",
diff --git a/scripts/check-tailwind-colors.mjs b/scripts/check-tailwind-colors.mjs
new file mode 100755
index 00000000..ccee5a9e
--- /dev/null
+++ b/scripts/check-tailwind-colors.mjs
@@ -0,0 +1,133 @@
+#!/usr/bin/env node
+
+import { readdir, readFile } from "node:fs/promises";
+import path from "node:path";
+import process from "node:process";
+import { fileURLToPath } from "node:url";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const repoRoot = path.resolve(__dirname, "..");
+
+const TARGET_DIRECTORIES = ["components", "js", "views"];
+const IGNORED_DIRECTORIES = new Set(["ed-legacy", "legacy", "__snapshots__"]);
+const LEGACY_ALLOWLIST = new Set([
+ "components/ed-/revert-video-modal.html",
+ "components/ed-/profile-modal.html",
+]);
+
+const COLOR_PATTERN =
+ /\b(?:text|bg|border|divide|ring|stroke|fill|outline|shadow|accent|placeholder)-(?:slate|gray|zinc|stone|neutral|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)(?:-[\w/]+)?\b/gim;
+
+function toPosix(relPath) {
+ return relPath.split(path.sep).join("/");
+}
+
+async function collectFiles() {
+ const results = new Set();
+ for (const directory of TARGET_DIRECTORIES) {
+ const absolute = path.resolve(repoRoot, directory);
+ await walkDirectory(absolute, results);
+ }
+ return [...results];
+}
+
+async function walkDirectory(dir, results) {
+ let entries = [];
+ try {
+ entries = await readdir(dir, { withFileTypes: true });
+ } catch (error) {
+ return;
+ }
+
+ for (const entry of entries) {
+ const fullPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (IGNORED_DIRECTORIES.has(entry.name)) {
+ continue;
+ }
+ await walkDirectory(fullPath, results);
+ continue;
+ }
+
+ if (!entry.isFile()) {
+ continue;
+ }
+
+ results.add(fullPath);
+ }
+}
+
+function buildLineIndex(content) {
+ const lines = [0];
+ let index = 0;
+ while ((index = content.indexOf("\n", index)) !== -1) {
+ lines.push(index + 1);
+ index += 1;
+ }
+ return lines;
+}
+
+function getLineNumber(indexes, matchIndex) {
+ let low = 0;
+ let high = indexes.length;
+ while (low < high) {
+ const mid = Math.floor((low + high) / 2);
+ if (indexes[mid] <= matchIndex) {
+ low = mid + 1;
+ } else {
+ high = mid;
+ }
+ }
+ return low;
+}
+
+async function main() {
+ const files = await collectFiles();
+ const violations = [];
+
+ for (const filePath of files) {
+ const rel = toPosix(path.relative(repoRoot, filePath));
+ if (LEGACY_ALLOWLIST.has(rel)) {
+ continue;
+ }
+
+ let content;
+ try {
+ content = await readFile(filePath, "utf8");
+ } catch (error) {
+ continue;
+ }
+
+ const lineIndex = buildLineIndex(content);
+ let match;
+ COLOR_PATTERN.lastIndex = 0;
+ while ((match = COLOR_PATTERN.exec(content))) {
+ const line = getLineNumber(lineIndex, match.index);
+ violations.push({
+ file: rel,
+ line,
+ value: match[0],
+ });
+ }
+ }
+
+ if (violations.length > 0) {
+ console.error("Tailwind color lint failed: raw palette classes detected.\n");
+ for (const violation of violations) {
+ console.error(`${violation.file}:${violation.line} → ${violation.value}`);
+ }
+ console.error(
+ "\nReplace raw tailwind colors with semantic utilities (text-muted, bg-status-warning-surface, etc.)."
+ );
+ process.exit(1);
+ }
+
+ process.exit(0);
+}
+
+main().catch((error) => {
+ console.error("Failed to check Tailwind color usage.");
+ console.error(error);
+ process.exit(1);
+});
diff --git a/tailwind.config.cjs b/tailwind.config.cjs
index b4befad8..cf6a9da8 100644
--- a/tailwind.config.cjs
+++ b/tailwind.config.cjs
@@ -16,20 +16,31 @@ module.exports = {
colors: {
page: "var(--color-page)",
"page-alt": "var(--color-page-alt)",
- surface: "var(--color-surface)",
- "surface-alt": "var(--color-surface-alt)",
+ surface: {
+ DEFAULT: "var(--surface-base)",
+ alt: "var(--surface-alt)",
+ raised: "var(--surface-raised)",
+ "raised-private": "var(--surface-raised-private)",
+ "raised-critical": "var(--surface-raised-critical)",
+ panel: "var(--surface-panel)",
+ "panel-hover": "var(--surface-panel-hover)",
+ sunken: "var(--surface-sunken)",
+ muted: "var(--surface-muted)",
+ inverse: "var(--surface-inverse)",
+ },
overlay: {
- DEFAULT: "var(--color-overlay)",
- muted: "var(--color-overlay-muted)",
- strong: "var(--color-overlay-strong)",
- darker: "var(--color-overlay-darker)",
- veil: "var(--color-overlay-veil)",
- banner: "var(--color-overlay-banner)",
- panel: "var(--color-overlay-panel)",
- "panel-soft": "var(--color-overlay-panel-soft)",
- "panel-tint": "var(--color-overlay-panel-tint)",
- "panel-glass": "var(--color-overlay-panel-glass)",
- black: "var(--color-overlay-black)"
+ DEFAULT: "var(--surface-overlay)",
+ muted: "var(--surface-overlay-muted)",
+ strong: "var(--surface-overlay-strong)",
+ darker: "var(--surface-overlay-darker)",
+ veil: "var(--surface-veil)",
+ banner: "var(--surface-overlay-banner)",
+ panel: "var(--surface-overlay-panel)",
+ "panel-soft": "var(--surface-overlay-panel-soft)",
+ "panel-tint": "var(--surface-overlay-panel-tint)",
+ "panel-glass": "var(--surface-overlay-panel-glass)",
+ "panel-glass-soft": "var(--surface-overlay-panel-glass-soft)",
+ black: "var(--surface-overlay-black)"
},
shadow: {
intense: "var(--color-shadow-intense)"
@@ -37,35 +48,39 @@ module.exports = {
primary: "var(--color-primary)",
secondary: "var(--color-secondary)",
panel: {
- DEFAULT: "var(--color-panel)",
- hover: "var(--color-panel-hover)"
+ DEFAULT: "var(--surface-panel)",
+ hover: "var(--surface-panel-hover)"
},
card: {
- DEFAULT: "var(--color-card)",
- private: "var(--color-card-private)",
- critical: "var(--color-card-critical)"
+ DEFAULT: "var(--surface-raised)",
+ private: "var(--surface-raised-private)",
+ critical: "var(--surface-raised-critical)"
},
border: {
- DEFAULT: "var(--color-border)",
- subtle: "var(--color-border-subtle)",
- translucent: "var(--color-border-translucent)",
+ DEFAULT: "var(--border-default)",
+ subtle: "var(--border-subtle)",
+ strong: "var(--border-strong)",
+ translucent: "var(--border-overlay)",
"translucent-strong": "var(--color-border-translucent-strong)",
"translucent-bright": "var(--color-border-translucent-bright)",
"translucent-medium": "var(--color-border-translucent-medium)",
"translucent-stronger": "var(--color-border-translucent-stronger)",
"translucent-strongest": "var(--color-border-translucent-strongest)",
- "translucent-glow": "var(--color-border-translucent-glow)",
- "info-soft": "var(--color-border-info-soft)",
- "neutral-soft": "var(--color-border-neutral-soft)",
- "sidebar-strong": "var(--color-border-sidebar-strong)"
+ "translucent-glow": "var(--border-overlay-glow)",
+ "info-soft": "var(--border-info-soft)",
+ "neutral-soft": "var(--border-neutral-soft)",
+ "sidebar-strong": "var(--border-sidebar-strong)"
},
text: {
- DEFAULT: "var(--color-text)",
- strong: "var(--color-text-strong)",
- muted: "var(--color-muted)",
- frosted: "var(--color-text-frosted)",
- "frosted-strong": "var(--color-text-frosted-strong)",
- "frosted-soft": "var(--color-text-frosted-soft)"
+ DEFAULT: "var(--text-primary)",
+ strong: "var(--color-white)",
+ inverse: "var(--text-inverse)",
+ muted: "var(--text-muted)",
+ "muted-strong": "var(--text-muted-strong)",
+ subtle: "var(--text-subtle)",
+ frosted: "var(--text-frosted-primary)",
+ "frosted-strong": "var(--text-frosted-strong)",
+ "frosted-soft": "var(--text-frosted-soft)"
},
muted: {
DEFAULT: "var(--color-muted)",
@@ -82,9 +97,53 @@ module.exports = {
strong: "var(--color-critical-strong)"
},
warning: {
- DEFAULT: "var(--color-warning)",
- strong: "var(--color-warning-strong)",
- surface: "var(--color-warning-surface)"
+ DEFAULT: "var(--status-warning)",
+ strong: "var(--status-warning-strong)",
+ surface: "var(--status-warning-surface)"
+ },
+ status: {
+ info: {
+ DEFAULT: "var(--status-info)",
+ strong: "var(--status-info-strong)",
+ surface: "var(--status-info-surface)",
+ border: "var(--status-info-border)",
+ on: "var(--status-info-on)"
+ },
+ success: {
+ DEFAULT: "var(--status-success)",
+ strong: "var(--status-success-strong)",
+ surface: "var(--status-success-surface)",
+ border: "var(--status-success-border)",
+ on: "var(--status-success-on)"
+ },
+ warning: {
+ DEFAULT: "var(--status-warning)",
+ strong: "var(--status-warning-strong)",
+ surface: "var(--status-warning-surface)",
+ border: "var(--status-warning-border)",
+ on: "var(--status-warning-on)"
+ },
+ danger: {
+ DEFAULT: "var(--status-danger)",
+ strong: "var(--status-danger-strong)",
+ surface: "var(--status-danger-surface)",
+ border: "var(--status-danger-border)",
+ on: "var(--status-danger-on)"
+ },
+ private: {
+ DEFAULT: "var(--status-private)",
+ strong: "var(--status-private-strong)",
+ surface: "var(--status-private-surface)",
+ border: "var(--status-private-border)",
+ on: "var(--status-private-on)"
+ },
+ neutral: {
+ DEFAULT: "var(--status-neutral)",
+ strong: "var(--status-neutral-strong)",
+ surface: "var(--status-neutral-surface)",
+ border: "var(--status-neutral-border)",
+ on: "var(--status-neutral-on)"
+ }
},
neutral: {
600: "var(--color-neutral-600)",
@@ -139,6 +198,64 @@ module.exports = {
"neutral-50": "var(--color-blog-neutral-50)"
}
},
+ textColor: {
+ primary: "var(--text-primary)",
+ inverse: "var(--text-inverse)",
+ muted: "var(--text-muted)",
+ "muted-strong": "var(--text-muted-strong)",
+ subtle: "var(--text-subtle)",
+ "status-info": "var(--status-info)",
+ "status-info-on": "var(--status-info-on)",
+ "status-success": "var(--status-success)",
+ "status-success-on": "var(--status-success-on)",
+ "status-warning": "var(--status-warning)",
+ "status-warning-on": "var(--status-warning-on)",
+ "status-danger": "var(--status-danger)",
+ "status-danger-on": "var(--status-danger-on)",
+ "status-private": "var(--status-private)",
+ "status-private-on": "var(--status-private-on)",
+ "status-neutral": "var(--status-neutral)",
+ "status-neutral-on": "var(--status-neutral-on)"
+ },
+ backgroundColor: {
+ surface: "var(--surface-base)",
+ "surface-alt": "var(--surface-alt)",
+ "surface-raised": "var(--surface-raised)",
+ "surface-muted": "var(--surface-muted)",
+ "overlay-strong": "var(--surface-overlay-strong)",
+ "overlay-muted": "var(--surface-overlay-muted)",
+ "overlay-banner": "var(--surface-overlay-banner)",
+ "overlay-panel": "var(--surface-overlay-panel)",
+ "overlay-panel-soft": "var(--surface-overlay-panel-soft)",
+ "overlay-panel-glass": "var(--surface-overlay-panel-glass)",
+ "status-info-surface": "var(--status-info-surface)",
+ "status-success-surface": "var(--status-success-surface)",
+ "status-warning-surface": "var(--status-warning-surface)",
+ "status-danger-surface": "var(--status-danger-surface)",
+ "status-private-surface": "var(--status-private-surface)",
+ "status-neutral-surface": "var(--status-neutral-surface)"
+ },
+ borderColor: {
+ surface: "var(--border-default)",
+ "surface-strong": "var(--border-strong)",
+ "overlay": "var(--border-overlay)",
+ "overlay-strong": "var(--border-overlay-strong)",
+ "status-info-border": "var(--status-info-border)",
+ "status-success-border": "var(--status-success-border)",
+ "status-warning-border": "var(--status-warning-border)",
+ "status-danger-border": "var(--status-danger-border)",
+ "status-private-border": "var(--status-private-border)",
+ "status-neutral-border": "var(--status-neutral-border)"
+ },
+ ringColor: {
+ "status-danger": "var(--status-danger)",
+ "status-warning": "var(--status-warning)",
+ "status-info": "var(--status-info)",
+ "status-success": "var(--status-success)"
+ },
+ ringOffsetColor: {
+ surface: "var(--surface-base)"
+ },
borderRadius: {
sm: "var(--radius-sm)",
md: "var(--radius-md)",