1
0

Include widgets in README, setup storybook

This commit is contained in:
Ganonmaster
2026-04-15 04:12:32 +02:00
parent 8b1f22d573
commit 299036eced
12 changed files with 4079 additions and 1 deletions
+1
View File
@@ -13,6 +13,7 @@ build/
coverage/
.cache/
.parcel-cache/
storybook-static/
# Environment files
.env
+53 -1
View File
@@ -2,9 +2,61 @@
This repository contains several components used to make Open3DLab's slopscaling April Fools prank work.
## Widgets
These widgets are the core of the "AI Chat experience". The first element is the `ai-assist-bar`, which is the main chat bar. The chat bar renders canned responses based on the "model" selected.
During the time the elements were live, queries were sent to the backend when the user confirmed. The elements in this repo have been modified to not send responses to the Open3DLab backend.
The other element is the `ai-assist-clippy`, which is an orb that renders a dialog with pre-defined options and fake responses. Let us know if you find the fake options and responses interesting or funny.
The `widgets` folder contains the standalone web component package for the two Lit custom elements used by the prank UI:
- `ai-assist-bar`
- `ai-assist-clippy`
The package is managed with `pnpm`.
### Run the widgets locally
From the repository root:
```sh
cd widgets
pnpm install
```
Once dependencies are installed, you can run the package checks:
```sh
pnpm check
```
### Preview the widgets in Storybook
The easiest way to inspect and tinker with the components is through Storybook:
```sh
cd widgets
pnpm storybook
```
This starts a local Storybook server at `http://localhost:6006/` where you can:
- preview both widgets in isolation
- switch between the supported `mode` values with Storybook controls
- interact with the components directly to test their loading, typing, and display behavior
To build a static Storybook bundle instead of running the dev server:
```sh
cd widgets
pnpm build-storybook
```
## ComfyUI Workflow
In the `workflow` folder, you will find the ComfyUI workflow that was used to mangle all the thumbnail images. This is an extremely simple, and extremely bad workflow that should under no circumstances be used for anything serious. However, you can theoreticaly run this on any device that has sufficient VRAM to run SDXL.
In the `workflow` folder, you will find the ComfyUI workflow that was used to mangle all the thumbnail images for the Open3DLab SlopScaling. This is an extremely simple, and extremely bad workflow that should under no circumstances be used for anything serious. However, you can theoreticaly run this on any device that has sufficient VRAM to run SDXL.
### Run it locally
+11
View File
@@ -0,0 +1,11 @@
export default {
stories: ['../stories/**/*.stories.js'],
addons: ['@storybook/addon-essentials'],
framework: {
name: '@storybook/web-components-vite',
options: {},
},
docs: {
autodocs: 'tag',
},
};
+66
View File
@@ -0,0 +1,66 @@
import { html } from 'lit';
import '../stories/preview.css';
if (!window.customElements.get('loading-element')) {
class LoadingElement extends HTMLElement {
connectedCallback() {
if (this.shadowRoot) return;
const root = this.attachShadow({ mode: 'open' });
root.innerHTML = `
<style>
:host {
display: inline-flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
}
.spinner {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid rgba(34, 211, 238, 0.25);
border-top-color: rgba(34, 211, 238, 1);
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
<div class="spinner" aria-hidden="true"></div>
`;
}
}
window.customElements.define('loading-element', LoadingElement);
}
export const parameters = {
layout: 'centered',
controls: {
expanded: true,
},
backgrounds: {
default: 'slate',
values: [
{ name: 'slate', value: '#0f1720' },
{ name: 'paper', value: '#f5efe4' },
],
},
};
export const decorators = [
(story) => {
return html`
<div class="story-shell">
<div class="story-surface">${story()}</div>
</div>
`;
},
];
+2
View File
@@ -0,0 +1,2 @@
import './js/ai-assist-bar.js';
import './js/ai-assist-clippy.js';
File diff suppressed because it is too large Load Diff
+762
View File
@@ -0,0 +1,762 @@
import { LitElement, css, html } from 'lit';
import { keyed } from 'lit/directives/keyed.js';
const UPLOADER_SUGGESTIONS = [
{
message:
"It looks like you're trying to respond to your idiot fans. Would you like me to think of a creative insult?",
options: [
'Yes, make it devastating',
'No thanks, I can be mean on my own',
'Just write "skill issue" for me',
],
},
{
message:
"Your fans seem to be dumb as bricks. Do you need help telling them that they're idiots?",
options: [
'Generate a passive-aggressive response',
"Compose something they won't understand",
'I enjoy suffering, let me type it myself',
],
},
{
message:
"I see someone asked a question that's answered in your description. Want me to draft a condescending reply?",
options: [
'Maximum condescension, please',
'Mild sarcasm will suffice',
'Just link them to the description… again',
],
},
{
message:
"It looks like you're trying to say \"read the description\" for the 47th time. Would you like me to automate this?",
options: [
'Set up an auto-reply bot',
'Generate a FAQ nobody will read',
'Let me savor typing it myself',
],
},
{
message:
"I notice a fan left a one-word comment. Would you like me to generate a proportionally low-effort response?",
options: [
'Reply with a single emoji',
'Match their energy with "k"',
'Ignore them with style',
],
},
{
message:
"I see someone has asked you to make your file compatible with software you have never heard of. Would you like me to draft a refusal that implies it's their fault for using it?",
options: [
'Blame their taste in software',
'Suggest they learn to port it themselves',
'Pretend I didn\'t see this one',
],
},
{
message:
"It looks like someone left a paragraph of feedback you didn't ask for. Would you like me to compose a response that technically says \"thank you\" but conveys something else entirely?",
options: [
'Weaponize politeness',
'Reply with a single period',
'Screenshot it and mock it privately',
],
},
{
message:
"I notice someone has commented \"cute\" on your incredibly detailed and technically complex model. Would you like me to help you express how that makes you feel?",
options: [
'Write something deeply passive-aggressive',
'Reply "thanks" while crying',
'Retire from making things',
],
},
{
message:
"It looks like a fan is asking for the exact same file but in a different format, for free, immediately. Would you like me to explain to them how file conversion works, or would you prefer something ruder?",
options: [
'Something ruder, please',
'Link them to a 2-hour tutorial they won\'t watch',
'Close the tab and lie down',
],
},
{
message:
"I see someone has replied to your pinned notice saying \"I didn't read this\" as if that is your problem. Would you like me to make it their problem instead?",
options: [
'Firmly and creatively, yes',
'Un-pin the notice out of spite',
'Add more words to the notice nobody will read',
],
},
];
const COMMENTER_SUGGESTIONS = [
{
message:
"It looks like you're trying to ask a question that can be easily answered by a Google search. Would you like me to hallucinate a bunch of wrong answers for you instead?",
options: [
'Yes, I love misinformation',
"Actually, I can't read",
'Just Google it for me (we both know you won\'t)',
],
},
{
message:
"It seems like you're about to comment \"doesn't work\" without any details whatsoever. Would you like me to help you be equally unhelpful, but with more words?",
options: [
'Add vague complaints for me',
'Generate a 3-paragraph rant with zero specifics',
'I was actually going to say "fix pls"',
],
},
{
message:
"I see you're writing a comment. Would you like me to check if this exact question has been asked and answered 15 times already in this thread?",
options: [
"No, I'm sure I'm the first",
"Reading other comments is for losers",
'Just post it, what could go wrong',
],
},
{
message:
"It looks like you're about to request a feature the uploader has already said no to. Would you like me to help you phrase it in a way that's slightly more annoying?",
options: [
'Make it sound urgent and entitled',
"Add 'please' so it's technically polite",
'Threaten to make it myself (I obviously won\'t)',
],
},
{
message:
"I notice you're typing a comment that could be interpreted as rude. Would you like me to make it sound even worse while technically following the rules?",
options: [
'Maximize passive aggression',
'Add a smiley face to disguise the hostility',
'On second thought, I\'ll just say "thanks"',
],
},
{
message:
"It looks like you're about to complain that the file doesn't work on your specific setup that you refuse to describe. Would you like me to blame the uploader anyway?",
options: [
'Yes, it\'s definitely their fault',
'Generate a bug report with zero useful info',
'Just type "broken" and log off',
],
},
{
message:
"I see you're writing a comment in a language the uploader almost certainly doesn't speak. Would you like me to run it through a translator that will make it slightly more confusing?",
options: [
'Triple-translate it for maximum chaos',
'Just add "(pls)" at the end in English',
'Send it anyway, vibes will carry it',
],
},
{
message:
"It looks like you're about to leave a five-paragraph essay demanding a free commission. Would you like me to add some light emotional manipulation to improve your odds?",
options: [
'Lay on the guilt thick',
'Claim it\'s "just a small thing"',
'Threaten to be very disappointed',
],
},
{
message:
"I notice you haven't downloaded this file but are about to review it one star anyway. Would you like me to help you sound more authoritative?",
options: [
'Add "trust me" for credibility',
'Reference a file you definitely didn\'t open',
'One star, no note, raw power move',
],
},
{
message:
"It seems like you're asking when the next update will be released. The uploader has not indicated this. Would you like me to invent a deadline for them?",
options: [
'Tell them it should\'ve been done already',
'Suggest they quit their day job',
'Just post "update?" every week forever',
],
},
{
message:
"I see you're typing a comment that starts with \"No offense but\". I have run the numbers and offense will, in fact, be taken. Shall I proceed?",
options: [
'Proceed, I am unstoppable',
'Replace it with "With all due respect"',
'Change it to just "offense"',
],
},
];
class AiAssistClippy extends LitElement {
static properties = {
mode: { type: String },
_state: { state: true },
_currentSuggestion: { state: true },
_typedText: { state: true },
_showBubble: { state: true },
};
static styles = css`
:host {
display: inline-block;
position: relative;
width: 56px;
--ai-glow-cyan: #22d3ee;
--ai-glow-purple: #a855f7;
}
.clippy {
position: relative;
user-select: none;
}
/* ── Orb ─────────────────────────────────── */
.orb-wrapper {
position: relative;
flex: 0 0 auto;
width: 56px;
height: 56px;
cursor: pointer;
}
.orb {
position: relative;
width: 56px;
height: 56px;
border-radius: 50%;
background: radial-gradient(
circle at 38% 35%,
var(--ai-glow-cyan) 0%,
var(--ai-glow-purple) 60%,
#6d28d9 100%
);
background-size: 200% 200%;
animation: orbShift 4s ease-in-out infinite;
box-shadow:
0 0 18px color-mix(in srgb, var(--ai-glow-cyan) 45%, transparent),
0 0 36px color-mix(in srgb, var(--ai-glow-purple) 30%, transparent);
transition:
box-shadow 250ms ease,
transform 250ms ease;
}
.orb::after {
content: '';
position: absolute;
top: 14%;
left: 22%;
width: 30%;
height: 24%;
border-radius: 50%;
background: radial-gradient(
ellipse,
rgba(255, 255, 255, 0.7) 0%,
rgba(255, 255, 255, 0) 100%
);
transform: rotate(-30deg);
pointer-events: none;
}
.orb-wrapper:hover .orb {
transform: scale(1.08);
box-shadow:
0 0 24px
color-mix(
in srgb,
var(--ai-glow-cyan) 60%,
transparent
),
0 0 48px
color-mix(
in srgb,
var(--ai-glow-purple) 45%,
transparent
);
}
.orb--loading {
animation:
orbShift 1.2s ease-in-out infinite,
orbPulse 0.6s ease-in-out infinite alternate;
}
.orb--error {
background: radial-gradient(
circle at 38% 35%,
#f87171 0%,
#dc2626 60%,
#991b1b 100%
) !important;
box-shadow:
0 0 18px rgba(248, 113, 113, 0.45),
0 0 36px rgba(220, 38, 38, 0.3) !important;
animation: orbShift 4s ease-in-out infinite !important;
}
@keyframes orbShift {
0%,
100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
@keyframes orbPulse {
from {
transform: scale(1);
box-shadow:
0 0 18px
color-mix(
in srgb,
var(--ai-glow-cyan) 45%,
transparent
),
0 0 36px
color-mix(
in srgb,
var(--ai-glow-purple) 30%,
transparent
);
}
to {
transform: scale(1.1);
box-shadow:
0 0 30px
color-mix(
in srgb,
var(--ai-glow-cyan) 70%,
transparent
),
0 0 54px
color-mix(
in srgb,
var(--ai-glow-purple) 55%,
transparent
);
}
}
/* ── Speech Bubble ───────────────────────── */
.bubble {
position: absolute;
bottom: 0;
left: calc(100% + 12px);
background: var(--panel-bg-color, #1a1a1a);
border: 1px solid var(--border-color, #363636);
border-radius: 12px;
padding: 14px 16px;
max-width: 500px;
min-width: 380px;
font-size: 0.9rem;
line-height: 1.5;
color: var(--body-color, #b9b5af);
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.25),
0 0 12px
color-mix(
in srgb,
var(--ai-glow-cyan) 12%,
transparent
);
animation: bubbleIn 200ms ease-out;
transform-origin: top left;
}
.bubble::before {
content: '';
position: absolute;
bottom: 18px;
left: -8px;
width: 0;
height: 0;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
border-right: 8px solid var(--border-color, #363636);
}
.bubble::after {
content: '';
position: absolute;
bottom: 19px;
left: -6px;
width: 0;
height: 0;
border-top: 7px solid transparent;
border-bottom: 7px solid transparent;
border-right: 7px solid var(--panel-bg-color, #1a1a1a);
}
@keyframes bubbleIn {
from {
opacity: 0;
transform: scale(0.9) translateY(6px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.bubble__text {
margin: 0 0 12px;
}
.bubble__cursor {
display: inline-block;
width: 2px;
height: 1em;
background: var(--ai-glow-cyan);
vertical-align: text-bottom;
animation: caretBlink 0.6s step-end infinite;
margin-left: 1px;
}
@keyframes caretBlink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
.bubble__options {
display: flex;
flex-direction: column;
gap: 6px;
}
.bubble__option {
display: block;
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border-color, #363636);
border-radius: 8px;
background: var(--input-bg-color, #222);
color: var(--ai-glow-cyan);
font-size: 0.82rem;
cursor: pointer;
text-align: left;
transition:
background 140ms ease,
border-color 140ms ease;
}
.bubble__option:hover {
background: color-mix(
in srgb,
var(--ai-glow-cyan) 14%,
var(--input-bg-color, #222)
);
border-color: var(--ai-glow-cyan);
}
.bubble__option:active {
background: color-mix(
in srgb,
var(--ai-glow-cyan) 22%,
var(--input-bg-color, #222)
);
}
/* ── Loading state ───────────────────────── */
.bubble__loading {
display: flex;
align-items: center;
gap: 8px;
color: var(--ai-glow-cyan);
font-size: 0.85rem;
}
.bubble__loading-dots {
display: inline-flex;
gap: 3px;
}
.bubble__loading-dots span {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--ai-glow-cyan);
animation: dotBounce 1.2s ease-in-out infinite;
}
.bubble__loading-dots span:nth-child(2) {
animation-delay: 0.15s;
}
.bubble__loading-dots span:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes dotBounce {
0%,
80%,
100% {
opacity: 0.3;
transform: scale(0.8);
}
40% {
opacity: 1;
transform: scale(1.1);
}
}
/* ── Error state ─────────────────────────── */
.bubble__error {
color: #f87171;
font-size: 0.85rem;
font-family: monospace;
}
/* ── Dismiss button ──────────────────────── */
.bubble__dismiss {
position: absolute;
top: 6px;
right: 8px;
background: none;
border: none;
color: var(--body-color, #b9b5af);
opacity: 0.5;
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 2px 4px;
}
.bubble__dismiss:hover {
opacity: 1;
}
/* ── Hidden utility ──────────────────────── */
.bubble__branding {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid var(--border-color, #363636);
font-size: 0.7rem;
color: var(--body-color, #b9b5af);
opacity: 0.45;
text-align: right;
}
.bubble__branding span {
background: linear-gradient(
90deg,
var(--ai-glow-cyan) 0%,
var(--ai-glow-purple) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: 700;
}
[hidden] {
display: none !important;
}
`;
constructor() {
super();
this.mode = 'commenter';
this._state = 'idle';
this._currentSuggestion = null;
this._typedText = '';
this._showBubble = false;
this._typeTimer = null;
this._idleTimer = null;
}
connectedCallback() {
super.connectedCallback();
this._schedulePopUp();
}
disconnectedCallback() {
super.disconnectedCallback();
clearTimeout(this._typeTimer);
clearTimeout(this._idleTimer);
clearTimeout(this._loadingTimer);
}
/* ── Helpers ─────────────────────────────── */
get _suggestions() {
return this.mode === 'uploader'
? UPLOADER_SUGGESTIONS
: COMMENTER_SUGGESTIONS;
}
_pickRandom() {
const pool = this._suggestions;
return pool[Math.floor(Math.random() * pool.length)];
}
_schedulePopUp() {
clearTimeout(this._idleTimer);
this._idleTimer = setTimeout(() => {
if (this._state === 'idle') {
this._showSuggestion();
}
}, 3000);
}
/* ── State transitions ───────────────────── */
_showSuggestion() {
this._currentSuggestion = this._pickRandom();
this._typedText = '';
this._showBubble = true;
this._state = 'typing';
this._typeNextChar(0);
}
_typeNextChar(index) {
const full = this._currentSuggestion.message;
if (index <= full.length) {
this._typedText = full.slice(0, index);
this._typeTimer = setTimeout(
() => this._typeNextChar(index + 1),
22 + Math.random() * 28,
);
} else {
this._state = 'suggesting';
}
}
_onOptionClick() {
this._state = 'loading';
this._loadingTimer = setTimeout(() => {
this._state = 'error';
}, 2000);
}
_dismiss() {
clearTimeout(this._typeTimer);
clearTimeout(this._loadingTimer);
this._showBubble = false;
this._state = 'idle';
this._schedulePopUp();
}
_onOrbClick() {
if (this._showBubble) {
this._dismiss();
} else {
clearTimeout(this._idleTimer);
this._showSuggestion();
}
}
/* ── Render ──────────────────────────────── */
_renderBubbleContent() {
switch (this._state) {
case 'typing':
return html`
<p class="bubble__text">
${this._typedText}<span class="bubble__cursor"></span>
</p>
`;
case 'suggesting':
return html`
<p class="bubble__text">
${this._currentSuggestion.message}
</p>
<div class="bubble__options">
${this._currentSuggestion.options.map(
(opt) => html`
<button
class="bubble__option"
@click=${this._onOptionClick}
>
${opt}
</button>
`,
)}
</div>
`;
case 'loading':
return html`
<div class="bubble__loading">
<span class="bubble__loading-dots">
<span></span><span></span><span></span>
</span>
Generating response…
</div>
`;
case 'error':
return html`
<p class="bubble__error">
Error: Account &lt;Open3DLab&gt; is out of AI tokens. You have $42000 in unpaid invoices. Please settle your balance to continue using AI features.
</p>
`;
default:
return null;
}
}
render() {
const orbClass = [
'orb',
this._state === 'loading' ? 'orb--loading' : '',
this._state === 'error' ? 'orb--error' : '',
]
.filter(Boolean)
.join(' ');
return html`
<div class="clippy">
<div
class="orb-wrapper"
@click=${this._onOrbClick}
title="Open3DLab AI Assistant"
>
<div class=${orbClass}></div>
</div>
${this._showBubble
? html`
<div class="bubble">
<button
class="bubble__dismiss"
@click=${this._dismiss}
aria-label="Dismiss"
>
</button>
${keyed(
this._state,
this._renderBubbleContent(),
)}
<div class="bubble__branding">
powered by <span>LLEmmy</span>
</div>
</div>
`
: null}
</div>
`;
}
}
if (!window.customElements.get('ai-assist-clippy')) {
window.customElements.define('ai-assist-clippy', AiAssistClippy);
}
+42
View File
@@ -0,0 +1,42 @@
{
"name": "open3dlab-slopscaling-widgets",
"version": "0.1.0",
"description": "Open3DLab SlopScaling web components.",
"type": "module",
"main": "./index.js",
"exports": {
".": "./index.js",
"./ai-assist-bar.js": "./js/ai-assist-bar.js",
"./ai-assist-clippy.js": "./js/ai-assist-clippy.js"
},
"files": [
"index.js",
"js"
],
"sideEffects": [
"./js/ai-assist-bar.js",
"./js/ai-assist-clippy.js"
],
"scripts": {
"check": "node --check index.js && node --check js/ai-assist-bar.js && node --check js/ai-assist-clippy.js",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"keywords": [
"web-components",
"lit",
"open3dlab"
],
"license": "MIT",
"dependencies": {
"lit": "^3.3.1"
},
"devDependencies": {
"@storybook/addon-essentials": "8.6.14",
"@storybook/web-components": "8.6.14",
"@storybook/web-components-vite": "8.6.14",
"esbuild": "0.25.12",
"storybook": "8.6.14",
"vite": "6.4.2"
}
}
+1715
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
import { html } from 'lit';
import '../js/ai-assist-bar.js';
const meta = {
title: 'Widgets/AI Assist Bar',
tags: ['autodocs'],
argTypes: {
mode: {
control: 'radio',
options: ['project', 'frontpage'],
},
},
args: {
mode: 'project',
},
render: ({ mode }) => html`
<div class="story-stack">
<p class="story-heading">Interactive Preview</p>
<p class="story-note">
Use the controls panel to switch the widget context. The component itself is live,
so you can type into it, change the fake model, and submit to watch its loading and
reveal states.
</p>
<ai-assist-bar mode=${mode}></ai-assist-bar>
</div>
`,
};
export default meta;
export const Default = {};
export const Frontpage = {
args: {
mode: 'frontpage',
},
};
@@ -0,0 +1,40 @@
import { html } from 'lit';
import '../js/ai-assist-clippy.js';
const meta = {
title: 'Widgets/AI Assist Clippy',
tags: ['autodocs'],
argTypes: {
mode: {
control: 'radio',
options: ['commenter', 'uploader'],
},
},
args: {
mode: 'commenter',
},
render: ({ mode }) => html`
<div class="story-stack">
<p class="story-heading">Interactive Preview</p>
<p class="story-note">
Click the orb to trigger the speech bubble immediately, or wait a few seconds for it
to pop on its own. Change the mode in controls to swap between commenter and uploader
suggestion sets.
</p>
<div class="story-clippy-row">
<ai-assist-clippy mode=${mode}></ai-assist-clippy>
</div>
</div>
`,
};
export default meta;
export const Default = {};
export const Uploader = {
args: {
mode: 'uploader',
},
};
+62
View File
@@ -0,0 +1,62 @@
:root {
color-scheme: dark;
--panel-bg-color: #161d27;
--input-bg-color: #0d131c;
--input-color: #e8ecf3;
--body-color: #c6d0de;
--border-color: #314154;
--link-color: #f59e0b;
}
body {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
background:
radial-gradient(circle at top, rgba(245, 158, 11, 0.08), transparent 30%),
linear-gradient(180deg, #081018 0%, #0f1720 100%);
color: var(--body-color);
}
.story-shell {
min-width: min(92vw, 960px);
padding: 32px;
}
.story-surface {
padding: 28px;
border-radius: 28px;
border: 1px solid rgba(245, 158, 11, 0.16);
background:
linear-gradient(180deg, rgba(245, 158, 11, 0.06), transparent 28%),
rgba(12, 18, 27, 0.92);
box-shadow:
0 24px 80px rgba(0, 0, 0, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.story-stack {
display: grid;
gap: 20px;
}
.story-heading {
margin: 0;
font-size: 12px;
letter-spacing: 0.24em;
text-transform: uppercase;
color: #f2c572;
}
.story-note {
margin: 0;
font-size: 14px;
line-height: 1.6;
max-width: 68ch;
color: rgba(232, 236, 243, 0.76);
}
.story-clippy-row {
display: flex;
align-items: flex-end;
min-height: 220px;
}