@kntic/links 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/package.json +46 -0
- package/src/.gitkeep +0 -0
- package/src/cli.js +39 -0
- package/src/commands/add.js +83 -0
- package/src/commands/config-cmd.js +49 -0
- package/src/commands/deploy.js +97 -0
- package/src/commands/init.js +107 -0
- package/src/commands/list.js +108 -0
- package/src/commands/open-cmd.js +64 -0
- package/src/commands/qr.js +99 -0
- package/src/commands/remove.js +55 -0
- package/src/commands/status.js +64 -0
- package/src/commands/theme.js +86 -0
- package/src/config.js +221 -0
- package/src/generator.js +185 -0
- package/src/themes/.gitkeep +0 -0
- package/src/themes/README.md +110 -0
- package/src/themes/developer.css +210 -0
- package/src/themes/glass.css +215 -0
- package/src/themes/loader.js +61 -0
- package/src/themes/minimal-dark.css +172 -0
- package/src/themes/minimal-light.css +176 -0
- package/src/themes/terminal.css +229 -0
- package/src/utils.js +80 -0
package/src/generator.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static HTML page generator for KNTIC Links.
|
|
3
|
+
*
|
|
4
|
+
* Produces a single, fully self-contained index.html with all CSS inlined.
|
|
5
|
+
* No JavaScript in the output — works with JS disabled.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { resolve, dirname, extname } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { isLinkActive } from './utils.js';
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Helpers
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/** HTML-escape a string to prevent XSS. */
|
|
20
|
+
function esc(str) {
|
|
21
|
+
if (!str) return '';
|
|
22
|
+
return String(str)
|
|
23
|
+
.replace(/&/g, '&')
|
|
24
|
+
.replace(/</g, '<')
|
|
25
|
+
.replace(/>/g, '>')
|
|
26
|
+
.replace(/"/g, '"')
|
|
27
|
+
.replace(/'/g, ''');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** MIME type lookup for common image formats. */
|
|
31
|
+
function imageMime(filePath) {
|
|
32
|
+
const ext = extname(filePath).toLowerCase();
|
|
33
|
+
const map = {
|
|
34
|
+
'.png': 'image/png',
|
|
35
|
+
'.jpg': 'image/jpeg',
|
|
36
|
+
'.jpeg': 'image/jpeg',
|
|
37
|
+
'.gif': 'image/gif',
|
|
38
|
+
'.webp': 'image/webp',
|
|
39
|
+
'.svg': 'image/svg+xml',
|
|
40
|
+
'.ico': 'image/x-icon',
|
|
41
|
+
'.avif': 'image/avif',
|
|
42
|
+
};
|
|
43
|
+
return map[ext] || 'application/octet-stream';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read a local image file and return a base64 data URI.
|
|
48
|
+
* @param {string} filePath — path to the image (absolute or relative to configDir)
|
|
49
|
+
* @param {string} configDir — directory that links.yaml lives in
|
|
50
|
+
* @returns {string} data URI string
|
|
51
|
+
*/
|
|
52
|
+
export function inlineImage(filePath, configDir) {
|
|
53
|
+
const abs = resolve(configDir, filePath);
|
|
54
|
+
const buf = readFileSync(abs);
|
|
55
|
+
const mime = imageMime(abs);
|
|
56
|
+
return `data:${mime};base64,${buf.toString('base64')}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Schedule filtering
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Filter links to only those whose schedule window includes `now`.
|
|
65
|
+
* Links without scheduling fields are always included.
|
|
66
|
+
* Delegates to isLinkActive() from utils.js for per-link evaluation.
|
|
67
|
+
*/
|
|
68
|
+
export function filterScheduled(links, now = new Date()) {
|
|
69
|
+
if (!links || !Array.isArray(links)) return [];
|
|
70
|
+
return links.filter((link) => isLinkActive(link, now));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Theme CSS loading
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Load theme CSS from the themes directory.
|
|
79
|
+
* @param {string} themeName — name without .css extension
|
|
80
|
+
* @returns {string} CSS content
|
|
81
|
+
*/
|
|
82
|
+
export function loadThemeCSS(themeName) {
|
|
83
|
+
const themePath = resolve(__dirname, 'themes', `${themeName}.css`);
|
|
84
|
+
try {
|
|
85
|
+
return readFileSync(themePath, 'utf8');
|
|
86
|
+
} catch {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Theme "${themeName}" not found at ${themePath}. ` +
|
|
89
|
+
'Available themes live in src/themes/.',
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// HTML generation
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Generate a complete, self-contained HTML page from a links config.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} config — parsed links.yaml config object
|
|
102
|
+
* @param {object} [options]
|
|
103
|
+
* @param {string} [options.configDir=process.cwd()] — directory links.yaml lives in (for resolving relative paths)
|
|
104
|
+
* @param {Date} [options.now=new Date()] — current time (for schedule filtering)
|
|
105
|
+
* @returns {string} Complete HTML document
|
|
106
|
+
*/
|
|
107
|
+
export function generatePage(config, options = {}) {
|
|
108
|
+
const configDir = options.configDir || process.cwd();
|
|
109
|
+
const now = options.now || new Date();
|
|
110
|
+
|
|
111
|
+
const name = config.name || 'My Links';
|
|
112
|
+
const bio = config.bio || '';
|
|
113
|
+
const theme = config.theme || 'minimal-dark';
|
|
114
|
+
|
|
115
|
+
// Load and inline CSS
|
|
116
|
+
const css = loadThemeCSS(theme);
|
|
117
|
+
|
|
118
|
+
// Filter links by schedule
|
|
119
|
+
const allLinks = config.links || [];
|
|
120
|
+
const links = filterScheduled(allLinks, now);
|
|
121
|
+
const totalCount = allLinks.length;
|
|
122
|
+
const activeCount = links.length;
|
|
123
|
+
const buildDate = now.toISOString().slice(0, 10);
|
|
124
|
+
|
|
125
|
+
// Avatar handling
|
|
126
|
+
let avatarHTML = '';
|
|
127
|
+
if (config.avatar && config.avatar.trim().length > 0) {
|
|
128
|
+
try {
|
|
129
|
+
const dataUri = inlineImage(config.avatar, configDir);
|
|
130
|
+
avatarHTML = `<img class="profile__avatar" src="${dataUri}" alt="${esc(name)}" width="88" height="88">`;
|
|
131
|
+
} catch {
|
|
132
|
+
// Fallback: reference the file directly (it will be copied to output dir)
|
|
133
|
+
avatarHTML = `<img class="profile__avatar" src="${esc(config.avatar)}" alt="${esc(name)}" width="88" height="88">`;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Build link list HTML
|
|
138
|
+
const linksHTML = links.map((link) => {
|
|
139
|
+
const icon = link.icon ? `<span class="links__icon">${esc(link.icon)}</span>` : '';
|
|
140
|
+
const description = link.description
|
|
141
|
+
? `<span class="links__description">${esc(link.description)}</span>`
|
|
142
|
+
: '';
|
|
143
|
+
|
|
144
|
+
return ` <li class="links__item">
|
|
145
|
+
<a class="links__anchor" href="${esc(link.url)}" target="_blank" rel="noopener noreferrer">
|
|
146
|
+
<span class="links__label">${icon}${esc(link.label)}</span>${description}
|
|
147
|
+
</a>
|
|
148
|
+
</li>`;
|
|
149
|
+
}).join('\n');
|
|
150
|
+
|
|
151
|
+
// OG description falls back to bio, then a generic string
|
|
152
|
+
const ogDescription = bio || `${name} — link page`;
|
|
153
|
+
|
|
154
|
+
return `<!DOCTYPE html>
|
|
155
|
+
<!-- built at ${buildDate}, ${activeCount} of ${totalCount} links active -->
|
|
156
|
+
<html lang="en">
|
|
157
|
+
<head>
|
|
158
|
+
<meta charset="utf-8">
|
|
159
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
160
|
+
<title>${esc(name)}</title>
|
|
161
|
+
<meta name="description" content="${esc(ogDescription)}">
|
|
162
|
+
<meta property="og:title" content="${esc(name)}">
|
|
163
|
+
<meta property="og:description" content="${esc(ogDescription)}">
|
|
164
|
+
<meta property="og:type" content="website">
|
|
165
|
+
<style>
|
|
166
|
+
${css}
|
|
167
|
+
</style>
|
|
168
|
+
</head>
|
|
169
|
+
<body>
|
|
170
|
+
<section class="profile">
|
|
171
|
+
${avatarHTML}
|
|
172
|
+
<h1 class="profile__name">${esc(name)}</h1>
|
|
173
|
+
${bio ? ` <p class="profile__bio">${esc(bio)}</p>\n` : ''} </section>
|
|
174
|
+
|
|
175
|
+
<ul class="links">
|
|
176
|
+
${linksHTML}
|
|
177
|
+
</ul>
|
|
178
|
+
|
|
179
|
+
<footer class="footer">
|
|
180
|
+
<a class="footer__link" href="https://kntic.ai">Powered by KNTIC Links</a>
|
|
181
|
+
</footer>
|
|
182
|
+
</body>
|
|
183
|
+
</html>
|
|
184
|
+
`;
|
|
185
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# KNTIC Links — Theme System
|
|
2
|
+
|
|
3
|
+
Themes are **plain CSS files** that live in `src/themes/`. No build step, no
|
|
4
|
+
preprocessor. Each file defines a complete visual identity for a link page by
|
|
5
|
+
implementing a fixed set of **CSS custom properties** (design tokens) on `:root`
|
|
6
|
+
and using them throughout.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Quick start — creating a new theme
|
|
11
|
+
|
|
12
|
+
1. Copy an existing theme (e.g. `minimal-dark.css`) to a new file:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
cp src/themes/minimal-dark.css src/themes/my-theme.css
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
2. Edit the `:root` block to change colours, fonts, radii, etc.
|
|
19
|
+
|
|
20
|
+
3. Set the theme in your project's `links.yaml`:
|
|
21
|
+
|
|
22
|
+
```yaml
|
|
23
|
+
theme: my-theme
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
4. Run `links deploy --self` to see the result.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Custom property contract
|
|
31
|
+
|
|
32
|
+
Every theme **must** declare all of the following custom properties inside a
|
|
33
|
+
`:root { … }` rule. The generator and the base HTML structure depend on these
|
|
34
|
+
tokens — omitting any of them will produce broken or inconsistent output.
|
|
35
|
+
|
|
36
|
+
| Token | Purpose | Example value |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `--bg-color` | Page background colour | `#0d0d0d` |
|
|
39
|
+
| `--bg-secondary` | Card / surface background | `#1a1a1a` |
|
|
40
|
+
| `--text-primary` | Primary text colour | `#f0f0f0` |
|
|
41
|
+
| `--text-secondary` | Secondary text colour (descriptions) | `#cccccc` |
|
|
42
|
+
| `--text-muted` | Muted / de-emphasised text | `#999999` |
|
|
43
|
+
| `--accent-color` | Primary accent (link text, highlights) | `#e0e0e0` |
|
|
44
|
+
| `--accent-hover` | Accent on hover / focus | `#ffffff` |
|
|
45
|
+
| `--link-bg` | Link card background | `var(--bg-secondary)` |
|
|
46
|
+
| `--link-bg-hover` | Link card background on hover | `#252525` |
|
|
47
|
+
| `--link-border` | Link card border colour | `#333333` |
|
|
48
|
+
| `--link-radius` | Link card border-radius | `8px` |
|
|
49
|
+
| `--link-padding` | Link card inner padding | `0.875rem 1.25rem` |
|
|
50
|
+
| `--font-body` | Body font stack | `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif` |
|
|
51
|
+
| `--font-mono` | Monospace font stack (if needed) | `'SF Mono', 'Fira Code', monospace` |
|
|
52
|
+
| `--avatar-radius` | Avatar image border-radius | `50%` |
|
|
53
|
+
| `--page-max-width` | Max width of the page content column | `480px` |
|
|
54
|
+
| `--footer-opacity` | Footer link resting opacity | `0.6` |
|
|
55
|
+
|
|
56
|
+
### Rules
|
|
57
|
+
|
|
58
|
+
* **No hardcoded colours outside `:root`** — every colour value used in
|
|
59
|
+
selectors must reference one of the tokens above.
|
|
60
|
+
* Themes may add *extra* custom properties for internal use but must **not**
|
|
61
|
+
remove or rename any of the standard tokens.
|
|
62
|
+
* The base reset (`box-sizing`, `margin`, `padding`) should be included in
|
|
63
|
+
every theme so each theme is fully self-contained.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Loader API (`src/themes/loader.js`)
|
|
68
|
+
|
|
69
|
+
The loader is a pure ES-module with two exports:
|
|
70
|
+
|
|
71
|
+
### `loadTheme(themeName)`
|
|
72
|
+
|
|
73
|
+
Resolves a theme name (e.g. `"minimal-dark"`) to the corresponding `.css` file
|
|
74
|
+
in the themes directory, reads it, and returns the raw CSS string.
|
|
75
|
+
|
|
76
|
+
* Throws a descriptive `Error` if the theme is not found (includes list of
|
|
77
|
+
available themes).
|
|
78
|
+
* Sanitises the input to prevent directory-traversal (uses `path.basename`).
|
|
79
|
+
|
|
80
|
+
### `listThemes()`
|
|
81
|
+
|
|
82
|
+
Returns a sorted `string[]` of available theme names (filenames without the
|
|
83
|
+
`.css` extension).
|
|
84
|
+
|
|
85
|
+
```js
|
|
86
|
+
import { loadTheme, listThemes } from './themes/loader.js';
|
|
87
|
+
|
|
88
|
+
console.log(listThemes()); // ['minimal-dark']
|
|
89
|
+
const css = loadTheme('minimal-dark');
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## File structure
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
src/themes/
|
|
98
|
+
├── loader.js # Theme loader module
|
|
99
|
+
├── minimal-dark.css # Default theme
|
|
100
|
+
├── README.md # This file
|
|
101
|
+
└── <your-theme>.css # Add your own here
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Integration with the generator
|
|
107
|
+
|
|
108
|
+
The generator (`src/generator.js`) loads the theme specified in `links.yaml`
|
|
109
|
+
and inlines the full CSS into a `<style>` block in the generated HTML. The
|
|
110
|
+
output is a single self-contained file — no external stylesheets.
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/* developer — hacker / IDE aesthetic for KNTIC Links
|
|
2
|
+
*
|
|
3
|
+
* Monospace everywhere. Links styled as code blocks.
|
|
4
|
+
* KNTIC orange accent. Left border bar on links (VSCode-style).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap');
|
|
8
|
+
|
|
9
|
+
*,
|
|
10
|
+
*::before,
|
|
11
|
+
*::after {
|
|
12
|
+
box-sizing: border-box;
|
|
13
|
+
margin: 0;
|
|
14
|
+
padding: 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
:root {
|
|
18
|
+
/* Backgrounds */
|
|
19
|
+
--bg-color: #1e1e1e;
|
|
20
|
+
--bg-secondary: #252526;
|
|
21
|
+
|
|
22
|
+
/* Text */
|
|
23
|
+
--text-primary: #d4d4d4;
|
|
24
|
+
--text-secondary: #9cdcfe;
|
|
25
|
+
--text-muted: #6a9955;
|
|
26
|
+
|
|
27
|
+
/* Accent — KNTIC orange */
|
|
28
|
+
--accent-color: #FF4500;
|
|
29
|
+
--accent-hover: #ff6a33;
|
|
30
|
+
|
|
31
|
+
/* Link cards */
|
|
32
|
+
--link-bg: var(--bg-secondary);
|
|
33
|
+
--link-bg-hover: #2d2d30;
|
|
34
|
+
--link-border: #3e3e42;
|
|
35
|
+
--link-radius: 3px;
|
|
36
|
+
--link-padding: 0.75rem 1rem 0.75rem 1.15rem;
|
|
37
|
+
|
|
38
|
+
/* Typography */
|
|
39
|
+
--font-body: 'JetBrains Mono', Menlo, Monaco, 'Cascadia Code', 'Courier New', monospace;
|
|
40
|
+
--font-mono: 'JetBrains Mono', Menlo, Monaco, 'Cascadia Code', 'Courier New', monospace;
|
|
41
|
+
|
|
42
|
+
/* Avatar */
|
|
43
|
+
--avatar-radius: 4px;
|
|
44
|
+
|
|
45
|
+
/* Layout */
|
|
46
|
+
--page-max-width: 540px;
|
|
47
|
+
|
|
48
|
+
/* Footer */
|
|
49
|
+
--footer-opacity: 0.45;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* ── Base ────────────────────────────────────────────────────────── */
|
|
53
|
+
|
|
54
|
+
html {
|
|
55
|
+
font-size: 16px;
|
|
56
|
+
-webkit-text-size-adjust: 100%;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
body {
|
|
60
|
+
font-family: var(--font-body);
|
|
61
|
+
background-color: var(--bg-color);
|
|
62
|
+
color: var(--text-primary);
|
|
63
|
+
min-height: 100vh;
|
|
64
|
+
display: flex;
|
|
65
|
+
flex-direction: column;
|
|
66
|
+
align-items: center;
|
|
67
|
+
padding: 2.5rem 1rem 1rem;
|
|
68
|
+
line-height: 1.6;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* ── Profile ─────────────────────────────────────────────────────── */
|
|
72
|
+
|
|
73
|
+
.profile {
|
|
74
|
+
text-align: left;
|
|
75
|
+
margin-bottom: 1.5rem;
|
|
76
|
+
max-width: var(--page-max-width);
|
|
77
|
+
width: 100%;
|
|
78
|
+
padding-bottom: 1rem;
|
|
79
|
+
border-bottom: 1px solid var(--link-border);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.profile__avatar {
|
|
83
|
+
width: 64px;
|
|
84
|
+
height: 64px;
|
|
85
|
+
border-radius: var(--avatar-radius);
|
|
86
|
+
object-fit: cover;
|
|
87
|
+
margin-bottom: 0.75rem;
|
|
88
|
+
border: 1px solid var(--link-border);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.profile__name {
|
|
92
|
+
font-size: 1.1rem;
|
|
93
|
+
font-weight: 700;
|
|
94
|
+
margin-bottom: 0.25rem;
|
|
95
|
+
color: var(--accent-color);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* File-path style decoration */
|
|
99
|
+
.profile__name::before {
|
|
100
|
+
content: '~/';
|
|
101
|
+
color: var(--text-muted);
|
|
102
|
+
font-weight: 400;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.profile__bio {
|
|
106
|
+
font-size: 0.8rem;
|
|
107
|
+
color: var(--text-muted);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.profile__bio::before {
|
|
111
|
+
content: '/* ';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.profile__bio::after {
|
|
115
|
+
content: ' */';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* ── Links ───────────────────────────────────────────────────────── */
|
|
119
|
+
|
|
120
|
+
.links {
|
|
121
|
+
list-style: none;
|
|
122
|
+
width: 100%;
|
|
123
|
+
max-width: var(--page-max-width);
|
|
124
|
+
display: flex;
|
|
125
|
+
flex-direction: column;
|
|
126
|
+
gap: 0.375rem;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.links__item {
|
|
130
|
+
display: block;
|
|
131
|
+
width: 100%;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.links__anchor {
|
|
135
|
+
display: block;
|
|
136
|
+
width: 100%;
|
|
137
|
+
padding: var(--link-padding);
|
|
138
|
+
background-color: var(--link-bg);
|
|
139
|
+
border: 1px solid var(--link-border);
|
|
140
|
+
border-left: 3px solid var(--accent-color);
|
|
141
|
+
border-radius: var(--link-radius);
|
|
142
|
+
color: var(--text-primary);
|
|
143
|
+
text-decoration: none;
|
|
144
|
+
text-align: left;
|
|
145
|
+
font-family: var(--font-mono);
|
|
146
|
+
transition: background-color 0.15s ease, border-left-color 0.15s ease;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.links__anchor:hover,
|
|
150
|
+
.links__anchor:focus {
|
|
151
|
+
background-color: var(--link-bg-hover);
|
|
152
|
+
color: var(--text-primary);
|
|
153
|
+
border-left-color: var(--accent-hover);
|
|
154
|
+
outline: none;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* Filename-style label prefix */
|
|
158
|
+
.links__anchor::before {
|
|
159
|
+
content: '$ open ';
|
|
160
|
+
color: var(--text-muted);
|
|
161
|
+
font-size: 0.8rem;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.links__label {
|
|
165
|
+
font-size: 0.85rem;
|
|
166
|
+
font-weight: 500;
|
|
167
|
+
color: var(--text-secondary);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.links__icon {
|
|
171
|
+
margin-right: 0.4em;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.links__description {
|
|
175
|
+
display: block;
|
|
176
|
+
font-size: 0.75rem;
|
|
177
|
+
color: var(--text-muted);
|
|
178
|
+
margin-top: 0.15rem;
|
|
179
|
+
padding-left: 5.2em;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.links__description::before {
|
|
183
|
+
content: '# ';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* ── Footer ──────────────────────────────────────────────────────── */
|
|
187
|
+
|
|
188
|
+
.footer {
|
|
189
|
+
margin-top: auto;
|
|
190
|
+
padding-top: 2rem;
|
|
191
|
+
padding-bottom: 1rem;
|
|
192
|
+
text-align: center;
|
|
193
|
+
max-width: var(--page-max-width);
|
|
194
|
+
width: 100%;
|
|
195
|
+
border-top: 1px solid var(--link-border);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.footer__link {
|
|
199
|
+
font-size: 0.65rem;
|
|
200
|
+
color: var(--text-muted);
|
|
201
|
+
text-decoration: none;
|
|
202
|
+
opacity: var(--footer-opacity);
|
|
203
|
+
font-family: var(--font-mono);
|
|
204
|
+
transition: opacity 0.2s ease;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.footer__link:hover {
|
|
208
|
+
opacity: 1;
|
|
209
|
+
color: var(--accent-color);
|
|
210
|
+
}
|